Shell Signal Handling: trap, Cleanup, and Process Groups
Reliable shell cleanup requires understanding signals, traps, process groups, and the important difference between a normal exit and an uncatchable termination.
Shell scripts often create temporary files, start child processes, or partially update state. trap can make cleanup reliable, but only when the script distinguishes shell exit handling from Unix signal delivery.
A trap replaces the shell’s default action
cleanup() {
rm -rf -- "$workdir"
}
workdir=$(mktemp -d) || exit 1
trap cleanup EXIT HUP INT TERM
EXIT is a shell pseudo-condition: the handler runs when the shell exits normally or after a caught signal leads to exit. HUP, INT, and TERM are real signals. Quoting the handler at trap-definition time delays variable expansion until it runs; using a function is easier to read and extend.
SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. No trap can guarantee cleanup after kill -9, a kernel crash, or power loss. That is why temporary-state design still matters even when traps exist.
Ctrl-C targets a foreground process group
An interactive terminal does not simply send SIGINT to “the shell.” Its terminal driver sends the signal to the current foreground process group. When a script launches a foreground command, that command and potentially the script can both participate in the terminal job-control arrangement.
This explains why signal behavior can change when a command is placed in a pipeline or backgrounded. Shells also differ in some details around waiting for children and running traps, so portable scripts should keep handlers simple and test the actual shells they support.
Preserve the reason for termination
A cleanup handler that always exits zero can hide failure. One robust pattern captures the current status:
cleanup() {
status=$?
trap - EXIT HUP INT TERM
rm -rf -- "$workdir"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
Resetting the traps prevents recursion. For applications that need conventional signal-derived statuses, separate handlers can clean up and then re-raise the original signal after restoring its default action.
Keep traps idempotent and narrow
A handler may run after only half the setup completed, so cleanup must tolerate missing files and already-stopped children. Avoid large amounts of business logic in signal handlers. Record a stop request, terminate owned children deliberately, remove private temporary data, and let the main flow decide whether retry or rollback is safe.
Most importantly, never use a broad command such as kill 0 without understanding the current process group: it can signal the calling shell or unrelated jobs sharing that group.
Bash’s traps beyond EXIT and real signals
trap 'echo "line $LINENO: $BASH_COMMAND"' DEBUG
trap 'echo "function returned"' RETURN
trap 'echo "command failed: $BASH_COMMAND" >&2' ERR
Bash extends trap with three pseudo-conditions beyond EXIT and genuine Unix signals. DEBUG fires before every simple command, useful for tracing execution beyond what set -x shows. RETURN fires when a shell function or sourced file finishes. ERR fires when a command exits with nonzero status, in roughly the same circumstances that would trigger set -e to abort the script — the two interact, and an ERR trap does not by itself stop execution the way set -e does; it merely lets a handler run before whatever set -e, or explicit exit-code checking, does next.
Listing what a shell actually recognizes
trap -l
kill -l
Both trap -l and kill -l print the signal names a shell or system recognizes, mapped to their numbers — useful because signal numbers are not perfectly portable across Unix variants even when the common names (SIGINT, SIGTERM, SIGHUP) are consistent. A script that must specify a signal numerically for some reason should still prefer resolving it from the name at runtime rather than hardcoding a number that could refer to a different signal on a different kernel.
A graceful-shutdown pattern for a long-running script
running=1
trap 'running=0' TERM INT
while [ "$running" -eq 1 ]; do
do_one_unit_of_work
done
echo "shut down cleanly"
A trap handler does not have to run cleanup and exit immediately — setting a flag variable and letting the script’s own main loop notice it on its next iteration is a common, deliberately simple pattern for scripts that need to finish an in-progress unit of work rather than being interrupted mid-operation. This trades immediate responsiveness to the signal for correctness: the script still terminates promptly in practice, just after completing whatever it was doing at the moment the signal arrived, rather than abandoning it partway through.
Why a trap defined with single quotes behaves differently than double
name=world
trap "echo hello $name" EXIT # $name expands now, when trap runs
trap 'echo hello $name' EXIT # $name expands later, when the trap fires
Double-quoting a trap’s action string lets the shell expand variables immediately, at the moment the trap command itself executes — freezing whatever value $name held right then into the handler. Single-quoting delays that expansion until the signal or EXIT condition actually fires, evaluating $name against whatever it holds at that later point. Neither choice is universally correct; it depends entirely on whether a handler should capture a snapshot or reflect current state, and picking the wrong one silently produces a handler that reports stale information without ever raising an error to reveal the mistake.
Traps do not automatically extend into subshells or command substitutions
A trap set in a script’s top-level shell is not automatically active inside a subshell spawned by that script, a background job it starts, or a command substitution — each of those runs as its own process with its own default signal dispositions unless it explicitly sets its own trap. This matters most for EXIT: a trap intended to clean up when the whole script finishes will not fire again for every subshell the script happens to spawn along the way, only for the shell that actually defined it, once that specific shell exits. A script relying on cleanup happening inside a subshell needs to set an equivalent trap inside that subshell explicitly rather than assuming inheritance that does not occur.
Where signal handling actually comes from
Signals are a kernel mechanism specified well before any of this shell-level tooling — POSIX formalized a common signal set and semantics that Unix-family kernels already mostly agreed on by the time of standardization, and a shell’s trap builtin is fundamentally a thin, POSIX-specified interface onto the kernel’s own signal-delivery machinery, not shell-invented behavior. This is why signal names and basic delivery semantics stay consistent across scripting languages entirely — a Python or Node.js process handling SIGTERM relies on the same kernel-level concept a shell script’s trap ... TERM does, just through a different language-level API onto identical kernel machinery.
Related:
- Job Control: How Shells Manage Foreground and Background Processes
- Fixing ‘Broken Pipe’ Errors in Shell Scripts and Pipelines
Sources: