opscape

Bash scripting guide · part 20 of 32

Traps and signals

Running cleanup code with trap on EXIT, handling INT and TERM, the ERR pseudo-signal, and the timing and scoping rules that trip people up.

trap 'commands' CONDITION... registers code for the shell to run when something happens. The conditions are real signals (INT, TERM, HUP, ...) plus four bash pseudo-signals: EXIT (the shell is about to exit, for any reason it can handle), ERR (a command failed, under the same rules as set -e), DEBUG (before every command) and RETURN (a function or sourced file finished). Almost every script that creates something temporary needs exactly one of these: an EXIT trap.

Cleanup on EXIT

An EXIT trap runs however the script ends

job.sh
#!/usr/bin/env bash
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleaned up $tmp"' EXIT
echo "working in $tmp"
ls data > "$tmp"
wc -l < "$tmp"
exit 3
bash job.sh; echo "status $?"
explain
output
working in /tmp/tmp.8Qo1AtQHJd
11
cleaned up /tmp/tmp.8Qo1AtQHJd
status 3

The trap fires on exit 3 and removes the temporary file. The script's status is still 3: an EXIT trap does not change the exit status unless it calls exit itself.

Note the quoting: the trap string is in single quotes, so $tmp is expanded when the trap runs, not when it is set. With double quotes the value is baked in at trap time, which also works here but breaks when the path contains a quote or when the variable changes later.

The EXIT trap also runs when set -e stops the script and when a handled signal leads to exit, which is why it is the one place to put cleanup.

The EXIT trap runs when set -e aborts

build.sh
#!/usr/bin/env bash
set -e
trap 'rc=$?; echo "exiting with status $rc"' EXIT
echo "step 1"
grep -q PANIC logs/app.log
echo "step 2"
bash build.sh
explain
outputexit 1
step 1
exiting with status 1

grep finds no match and returns 1, -e ends the script, and the trap runs first. Inside an EXIT trap, $? is the status the shell is exiting with; save it in a variable on the first line before any other command overwrites it.

Handling signals

When a process receives TERM or INT (Ctrl-C) and has no handler for it, it dies with status 128 + the signal number. Bash still runs its EXIT trap on the way out for these fatal signals. Not every shell does: dash, which is /bin/sh on Debian and Ubuntu, skips the EXIT trap when a signal kills it, so portable scripts trap the signals too.

Bash runs the EXIT trap when TERM kills the script

worker.sh
#!/usr/bin/env bash
trap 'echo "EXIT trap ran"' EXIT
kill -TERM $$
echo "not reached"
bash worker.sh; echo "status $?"
explain
output
EXIT trap ran
Terminated
status 143

No TERM handler is set, so the signal is fatal: the script never reaches its last line and its status is 143 (128 + 15). The EXIT trap ran anyway. The calling shell prints Terminated because its child was killed by a signal.

To do something specific on a signal, or to choose the exit status yourself, trap it and call exit from the handler. exit then runs the EXIT trap, so the cleanup stays in one place.

Turn TERM into an orderly exit

server.sh
#!/usr/bin/env bash
trap 'echo "EXIT trap: cleaning up"' EXIT
trap 'echo "got TERM"; exit 143' TERM
echo "pid $$ running"
kill -TERM $$
echo "not reached"
bash server.sh
explain
outputexit 143
pid 6209 running
got TERM
EXIT trap: cleaning up

The script signals itself. The TERM handler runs, calls exit 143, and that triggers the EXIT trap. 143 (128 + 15) is the conventional status for "terminated", so callers see the same code they would without the handler.

A trap waits for the foreground command

Bash runs a trap only between commands. If the script is in the middle of a long foreground command such as sleep 3 or rsync, a signal sent to the script is remembered and the trap runs after that command returns. To react promptly, run the long command in the background and wait for it: wait is interrupted by a trapped signal.

Foreground commands delay the trap; wait does not

slow.sh
#!/usr/bin/env bash
trap 'echo "trap ran after ${SECONDS}s"' USR1
(sleep 1; kill -USR1 $$) &
sleep 3
echo "foreground sleep done at ${SECONDS}s"
(sleep 1; kill -USR1 $$) &
sleep 3 & wait $!
echo "wait returned $? at ${SECONDS}s"
bash slow.sh
explain
output
trap ran after 3s
foreground sleep done at 3s
trap ran after 4s
wait returned 138 at 4s

The first signal arrives after one second, but the trap only runs once the foreground sleep 3 has finished. The second time, wait is interrupted as soon as the signal arrives, the trap runs right away, and wait returns 138 (128 + 10, the number of USR1).

The ERR pseudo-signal

trap ... ERR runs whenever a command fails, with the same exceptions as set -e: not in if conditions, &&/|| lists or after !. $LINENO and $BASH_COMMAND inside it tell you where. By default an ERR trap is not inherited by functions, command substitutions or subshells; set -E (errtrace) makes it so.

Report failures, including inside functions, with set -E

err.sh
#!/usr/bin/env bash
trap 'echo "ERR at line $LINENO: $BASH_COMMAND (status $?)"' ERR
load() { cat data/missing.json; }
load
echo "--- with set -E"
set -E
load
bash err.sh
explain
outputexit 1
cat: data/missing.json: No such file or directory
ERR at line 4: cat data/missing.json (status 1)
--- with set -E
cat: data/missing.json: No such file or directory
ERR at line 3: cat data/missing.json (status 1)
ERR at line 7: cat data/missing.json (status 1)

Without -E, the trap fires once, after load returns, at line 4 where the function was called; BASH_COMMAND still names the cat that failed last. With set -E it also fires inside the function at line 3, and then again for the call on line 7. The script's status is the failed call's, 1.

One trap per condition

Each condition has a single handler. A second trap ... EXIT replaces the first; it does not add to it. That bites when a library function sets its own cleanup trap. Keep one cleanup function and have it handle everything, or append deliberately. trap -p shows what is set, trap - SIG restores the default, and trap '' SIG ignores the signal.

A second trap replaces the first

trap 'echo "remove lock"' EXIT
trap 'echo "remove temp dir"' EXIT
trap -p EXIT
output
trap -- 'echo "remove temp dir"' EXIT
remove temp dir

trap -p shows only the second handler; the lock would never be removed. When the shell exits it runs that one handler and prints remove temp dir.

One cleanup function that grows as the script goes

multi.sh
#!/usr/bin/env bash
cleanup_items=()
cleanup() {
  for item in "${cleanup_items[@]}"; do
    rm -rf -- "$item" && echo "removed $item"
  done
}
trap cleanup EXIT

dir=$(mktemp -d)
cleanup_items+=("$dir")
lock=$(mktemp /tmp/job.XXXX.lock)
cleanup_items+=("$lock")
echo "using $dir and $lock"
bash multi.sh
explain
output
using /tmp/tmp.czbaR0fgRm and /tmp/job.Qsmo.lock
removed /tmp/tmp.czbaR0fgRm
removed /tmp/job.Qsmo.lock

The trap is a function call, so it can read state built up later. Each resource is added to the list as soon as it exists, so a failure halfway through only cleans up what was created.

Ignoring a signal

trap '' SIGNAL ignores it, and unlike a handler, an ignored signal stays ignored in child processes. trap - SIGNAL undoes either. nohup works on the same principle for HUP.

An ignored signal is ignored by children too

trap '' INT
bash -c 'kill -INT $$; echo "child survived SIGINT"'
trap - INT
bash -c 'kill -INT $$; echo "not printed"'
echo "second child status: $?"
output
child survived SIGINT
second child status: 130

With INT ignored in the parent, the child inherits the ignored disposition and kill -INT has no effect on it. After trap - INT restores the default, the second child dies from its own signal, status 130 (128 + 2).

Traps in subshells

Handlers are reset to the default in subshells, including ( ... ), pipelines and $(...). An EXIT trap set in the main script does not run when a subshell exits, and a trap you set inside a subshell only lasts for that subshell. Ignored signals are the exception, as shown above. Bash does let $(trap -p) see the parent's handlers, so you can save and restore them.

Checklist

  • One EXIT trap per script, set right after the first resource is created, calling a cleanup function.
  • Single-quote the trap string, or call a function.
  • Save $? on the first line of the handler if you need it.
  • Trap INT and TERM only if you need to do something special; the EXIT trap plus exit from those handlers covers cleanup.
  • Long waits: background the command and wait, so signals are handled promptly.

Related