Exotica

5 min read

Core idea

The bash features collected here are the ones most scripts never need — and the ones a few scripts can't be written without. Group commands and subshells bundle multiple commands for shared redirection. Process substitution turns the output of a subshell into a file-like path you can pass anywhere a filename is expected. eval lets you build commands from strings at runtime. Traps install handlers for signals so cleanup happens even when the script is killed. Asynchronous execution with & and wait lets a script supervise its own background workers. Named pipes (FIFOs) are persistent file-system endpoints two processes can use to stream data. Each tool is a sharp edge — useful in the right place, dangerous everywhere else.

Why it matters

The everyday subset of bash — variables, loops, conditionals, pipelines — covers maybe 90% of scripting. The last 10% is where these "exotica" live, and it's also where the genuinely hard problems show up: keeping a script idempotent when interrupted, parallelising work without blowing up the parent shell, doing client–server IPC without inventing a protocol, and writing constructs that bash itself didn't originally support (like while read inside a pipeline that needs to mutate parent variables). Knowing these features exist is half the win — the other half is recognising when a problem really needs them, versus when a less exotic shape would do.

Mental model

The exotica family tree

These features cluster around three concerns: composing commands (group/subshell, process substitution), reacting to events (traps, eval-built dispatch), and orchestrating other processes (background jobs, wait, FIFOs).

The exotica family tree

Group commands vs. subshells

The single most useful distinction in this set. They look almost identical and serve almost the same purpose; the difference is where state lives.

| Feature | { cmds; } | ( cmds ) | | ------------------------------------ | --------------------- | -------------------- | | Runs in | current shell | child subshell | | Mutates parent variables | yes | no | | cd affects subsequent commands | yes | no | | Required syntax | spaces + final ;/newline | no spaces needed | | Performance | faster (no fork) | slower (forks) | | When to prefer | the default | isolation / cleanup |

The book's pithy rule: prefer group commands unless you specifically need the isolation a subshell provides — a temporary cd, a scoped set -e, a one-off environment change.

Process substitution — why and when

The classic trap: cmd | while read line; do total=$((total + 1)); done. After the loop, $total is zero — the while ran in a subshell created by the pipeline, and its variables died with it. Process substitution sidesteps the subshell:

while read line; do
  total=$((total + 1))
done < <(cmd)
echo "$total"   # now actually has the count

The <(cmd) expands to a path like /dev/fd/63 that bash hands the loop directly via <. The loop runs in the parent shell; only cmd runs in the subshell. Same trick going the other way with >(cmd):

# tee to two compressors in parallel
generate_data | tee >(gzip > a.gz) >(xz > a.xz) > /dev/null

Traps — the cleanup contract

A trap is a promise the script makes to itself: when this signal arrives, do this work. The three signals worth knowing by name:

Traps — the cleanup contract

Async execution and named pipes

cmd & launches a job in the background and returns immediately. $! records its PID; wait $! blocks until it exits; wait with no argument blocks on all children. This is the foundation of "parallelise N independent units" patterns — kick them off in a loop, capture their PIDs in an array, then wait for each.

Named pipes (mkfifo) push further: two processes that don't share a parent can connect via a file-system path. One writes, the other reads, bash blocks each side until both are ready. They're the simplest IPC primitive on Unix and underrated for ad-hoc client–server tooling.

Practical application

  1. Default to { … } for grouping. Reach for ( … ) only when you need a one-off environment change (cd, set -e, an env-var override) that must not leak.

  2. Banish "while-read inside a pipeline" with process substitution. Whenever a loop needs to mutate parent state from another command's output, switch from cmd | while … to while … < <(cmd).

  3. Install a cleanup trap as soon as you create temp state. The pattern: tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT INT TERM. Two lines, every script that touches /tmp.

  4. Capture PIDs in an array for parallel work. pids=(); for url in "${urls[@]}"; do fetch "$url" & pids+=($!); done; wait "${pids[@]}". This is the canonical fan-out / wait pattern.

  5. Audit every eval. eval is the easiest way to introduce code injection in a shell script. If the string passed to eval is built from any external input, sanitise it ruthlessly — or rewrite to avoid eval entirely.

Example

A backup script combining most of the exotica: a trap-protected workspace, parallel compression of multiple databases via background jobs, and a process-substitution reporter that surfaces per-job results to the parent shell without sub-shell variable loss:

#!/usr/bin/env bash
set -u
workdir=$(mktemp -d -t backup.XXXXXX)
trap 'rm -rf "$workdir"' EXIT INT TERM

databases=(main analytics audit)
declare -A status

# Fan out: dump each DB in parallel, recording PIDs.
pids=()
for db in "${databases[@]}"; do
  ( pg_dump "$db" | gzip > "$workdir/$db.sql.gz" ) &
  pids+=($!)
  status[${pids[-1]}]=$db   # PID → db name
done

# Wait on each, harvesting exit codes without losing state.
ok=0; failed=0
for pid in "${pids[@]}"; do
  if wait "$pid"; then
    (( ok++ ))
  else
    (( failed++ ))
    echo "FAILED: ${status[$pid]}" >&2
  fi
done

# Process substitution to feed a totals loop without a subshell.
total_bytes=0
while read -r bytes _; do
  (( total_bytes += bytes ))
done < <(du -b "$workdir"/*.sql.gz)

printf 'Backup complete: %d ok, %d failed, %d bytes\n' \
  "$ok" "$failed" "$total_bytes"

Five exotica in twenty lines: mktemp for safe temp dirs, trap for cleanup, subshells for isolated pipelines, & + wait + $! for parallel work, and < <(…) to keep the byte-counting accumulator in the parent shell. Each one is doing exactly the job it was designed for.

Continue exploring

Tags