Your shell scripts crash. trap EXIT is why the mess does not have to survive.
Published by RodHat

Here is what happens. Someone writes a shell script that creates a temporary file at the top, does work in the middle, and at the bottom deletes the temporary file. Then someone sends it a SIGTERM at step five. The temp file lives in /tmp until the next reboot, or longer. The script runs every hour from cron, so next week there are a hundred and sixty-eight of them.
I have cleaned up other people’s /tmp disasters more times than I care to count. The fix is one shell builtin: trap.
What trap does
trap registers a command to run when the shell receives a signal or hits a special condition. POSIX, works in any sh. Syntax:
trap 'command or function name' SIGNAL [SIGNAL...]
Signals are the usual suspects: INT (Ctrl-C), TERM (kill), HUP (terminal close), QUIT, USR1, USR2. Plus three pseudo-signals the shell treats specially:
EXIT— runs when the shell exits for any reason: normal completion, uncaught signal,exit N,set -etriggered abort. This is the one you want.ERR— runs when a command exits non-zero (with grammar-dependent caveats — below).DEBUG— runs before every command. Useful for tracing, annoying in production.
EXIT is the important one. It fires regardless of how the script exits — normal, signal death, error abort, exit 1 from anywhere in the body. The shell’s version of defer in Go or finally in languages that have it.
The correct pattern
#!/bin/sh
set -eu
tmpfile=$(mktemp)
cleanup() {
rm -f "$tmpfile"
}
trap cleanup EXIT
# ... rest of script uses $tmpfile ...
Three things working together:
mktemp generates a unique path in /tmp with proper permissions (0600 by default on any sane system). Never construct temp paths by hand — /tmp/myscript.$$ is a symlink race condition waiting to happen. mktemp avoids it.
cleanup() does the actual work. Function, not inline command — this way you can add more cleanup later without rewriting the trap line, and you can call it explicitly from multiple places.
trap cleanup EXIT registers it before you do any work that creates state. Register early. If mktemp itself fails, the script exits before creating anything, so there is nothing to clean up. If mktemp succeeds and the script dies at line three of the body, EXIT fires and rm -f "$tmpfile" runs. The -f handles the case where the file was already gone.
Signal handling for long-running scripts
EXIT alone is enough for short scripts. If your script runs a long operation or manages child processes, add explicit signal handling:
#!/bin/sh
set -eu
pids=""
tmpfile=$(mktemp)
cleanup() {
# Kill any still-running background children
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
rm -f "$tmpfile"
}
trap cleanup INT TERM EXIT
kill 0 is an alternative to the pid loop — it sends SIGTERM to the entire process group (the script and all children it started). Fine if you want everything gone immediately. The pid loop is safer if you need to selectively kill workers while preserving something else.
wait after the kills blocks until all children exit. Without it, the parent exits and the children become orphans under init. That is the other common mess: zombie processes and file handles held open by children who outlived their parent.
The ERR trap and set -e: where it gets annoying
ERR fires when a command exits non-zero — same condition as set -e. They interact in ways that catch people off guard:
set -e
trap 'echo "failed at line $LINENO, exit $?" >&2' ERR
$LINENO in the ERR handler gives you the line number of the failing command. $? gives you the exit code. Useful. The catch: ERR does not fire inside if conditions, || and && constructs, or commands prefixed with !. The rule is: any context where the shell grammar expects a non-zero exit to be handled by the surrounding construct does not trigger ERR. This is the same context where set -e also does not abort.
# This does NOT trigger ERR — grep's non-zero exit is handled by the if
if grep -q "pattern" file; then
echo "found"
fi
# This does NOT trigger ERR — non-zero handled by ||
grep -q "pattern" file || echo "not found"
# This DOES trigger ERR — naked non-zero exit
grep -q "pattern" file
Practical upshot: use EXIT for cleanup, use ERR sparingly and only for logging/diagnosis. Do not try to replicate set -e behavior in the ERR handler — they overlap in confusing ways. If you want the script to abort on errors, set -e. If you want cleanup, trap cleanup EXIT. Do not need both for most scripts.
Subshells do not inherit traps
Subshells — $(...), explicit ( ... ), pipelines — do not inherit traps from the parent. Each subshell starts with traps reset to their defaults.
trap 'echo "parent exit"' EXIT
(
# This subshell exits cleanly, no trap fires here
echo "in subshell"
exit 1
)
# Parent sees exit code of the subshell, continues if set -e not set
# Parent's EXIT trap fires when the parent eventually exits
Background jobs started with & are subshells and also don’t inherit traps. If you want a background worker to clean up its own state, it needs its own trap:
worker() {
local wtmp
wtmp=$(mktemp)
trap 'rm -f "$wtmp"' EXIT
# ... do work ...
}
worker &
pids="$pids $!"
Each invocation of worker is a subshell with its own trap. The parent’s trap does not cover the worker’s temp files; the worker’s trap does.
Inspecting and resetting traps
To remove a trap, reset it to the default signal disposition:
trap - EXIT # reset EXIT to default (no action on exit)
trap - INT # reset INT to default (terminate)
To see what traps are currently registered:
trap -p
Prints all traps in a form you can eval. Useful inside a library function that needs to save and restore the caller’s trap:
save_exit_trap() {
old_exit_trap=$(trap -p EXIT)
trap 'new_cleanup' EXIT
# ... do stuff ...
eval "$old_exit_trap" # restore caller's trap
}
A complete example
#!/bin/sh
set -eu
tmpdir=$(mktemp -d)
pids=""
cleanup() {
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
rm -rf "$tmpdir"
}
trap cleanup EXIT INT TERM
# Spawn workers, record pids
for item in "$@"; do
process_item "$item" > "$tmpdir/$item.out" &
pids="$pids $!"
done
wait # wait for all workers
cat "$tmpdir"/*.out
Kill it with Ctrl-C halfway through. Send it SIGTERM from the scheduler. Let one of the workers crash with a non-zero exit and set -e abort the parent. In every case: cleanup runs, $tmpdir disappears, workers are killed, no orphans.
Why this matters more than it sounds
The standard defense is “I clear /tmp manually” or “cron scripts are short enough it doesn’t matter.” That works until: the temp file contains half-written data the next run interprets as valid input; a background child holds a file lock the next invocation can’t acquire; the cleanup would have happened on the next run but this system is getting decommissioned and the next run is never.
Shell scripts are infrastructure. They run from cron, from init, from deployment pipelines. They get SIGTERM’d during reboots and SIGKILL’d when nodes are drained. Writing them without trap is the same kind of sloppiness as malloc without checking the return value — it works exactly until the conditions that expose the sloppiness arrive.
Three lines. mktemp. cleanup(). trap cleanup EXIT. Write them first, before the rest of the script, every time.
The POSIX spec defines trap behavior precisely, including the grammar rules that determine when ERR fires — section 2.14 of the POSIX.1 Shell Command Language spec. For Unix signal semantics underneath the shell abstraction (what SIGTERM actually means, how signal disposition is inherited across fork/exec, why SIGKILL cannot be caught), Kerrisk’s The Linux Programming Interface covers it thoroughly in Chapters 20-22. Stevens’ Advanced Programming in the UNIX Environment handles the same ground from the POSIX application layer in Chapter 10.