---
title: 'Traps and Cleanup'
source: 'https://academia.sh/en/courses/shell-programming/traps-and-cleanup'
course: 'Shell Programming'
language: en
updated: '2026-08-17T18:10:00+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
#!/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:

```sh
#!/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 `$?`:

```sh
#!/usr/bin/env bash
cleanup() { local code=$?; echo "cleanup, preserved code=$code"; }
trap cleanup EXIT
exit 7
```

```
cleanup, preserved code=7
```

```sh
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.

```sh
#!/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)
```

```sh
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 **idempotent** — `rm -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.

```sh
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 `touch`ed 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.

```sh
#!/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:

```sh
#!/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.
