Skip to content
academia.sh

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 idempotentrm -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; SIGKILL and SIGSTOP cannot be caught, so cleanup cannot be the sole safeguard.
  • trap ... EXIT runs regardless of how the shell terminates, and is set as soon as the resource is created.
  • The EXIT trap’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 mkdir is 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.

Start typing to search.

↑↓ Esc navigate · open · close