Bash and Linux

Clean Up with trap

mediumBash scripts and automation

Problem statement

Make a script that creates a temporary folder and always removes it when it ends: after success, after a failed step, and when it is stopped with TERM. Scripts that leave temp files, lock files or half-written output behind slowly fill disks and break the next run.

The script saves the tool as work.sh and runs it three ways. Each run writes the path of its temp folder to where.txt, so the outer script can check afterwards that the folder is gone.

  1. A normal successful run.
  2. A run where a step fails (with set -e, the script stops).
  3. A run that is stopped with TERM while it waits.

After each run, print the exit code and whether the temp folder still exists.

Expected output:

TEXT
== run 1: success ==
working in a temp folder
all steps done
cleanup: temp folder removed
exit code: 0
temp folder still there: no
== run 2: a step fails ==
working in a temp folder
step 2 fails
cleanup: temp folder removed
exit code: 1
temp folder still there: no
== run 3: stopped with TERM ==
working in a temp folder
waiting...
cleanup: temp folder removed
exit code: 143
temp folder still there: no

Hints

Hint 1: trap cleanup EXIT runs the cleanup function whenever the script exits, for any reason, including set -e stopping it.

Approach

Optimal: mktemp -d with trap EXIT

Covers: mktemp -d, trap 'command' EXIT, trapping INT and TERM, exit codes 130 and 143, rm -rf -- "${tmp:?}", what cannot be trapped, strict mode piece by piece.

Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:

Option Means
-e exit as soon as a command fails (with some exceptions, see Handle Command Failures)
-u treat an unset variable as an error, instead of silently using empty text
-o pipefail a pipeline fails if any command in it fails, not just the last one

Put it right after the shebang line #!/usr/bin/env bash in every script you write.

Create temp space safely. mktemp -d makes a new, empty folder with a random name under /tmp, readable only by you, and prints its path. Never use a fixed name like /tmp/work: two runs would clash, and another user could create it first.

trap runs code when the script ends. trap cleanup EXIT tells bash: "whenever this script exits, run cleanup first". That covers a normal end, an exit 3, and set -e stopping it on a failed command:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB S(["script ends"]):::purple --> A["normal end"]:::green S --> B["set -e stops it"]:::red S --> C["exit 3"]:::yellow A --> T["trap EXIT runs cleanup"]:::blue B --> T C --> T classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Signals need one more line. When the script is stopped by TERM (what kill and systemctl stop send) or INT (Ctrl+C), bash dies at once unless that signal is trapped. Turning the signal into an exit makes the EXIT trap run too:

TEXT
trap cleanup EXIT
trap 'exit 143' TERM 128 + 15, the usual code for "stopped by TERM"
trap 'exit 130' INT 128 + 2, the usual code for Ctrl+C

KILL (signal 9) can never be trapped, so nothing runs. That is why you send TERM first, as on the Find and Stop a Process page.

Delete carefully. rm -rf -- "${tmp:?}" has two guards. ${tmp:?} stops the script if tmp is empty or unset, so a bug can never turn it into rm -rf "" or worse. -- ends the options, so a path starting with - is not read as an option.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB K(["kill -TERM"]):::red --> T{{"TERM trapped?"}}:::yellow T --> Y["exit 143
EXIT trap cleans up"]:::green T --> N["bash dies
temp folder left behind"]:::red classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Walking through the code. The # Setup: lines write the tool to work.sh, so skip past the setup part; the tool itself is the solution.

  1. work.sh makes the temp folder, records its path, sets the traps, and does its work. Its argument chooses ok, fail (runs false) or hang (waits on a background sleep).
  2. Run 1 succeeds, the trap cleans up, exit 0.
  3. Run 2: false stops the script with code 1, and the trap still cleans up.
  4. Run 3 starts in the background, gets TERM after half a second, exits 143, and cleans up. sleep 3 & wait is used so the signal is handled at once; bash waits for a foreground command to finish before running a trap.

Edge cases. Setting trap ... EXIT twice replaces the first one; put all cleanup in one function. Inside the trap, $? is the code the script is exiting with, so cleanup can log it. A background job the script started keeps running after the script exits unless the cleanup stops it, which is what kill "$child" does here.

#!/usr/bin/env bash
set -euo pipefail

# Setup: write the tool in a fresh temporary folder
cd "$(mktemp -d)"
cat > work.sh << 'TOOL'
#!/usr/bin/env bash
set -euo pipefail

tmp=$(mktemp -d)
echo "$tmp" > where.txt                 # so the outer script can check it later
child=""

cleanup() {
  if [[ -n $child ]]; then kill "$child" 2>/dev/null || true; fi
  rm -rf -- "${tmp:?}"
  echo "  cleanup: temp folder removed"
}
trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT

echo "  working in a temp folder"
echo "data" > "$tmp/part1"
case "${1:-ok}" in
  fail) echo "  step 2 fails"; false ;;
  hang) echo "  waiting..."; sleep 3 & child=$!; wait "$child" ;;
esac
echo "  all steps done"
TOOL

gone() { if [[ -d $(cat where.txt) ]]; then echo "temp folder still there: yes"; else echo "temp folder still there: no"; fi; }

echo "== run 1: success =="
bash work.sh ok && echo "exit code: 0"; gone
echo "== run 2: a step fails =="
bash work.sh fail || echo "exit code: $?"; gone
echo "== run 3: stopped with TERM =="
bash work.sh hang & pid=$!
sleep 0.5
kill -TERM "$pid"
wait "$pid" || echo "exit code: $?"; gone

Interview follow-ups

  • Also create a lock file at the start and remove it on exit.

    Create it right after the traps: lock=/tmp/work.lock; [[ -e $lock ]] && { echo "already running" >&2; exit 1; }; echo $$ > "$lock". Add rm -f -- "$lock" to cleanup, so it is removed on success, failure and TERM. Be careful to set the lock only after checking it, otherwise cleanup would delete another run's lock. For real use, prefer flock from the FAQ, because a KILL would still leave this file behind.

Frequently asked questions

In bash, an untrapped INT or TERM kills the script directly, and whether the EXIT trap still runs depends on the shell and version. Do not rely on it: add trap 'exit 130' INT and trap 'exit 143' TERM, which turn the signal into a normal exit, and the EXIT trap then always runs. Also remember that bash runs traps only after the current foreground command finishes, so a long sleep delays them; sleep ... & wait $! lets the trap run at once.

Because a script with an empty variable and rm -rf "$tmp/"* turns into rm -rf /*, which has deleted whole servers. ${tmp:?} makes bash stop with an error if tmp is empty or unset, before rm ever runs. set -u catches unset variables but not empty ones, so the :? guard adds real protection. Use it on every rm -rf that takes a variable.

Use flock, which holds a lock as long as a file descriptor is open: exec 9> /tmp/backup.lock; flock -n 9 || { echo "already running" >&2; exit 1; }. The lock is released automatically when the script exits, even if it crashes or is killed with KILL, because the kernel closes the descriptor. That is safer than creating and deleting a lock file yourself, which leaves a stale lock behind after a crash.