Skip to content
Shell & TerminalDeep Dive Published Updated 6 min readViews unavailable

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() {
  status=$?
  trap - EXIT
  rm -rf -- "$workdir" || printf '%s\n' 'warning: could not remove work directory' >&2
  exit "$status"
}

workdir=$(mktemp -d) || exit 1
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' 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
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

The signal actions exit with conventional 128-plus-signal statuses, which invoke the single EXIT cleanup path. Resetting the EXIT trap inside cleanup prevents recursion. Applications that require termination by the original signal rather than a conventional status can instead restore that signal’s default action and re-raise it, but that is a separate policy which needs careful process-group testing.

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     # Alternative A: capture $name now
# Or, instead of the line above:
trap 'echo hello $name' EXIT     # Alternative B: expand $name when EXIT fires

Choose one of the two trap commands; because each replaces the EXIT action, running both leaves only the second handler installed. Double-quoting lets the shell expand variables immediately, when trap executes, freezing the current value into the handler. Single-quoting delays expansion until the EXIT condition fires, using the later value. Neither choice is universally correct; decide whether cleanup should capture a snapshot or reflect current state.

Traps do not automatically extend into subshells or command substitutions

Do not assume a top-level trap is a child-process cleanup mechanism. EXIT cleanup belongs to the shell that registered it; it does not automatically clean each background utility’s state. Subshell environments and command substitutions have shell-defined trap inheritance/reset rules (Bash resets caught traps in several subshell environments), while an external program receives signal dispositions according to the exec rules. If a child owns temporary files or must handle a signal itself, establish that child’s cleanup policy explicitly and test it in the target shell; keep the parent’s cleanup for parent-owned resources.

Where signal handling actually comes from

Signals are an operating-system process mechanism, while trap is the shell-language interface for handling specified signal conditions and shell exit. On POSIX systems, a Python or Node.js process handling a signal such as SIGTERM uses the host’s signal facility too, although each runtime and operating system has its own restrictions and details. Prefer signal names over hard-coded numbers, and do not assume identical behavior across Unix variants or non-POSIX platforms.

Related:

Sources:

Comments