Robust Scripts · about 40 min

Traps, temporary files and cleanup

Why this matters

Real scripts create things they must undo: temporary files, lock files, half-written output, a mounted image, a background helper process. If the script is interrupted with Ctrl-C, killed by a timeout, or stopped by set -e halfway through, that mess stays behind: /tmp fills up, a stale lock file blocks every later run, a half-written report.csv gets uploaded as if it were complete. The trap builtin lets you say "whatever happens, run this on the way out".

trap in one minute

trap 'commands' CONDITION...

The commands are stored as a string and run when one of the conditions occurs. Conditions are signal names (INT, TERM, HUP, ...) or one of bash's pseudo-signals:

ConditionWhen it fires
EXITwhen the shell exits, for any reason it controls (end of script, exit, errexit)
ERRafter any command fails in a place where set -e would exit (same exceptions)
INTCtrl-C was pressed (SIGINT)
TERMpolite termination request (SIGTERM), the default of kill
HUPthe terminal went away (SIGHUP)
$ cat t.sh
trap 'echo "goodbye from $$"' EXIT
echo "working"
exit 4
$ bash t.sh; echo "status $?"
working
goodbye from 2812
status 4

The trap ran, and the script's exit status was still 4. (Inside an EXIT trap $? holds the status the script is exiting with; the trap does not change it unless it calls exit itself.)

Other forms:

trap - EXIT        # remove the trap (restore default behavior)
trap '' INT        # ignore Ctrl-C entirely
trap -p            # print the traps currently set
trap -l            # list signal names and numbers

The temporary file pattern

Never invent temporary names like /tmp/myscript.tmp: two copies of the script running at once would clobber each other, and a predictable name in a world-writable directory is a classic security hole (another user creates it first as a symlink to one of your files). mktemp asks for a name that did not exist, creates the file atomically with permissions only you can read, and prints the name:

$ mktemp
/tmp/tmp.k3VqzX01aB
$ mktemp -d /tmp/report.XXXXXX
/tmp/report.Q9fT2c

-d makes a directory; the Xs in a template are replaced with random characters. Combine it with a trap, installed immediately after creation:

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

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

sort -u "$1" > "$tmp/sorted"
# ... any failure from here on still removes $tmp

If you put the trap later, a failure between mktemp and trap leaks the directory. If you put it earlier, $tmp is unset when the trap fires (an error under set -u).

Quoting the trap string

The trap command is a string that bash evaluates when the trap fires, so the quoting rules from module 7 apply twice: once now, once later.

trap "rm -rf $tmp" EXIT      # $tmp expanded NOW; breaks if the path contains spaces
trap 'rm -rf "$tmp"' EXIT    # stored as-is; $tmp expanded later, quoted correctly

The second form is almost always right. Use the first only when you deliberately want to freeze the current value.

A cleaner style for anything longer than one command is a function:

cleanup() {
  rm -rf "$tmp"
  [ -n "${helper_pid:-}" ] && kill "$helper_pid" 2>/dev/null
}
trap cleanup EXIT

Signals: what happens underneath

A signal is a small asynchronous notification the kernel delivers to a process: "the user pressed Ctrl-C", "someone asked you to terminate", "the pipe you write to has no reader". Each signal has a default action, usually "terminate the process". When you press Ctrl-C, the terminal driver in the kernel sends SIGINT to every process in the foreground process group (module 13 explains groups). A process can tell the kernel "call my handler instead" for most signals; that is exactly what bash does when you trap ... INT.

Two details matter for scripts:

  1. bash runs a trap between commands. If the script is waiting for a foreground program (say sleep 60), the signal is noted and the handler runs only after that program finishes. That program usually got the same Ctrl-C and died, so in practice the handler runs promptly. For a SIGTERM sent only to the script's PID, though, a long sleep delays the handler.
  2. An untrapped terminating signal ends the script. Current bash runs the EXIT trap first (you can verify it in the sandbox), but sh/dash and older bash versions do not, and an explicit handler documents your intent. To be sure, convert signals into a normal exit:
trap cleanup EXIT
trap 'exit 130' INT     # 128 + 2 (SIGINT)
trap 'exit 143' TERM    # 128 + 15 (SIGTERM)
trap 'exit 129' HUP     # 128 + 1 (SIGHUP)

Now Ctrl-C causes exit 130, which fires the EXIT trap, which cleans up, and the caller still sees the conventional "killed by SIGINT" status. SIGKILL (kill -9) and SIGSTOP cannot be trapped at all: the kernel acts without involving the process.

ERR: report where it failed

The ERR trap fires on the same failures that set -e reacts to. Combined with $LINENO and $BASH_COMMAND, it gives a useful last message:

set -euo pipefail
trap 'echo "error: line $LINENO: $BASH_COMMAND (status $?)" >&2' ERR
cp config.yml /etc/app/
cp: cannot create regular file '/etc/app/config.yml': Permission denied
error: line 3: cp config.yml /etc/app/ (status 1)

By default the ERR trap is not inherited by functions, command substitutions and subshells; set -E (errtrace) changes that. Use it if your ERR trap seems to "miss" failures inside functions.

Writing output atomically

Cleanup has a sibling: never let a reader see a half-written file. Write to a temporary file in the same directory and rename it into place at the end:

out=report.csv
tmpout=$(mktemp "$out.XXXXXX")
trap 'rm -f "$tmpout"' EXIT
generate_report > "$tmpout"
mv "$tmpout" "$out"         # rename is atomic within one filesystem

mv within a filesystem is a single rename system call: readers see either the old complete file or the new complete file, never a mixture. (After the mv, the EXIT trap's rm -f finds nothing to remove, which is fine.)

Common mistakes

Exercises

Two quizzes on trap semantics and quoting, then a script that must clean up on both its success and its error path. The checker runs your script several times in the same container and then looks in /tmp for leftovers.

Recap

Going further: help trap, and trap -l for the signal list. In the sandbox shell, run bash -c 'trap "echo caught" INT; sleep 5; echo after', press Ctrl-C during the sleep, and work out the order of what you see.

Exercises

Exercise 1 · quiz

When does the EXIT trap run?

A script sets trap cleanup EXIT near the top. In which situations does cleanup run? Select all that apply.

Answer
  • The script reaches its last line normally.

  • The script runs exit 3 in the middle.

  • A command fails under set -e and the script stops.

  • Someone runs kill -TERM on the script (bash 5, no TERM trap set).

bash runs the EXIT trap whenever the shell itself decides to exit: falling off the end, exit, or errexit. Modern bash also runs it when a catchable terminating signal such as TERM or HUP arrives: it catches the signal, runs the EXIT trap, then kills itself with the same signal so the caller still sees status 143 or 129. Other shells (dash, the /bin/sh on Debian) do not, which is why portable and careful scripts also trap INT, TERM and HUP and turn them into a normal exit. And nothing at all can run on SIGKILL: the kernel removes the process without telling it. Cleanup that must survive kill -9 has to be done by someone else, for example on the next start.

Exercise 2 · quiz

Single or double quotes?

Consider:

tmp=/tmp/a
trap "rm -f $tmp" EXIT
tmp=/tmp/b

Which file does the trap remove when the script exits?

Answer
  • /tmp/a

The trap string is in double quotes, so $tmp is expanded when the trap command runs, at line 2: the stored action is literally rm -f /tmp/a. With single quotes, trap 'rm -f "$tmp"' EXIT, the text rm -f "$tmp" is stored and expanded when the trap fires, so it would remove /tmp/b. Single quotes are usually what you want; they also keep the expansion quoted at the right moment, so a path with spaces still works.

Exercise 3 · write code

Never leave temp files behind

script.sh FILE makes a temporary directory, stores a sorted, de-duplicated copy of FILE in it, and prints unique: N (the number of distinct lines). If FILE does not exist it prints error: no such file: FILE to stderr and exits with status 3.

The starter works, but on the error path it leaves its temporary directory behind in /tmp. Fix it with a trap so that the temporary directory is removed however the script ends.

Requirements:

File: script.sh

Starting point

tmp=$(mktemp -d /tmp/report.XXXXXX)
if [ ! -f "$1" ]; then
  echo "error: no such file: $1" >&2
  exit 3
fi
sort -u "$1" > "$tmp/sorted"
echo "unique: $(wc -l < "$tmp/sorted")"
rm -r "$tmp"

Example with arguments words.txt

expected output

unique: 3

Example with arguments missing.txt

expected output

(nothing)
Hint 1

trap 'command' EXIT runs the command whenever the script exits, including via exit 3.

Hint 2

Install the trap immediately after mktemp, before anything can fail.

Hint 3

trap 'rm -rf "$tmp"' EXIT with single quotes, then the final rm is no longer needed.

Reference solution
tmp=$(mktemp -d /tmp/report.XXXXXX)
trap 'rm -rf "$tmp"' EXIT
if [ ! -f "$1" ]; then
  echo "error: no such file: $1" >&2
  exit 3
fi
sort -u "$1" > "$tmp/sorted"
echo "unique: $(wc -l < "$tmp/sorted")"