Traps and Signals
Register cleanup code with trap EXIT/INT/TERM, understand Unix signals, use kill and wait for process management, and handle subshell signal propagation.
- Register handlers for EXIT, INT, and TERM signals with trap
- List common Unix signals and their meanings
- Clean up temp files reliably with trap EXIT
- Send signals to processes with kill and wait for them with wait
- Explain how signals propagate through subshells and pipelines
Production scripts run in unpredictable environments: the user presses Ctrl-C, the system sends SIGTERM before a reboot, a dependent service fails and the script is killed. Without signal handling, these interruptions leave stale lock files, half-written outputs, and dangling background processes. trap is Bash's mechanism for registering cleanup code that runs no matter how the script ends.
Unix signals
A signal is an asynchronous notification sent to a process. Common ones:
| Signal | Number | Default action | Sent by |
|---|---|---|---|
SIGHUP | 1 | Terminate | Terminal closed |
SIGINT | 2 | Terminate | Ctrl-C |
SIGTERM | 15 | Terminate | kill (default), system shutdown |
SIGKILL | 9 | Force kill (uncatchable) | kill -9 |
SIGPIPE | 13 | Terminate | Write to closed pipe |
SIGUSR1 | 10 | Terminate | Application-defined |
Scripts cannot catch SIGKILL — it goes directly to the kernel. For everything else, trap lets you intercept and handle the signal.
trap syntax
trap 'command or function' SIGNAL [SIGNAL2 ...]
trap 'cleanup' EXIT # runs when the shell exits (any reason except SIGKILL)
trap 'cleanup' INT TERM # runs on Ctrl-C or kill <pid>
trap '' SIGPIPE # ignore SIGPIPE (useful for writer scripts)
trap - SIGINT # restore default behaviour for SIGINTThe command is evaluated in the current shell at the time the signal is received, so variable values at that point are used.
trap EXIT for reliable cleanup
trap EXIT is the most important form — it fires when the script exits for any reason (including set -e failures):
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
LOCKFILE="/var/run/myscript.lock"
cleanup() {
rm -rf "$TMPDIR"
rm -f "$LOCKFILE"
echo "Cleaned up." >&2
}
trap cleanup EXIT
# Acquire lock
touch "$LOCKFILE"
# Work here — any failure exits cleanly via set -e, and cleanup runs
cp data.csv "$TMPDIR/"
process "$TMPDIR/data.csv"Handling INT and TERM explicitly
Sometimes you need to distinguish between a normal exit and an interruption — for example, to log that the script was cancelled:
interrupted=false
on_int() {
interrupted=true
echo "Interrupted by user" >&2
}
trap on_int INT
do_work() {
for i in $(seq 1 100); do
"$interrupted" && break
process_item "$i"
done
}
do_work
"$interrupted" && echo "Run was incomplete" >&2 || echo "Run completed"Don't call exit from inside a SIGINT handler if you want the shell to re-raise INT. The convention is to reset the handler to default and re-kill the process: trap - INT; kill -INT $$. This lets the parent process see the correct exit status (terminated by SIGINT) rather than a clean exit.
kill and wait
kill sends a signal to a process; wait waits for a background process to finish:
# Start a background job
sleep 100 &
bgpid=$!
# Do other work
echo "Background pid: $bgpid"
# Wait for it
wait "$bgpid"
echo "Background job finished with exit $?"
# Or terminate it
kill "$bgpid" # send SIGTERM (polite)
sleep 1
kill -0 "$bgpid" 2>/dev/null && kill -9 "$bgpid" # force-kill if still runningkill -0 $pid is a test: it sends no signal but returns 0 if the process exists and you have permission to signal it, non-zero otherwise.
Signals in subshells and pipelines
Each element of a pipeline runs in a subshell. Signals sent to the parent shell are not automatically forwarded to the pipeline's subshells:
# This background pipeline child won't receive the parent's trap
sleep 100 | cat &
# Killing the parent doesn't necessarily kill "sleep 100"For reliable cleanup of background processes, store their PIDs and kill them explicitly in the EXIT trap:
pids=()
cleanup() {
for pid in "${pids[@]}"; do
kill "$pid" 2>/dev/null
done
wait "${pids[@]}" 2>/dev/null
}
trap cleanup EXIT
sleep 100 & pids+=($!)
sleep 200 & pids+=($!)
# cleanup kills both when the script exitswait without arguments waits for all background jobs started by the current shell. With a PID, it waits for just that process. In an EXIT trap that kills background jobs, call wait "${pids[@]}" after the kill calls to avoid zombie processes.
Check your understanding
- 1.Which signal should you trap to run cleanup code whenever the script exits — including on Ctrl-C and set -e failures?
- 2.kill -0 $pid returns 0. What does this tell you?
- 3.A script can catch SIGKILL with trap to run cleanup before being killed.
Do it yourself
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
echo "Created $tmpdir"
cleanup() {
echo "Cleaning up $tmpdir"
rm -rf "$tmpdir"
}
trap cleanup EXIT
# Simulate work
echo "working" > "$tmpdir/data.txt"
sleep 2 # Press Ctrl-C here to test INT handling
cat "$tmpdir/data.txt"
echo "Done normally"Run it, let it complete, then run it again and press Ctrl-C during the sleep. The cleanup message should appear in both cases.
Where to go next
You've completed the Shell mastery module. The lab is next for quiz-based review, then the Automation module: cron scheduling, Makefiles as task runners, writing CI-grade shell scripts, and debugging techniques.
Heredocs and Herestrings
Use <<EOF heredocs, <<-EOF for indented scripts, <<< herestrings, and process substitution with <(cmd) and >(cmd) to pass data without temp files.
Lab: Shell mastery
Hands-on quiz challenges covering parameter expansion edge cases, heredoc quoting rules, and signal handling patterns.