Guarantee Cleanup Runs When a Shell Script Exits
Install EXIT and signal traps that release locks, remove temp files, and kill child processes on success, failure, and Ctrl-C alike.
Task
Add a single cleanup routine that fires on every exit path, including signals and errors, and verify it with a kill test.
When to use this
A data job uses a lockfile and a scratch directory. When someone hits Ctrl-C, the lock stays and the scratch fills the disk. The next run refuses to start, at 3 a.m.
Prerequisites
- Bash 4+ (trap and EXIT semantics)
- A script owning resources: lockfiles, temp dirs, child processes
- Permission to test interrupt paths in a scratch environment
Steps
1. Create resources in trackable variables and install the trap immediately.
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE=/tmp/app.lock
WORKDIR=""; CHILD_PIDS=()
cleanup() {
# kill children first (they may hold the workdir)
if ((${#CHILD_PIDS[@]})); then
kill "${CHILD_PIDS[@]}" 2>/dev/null || true
wait "${CHILD_PIDS[@]}" 2>/dev/null || true
fi
[ -n "$WORKDIR" ] && rm -rf "$WORKDIR"
[ -f "$LOCKFILE" ] && rm -f "$LOCKFILE"
}
trap cleanup EXIT
trap 'exit 130' INT TERM # convert signals into an EXIT
# now acquire resources
exec 9>"$LOCKFILE"
flock -n 9 || { echo "already running" >&2; exit 1; }
WORKDIR=$(mktemp -d /tmp/app.XXXXXX)
Expected output: Whatever exit path the script takes, the same cleanup runs: children reaped, workdir removed, lock released.
2. Register child processes as they spawn.
run_step() {
"$@" &
CHILD_PIDS+=($!)
wait $!
}
run_step ./etl-extract --to "$WORKDIR/raw"
run_step ./etl-transform --in "$WORKDIR/raw" --out "$WORKDIR/clean"
run_step ./etl-load --from "$WORKDIR/clean"
Expected output: Every background process is in the registry, so an interrupt during step three still kills extract and transform leftovers.
3. Prove the interrupt path actually cleans up.
bash job.sh & JOB=$!; sleep 2; kill -INT $JOB; wait $JOB; ls /tmp/app.lock 2>/dev/null && echo "LEAK: lock survived" || echo "lock released"; ls -d /tmp/app.* 2>/dev/null | grep -v lock || echo "workdir removed"Expected output: The test prints lock released and workdir removed: the trap demonstrably fires on SIGINT, not only on a clean exit.
4. Keep the trap idempotent and cheap for double-fire safety.
CLEANED=0
cleanup() {
[ "$CLEANED" -eq 1 ] && return 0
CLEANED=1
trap - EXIT INT TERM # disarm before acting
# ... actual cleanup ...
}
trap cleanup EXIT
trap 'cleanup; exit 143' TERM
Expected output: A signal arriving during cleanup cannot re-enter the function, and disarmed traps prevent recursion while rm -rf runs.
5. Test the error path too, not just Ctrl-C.
bash -c 'source job.sh; false' 2>/dev/null; ls /tmp/app.lock 2>/dev/null || echo "error path: clean"; bash job.sh --inject-failure 2>/dev/null; ls -d /tmp/app.* 2>/dev/null | wc -lExpected output: Both an early command failure and a mid-run injected failure leave zero stray files, covering the set -e exit path as well.
Troubleshooting
Problem
Cleanup runs but children survive
Solution
wait after kill, and start children in their own process group with setsid so a terminal-wide signal cannot be outrun; plain kill on the script does not propagate to grandchildren by itself.
Problem
trap on EXIT fires twice
Solution
Signal traps that call exit re-enter the EXIT trap; disarm traps inside cleanup (trap - EXIT INT TERM) and guard with a CLEANED flag as the double-fire latch.
Problem
flock releases but a stale lock blocks the next run
Solution
You are mixing flock with a manual lockfile; pick one: flock releases automatically when fd 9 closes on any exit, while manual lockfiles need the trap plus a staleness check on PID.
Limitations
- SIGKILL (kill -9) cannot be trapped; design for eventual consistency (stale-lock TTL) rather than pretending cleanup always runs.
- Terminal-wide signals (Ctrl-C in a shared pty) hit every process; process groups are the mitigation, not the trap alone.
Every exit path deserves the same goodbye
Lockfiles and scratch directories leak when a script dies by Ctrl-C, by error, or by kill. One cleanup function, registered on EXIT with signals converted to exits, covers all paths. The test is five lines: run, interrupt, inspect the filesystem.
cleanup() {
kill "${CHILD_PIDS[@]}" 2>/dev/null || true
wait 2>/dev/null || true
rm -rf "$WORKDIR"
rm -f "$LOCKFILE"
}
trap cleanup EXIT
trap 'exit 130' INT TERM # signals become exits
# proof:
# bash job.sh & sleep 2; kill -INT %1; wait
# /tmp/app.lock -> gone
# /tmp/app.abc123 -> gone
Convert signals to exits
The EXIT trap alone misses Ctrl-C on some shells; trapping INT and TERM to run exit re-enters the EXIT trap and gives signals the same cleanup path. One function, every cause of death. Without this, the interrupt you cannot prevent becomes the leak you cannot explain.
Design for the kill you cannot trap
kill -9 and power loss skip all traps, so reliability needs a stale-lock strategy: flock (auto-release on fd close) or a PID-staleness check. The trap handles the graceful 99 percent; the TTL handles the violent remainder. Both are required for a job that runs unattended.