Lesson 21 / 24
Traps and Cleanup
The concept of a signal, setting traps, a cleanup hook that runs on exit, the safe creation of temporary files, and a lock that prevents concurrent runs.
Contents
Strict mode stops the script the moment there is an error. What it leaves behind at the point where it stops is a separate question: a half-written output file, an uncleaned temporary directory, a lock another process is waiting on. A script terminating correctly matters as much as it running correctly.
In the How Computers Work course, an interrupt was hardware telling the processor “pause your work.” A signal is the process-level counterpart of the same idea: an asynchronous notification the kernel, or another process, sends to a running process. This lesson sets up catching signals and the cleanup that runs on exit.
Signals
When a process receives a signal, it applies the default behavior — for most signals, this is termination. Few signals are of interest in scripts:
| Signal | Number | Source |
|---|---|---|
SIGHUP |
1 | the controlling terminal closed |
SIGINT |
2 | interrupt from the keyboard |
SIGTERM |
15 | a request for clean termination |
SIGKILL |
9 | unconditional termination |
SIGPIPE |
13 | writing to a pipe whose read end is closed |
The Exit Codes lesson showed that a process terminated by a signal returns the
code 128 + signal number; 143 for SIGTERM, 130 for SIGINT.
SIGKILL and SIGSTOP cannot be caught, ignored, or redefined. This
guarantees that the operating system has the last word over a process.
Consequently, no cleanup hook runs in a script terminated with SIGKILL — it
cannot be the sole basis for cleanup.
Setting a Trap
The form trap 'command' signal... sets the command to run when the signal
arrives. The shell also recognizes three pseudo-signals: EXIT, ERR,
and DEBUG.
EXIT is the most important one: it runs no matter how the shell terminates
— normal completion, an exit call, strict mode’s early exit, or after the
handler of a caught signal.
#!/usr/bin/env bash set -Eeuo pipefail temp=$(mktemp -d) cleanup() { rm -rf "$temp"; } trap cleanup EXIT echo "data" > "$temp/scratch.txt" echo "temp dir contents: $(ls "$temp")"
temp dir contents: scratch.txt
When the script finishes, the directory has been deleted. The same holds on the error path:
#!/usr/bin/env bash set -Eeuo pipefail temp=$(mktemp -d) trap 'rm -rf "$temp"' EXIT echo "operation started" false echo "THIS LINE IS NOT PRINTED"
operation started
Strict mode stopped the script at the false line; the EXIT trap still
ran, and no temporary directory was left behind.
A trap is valid from the moment it is set. This is why it is set as soon as the resource is created; if an error occurs between the two lines, cleanup does not happen.
Preserving the Exit Code
The EXIT trap’s body must not change the script’s exit code. For the code
to be read, the body’s first command must read $?:
#!/usr/bin/env bash cleanup() { local code=$?; echo "cleanup, preserved code=$code"; } trap cleanup EXIT exit 7
cleanup, preserved code=7
echo $?
7
If the trap’s body itself calls exit, the script’s code becomes that value;
if it does not, the original code is preserved. If cleanup failure is not
meant to change the script’s result, the commands inside the body must have
their codes suppressed.
Re-sending the Signal
Catching a signal and doing cleanup erases the information that the process ended by that signal: if the script finishes normally, its exit code is 0, and the caller cannot tell it was interrupted.
The correct pattern has three steps: clean up, remove the trap, re-send the signal to itself.
#!/usr/bin/env bash cleanup() { echo "cleanup ran (source: $1)"; } trap 'cleanup EXIT' EXIT trap 'cleanup TERM; trap - TERM; kill -TERM $$' TERM echo "ready" sleep 30
When the script is started in the background and sent SIGTERM:
ready cleanup ran (source: TERM) cleanup ran (source: EXIT)
echo $?
143
The code is 143, that is, 128 + 15. A service manager or scheduler managing the process can see this code and tell that the script was terminated, not that it failed.
The form trap - SIGNAL removes the trap and restores the default behavior.
If this step is skipped, the kill call triggers its own trap again, and an
infinite loop results.
Notice that the EXIT trap also ran: cleanup executed twice. This is why the
cleanup function must be written idempotent — rm -rf and rmdir calls
do no harm when they try to delete an already-gone path a second time, but a
cleanup that increments a counter or sends a message is wrong if it runs
twice.
Creating Temporary Files Safely
Producing a temporary file’s name by hand — something like
/tmp/report.$$ — is wrong in three ways.
It is predictable. Process numbers occupy a narrow range and are reused; an attacker can guess the name in advance and plant a symbolic link at that path. When the script opens the file, it writes to wherever the link points.
It contains a race. The gap between the “does the file exist” check and creating it is open to interference.
It can collide. Two instances of the same script can receive the same process number at different times.
mktemp produces the name unpredictably and creates the file atomically;
if the name collides, it retries. The -d option creates a directory and
provides a single cleanup point when multiple temporary files are needed.
temp=$(mktemp -d) trap 'rm -rf "$temp"' EXIT
mktemp opens the files and directories it creates with permissions
accessible only to their owner; a file touched by hand has no such
guarantee.
The quotes in the line rm -rf "$temp" are mandatory. If the variable is
left undefined — without set -u — the command turns into rm -rf; the
quotes are a second layer of protection for paths containing spaces. Strict
mode’s set -u option is this line’s most important guard.
Preventing Concurrent Runs
A scheduled report script can be restarted before the previous run finishes. If two instances write to the same temporary files, the result is corrupted.
The portable way to acquire a lock is creating a directory: mkdir is an
atomic operation that fails if the target already exists. Creating a
file does not give the same guarantee, because the “does it exist” check and
creation are two separate steps.
#!/usr/bin/env bash set -Eeuo pipefail LOCK="report.lock" if ! mkdir "$LOCK" 2>/dev/null; then echo "another run is in progress, exiting" >&2 exit 75 fi trap 'rmdir "$LOCK"' EXIT echo "lock acquired, working" sleep 1
When a second instance is started while one is running:
lock acquired, working another run is in progress, exiting second run code=75
The trap must be set after mkdir succeeds. Had it been set before, the
second instance, unable to acquire the lock, would delete someone else’s
lock on its way out.
The code 75 is a common convention meaning “temporary error, retry later”; a scheduler can distinguish this code from a real error.
This pattern’s known weakness is that the lock is left behind if the script
is terminated with SIGKILL or the machine shuts down. In long-lived
systems, the process number is written into the lock directory, and when the
lock cannot be acquired, whether that process is still alive is tested.
Applying It to the Script
report.sh takes on the strict mode header and the cleanup infrastructure:
#!/usr/bin/env bash # report.sh — produces a summary report from the access log. set -Eeuo pipefail export LC_ALL=C SCRIPT_NAME="report.sh" temp="" cleanup() { local code=$? [ -n "$temp" ] && rm -rf "$temp" return "$code" } trap cleanup EXIT trap 'cleanup; trap - INT; kill -INT $$' INT trap 'cleanup; trap - TERM; kill -TERM $$' TERM temp=$(mktemp -d)
Initializing the temp variable with an empty string before the trap is
set is required under set -u: the trap also runs if an error occurs before
the mktemp call, and at that point the variable would be undefined.
The compare function from the previous lesson no longer leaves temporary
files behind; everything under $temp is deleted on exit.
Summary
- A signal is an asynchronous notification sent to a running process;
SIGKILLandSIGSTOPcannot be caught, so cleanup cannot be the sole safeguard. trap ... EXITruns regardless of how the shell terminates, and is set as soon as the resource is created.- The
EXITtrap’s first command must read$?; otherwise the script’s exit code is lost. - When a signal is caught, cleanup runs, the trap is removed, and the signal
is re-sent to itself; this preserves the exit code as
128 + signal. - A temporary file is created with
mktemp: the name is unpredictable, creation is atomic, and permissions are narrow. - Because
mkdiris atomic, it is used as a portable locking mechanism; the trap is set after the lock is acquired.
Next Step
The script now terminates cleanly and leaves no trace behind. How to find out it is producing the wrong result is a separate question. The next lesson takes up debugging tools: a trace mode that prints every command before it runs, a format variable that makes the trace output readable, a syntax check performed without running the script, and applying the trace to only part of the script.
To keep your progress and take notes, Log in
My notes
Log in to take notes.