Bash Scripting 22 ๐ง Signal Handling โ Catching interrupts and cleaning up with trap
A shell script that creates temporary files, acquires locks, or starts background processes has a cleanup obligation. If the script runs to completion, the cleanup code at the end executes. If the user presses Ctrl+C, if the system sends a termination signal, or if a command fails under set -e, the script exits before reaching that cleanup code. The temporary files remain, the lock is held, and the background process continues running. The trap builtin is how a script registers code that runs when these events occur, regardless of how the script exits.
This chapter covers the trap command and the signals it handles: the difference between EXIT and the termination signals INT and TERM, the pattern of registering cleanup on EXIT so that it runs on every exit path, and the signal-specific handlers that print an appropriate message before exiting. It also covers the double-execution mistake that occurs when cleanup is registered on both EXIT and INT/TERM, and the ERR trap that reports which command failed and on which line.
Key point: Register cleanup on EXIT, not on individual signals. The EXIT pseudo-signal fires when the script exits for any reasonโnormal completion, set -e failure, or a signal-induced exitโso a single trap cleanup EXIT covers every path without double execution.
Why trap has this structure
The exit-path problem. A script has many ways to end. It can reach the last line and exit 0. It can fail under set -e and exit non-zero. It can receive SIGINT from Ctrl+C or SIGTERM from a process manager. It can call exit explicitly. The cleanup code must run in all of these cases. Placing cleanup at the bottom of the script only handles the first case. The trap builtin exists to register code that the shell executes when the script exits, independent of the path that led to the exit .
The EXIT pseudo-signal problem. Bash treats EXIT as a special signal specification that fires when the shell exits. The GNU Bash manual states: “If a SIGNAL_SPEC is EXIT (0) ARG is executed on exit from the shell” . This includes exits caused by signals whose default action is to terminate the shell. In other words, trap cleanup EXIT covers Ctrl+C, kill, and normal completion, because all of them result in the shell exiting, and the EXIT trap fires at that moment.
The double-execution problem. A common mistake is to register the cleanup function on EXIT, INT, and TERM simultaneously: trap cleanup EXIT INT TERM. When SIGINT arrives, the shell runs the INT handler, which is cleanup, and then exits, which triggers the EXIT handler, which is also cleanup. The cleanup runs twice. The documentation for trap-based cleanup is explicit about this: “WRONG: cleanup runs TWICE on SIGINT/SIGTERM… CORRECT: EXIT alone catches all exit paths including signals” . The correct pattern is trap cleanup EXIT alone, with separate handlers only if signal-specific behavior is needed.
The signal-handler timing problem. When Bash is waiting for a foreground command to complete and receives a signal for which a trap is set, it does not execute the trap until the command completes . This means a sleep 60 in the script will not be interrupted by Ctrl+C until the sleep finishes. The wait builtin behaves differently: if Bash is waiting via wait and receives a trapped signal, wait returns immediately with a status greater than 128, and the trap runs . This distinction matters for scripts that need to respond promptly to termination signals.
a. The trap syntax and signal names
The syntax is trap 'commands' SIGNAL [SIGNAL...]. The commands are shell code, typically a function call. The signals can be specified by name with or without the SIG prefix, or by number. Signal names are case-insensitive .
# Register cleanup on EXIT
trap cleanup EXIT
# Register a handler for SIGINT (Ctrl+C)
trap 'echo "Interrupted"' INT
# Multiple signals to the same handler (use with caution)
trap 'echo "Terminating"' TERM HUP
The EXIT pseudo-signal is not a real signal. It is a condition that Bash recognizes and fires when the shell exits. The INT signal is generated by Ctrl+C. The TERM signal is the polite termination request sent by kill and by most process managers. The HUP signal is sent when the terminal disconnects .
The trap -l option lists all signal names and numbers. The trap -p option displays the currently registered traps, which is useful for debugging a script that seems to be ignoring a signal .
b. The EXIT trap and cleanup functions
The most important trap is EXIT. A single trap cleanup EXIT covers normal exit, set -e failure, and signal-induced exit. The cleanup function should preserve the exit code so that the script’s failure status is not lost.
#!/bin/bash
set -euo pipefail
tmpdir=""
cleanup() {
local exit_code=$?
if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then
rm -rf "$tmpdir"
fi
exit "$exit_code"
}
trap cleanup EXIT
tmpdir=$(mktemp -d)
# ... script work ...
The local exit_code=$? captures the exit status at the moment the trap fires. The exit "$exit_code" at the end of the cleanup function propagates that status so that the script exits with the correct code. The guard [[ -n "$tmpdir" ]] handles the case where the trap fires before tmpdir is assigned, which can happen if set -e triggers on an early command .
The cleanup function should be idempotent. If a signal handler runs cleanup and then exits, the EXIT trap may run cleanup again. Guarding each removal with an existence check prevents errors from a second attempt .
c. Signal-specific handlers for messages and exit codes
The EXIT trap handles cleanup, but it does not distinguish between a normal exit and an interruption. If the script should print a different message or use a different exit code when interrupted, a separate signal handler is needed.
#!/bin/bash
set -euo pipefail
cleanup() {
local exit_code=$?
rm -f /tmp/workfile
exit "$exit_code"
}
trap cleanup EXIT
trap 'echo "Interrupted by user" >&2; exit 130' INT
trap 'echo "Terminated by signal" >&2; exit 143' TERM
# ... script work ...
The INT handler prints a message and exits with code 130, which is the conventional exit code for SIGINT (128 + signal number 2). The TERM handler exits with 143 (128 + 15). When either handler calls exit, the shell exits, and the EXIT trap fires, running the cleanup function. The cleanup runs once, because it is registered only on EXIT .
This pattern separates the concern of “what to tell the user” from the concern of “what to clean up.” The signal handler handles the message and the exit code. The EXIT trap handles the cleanup. The two do not conflict.
d. The ERR trap for failure reporting
The ERR trap fires when a command fails in a context that would cause the shell to exit under set -e. It is used for debugging, not for cleanup. The ERR trap can report the failing command and the line number.
#!/bin/bash
set -euo pipefail
trap 'echo "Error on line $LINENO: $BASH_COMMAND" >&2' ERR
trap cleanup EXIT
# ... script work ...
The $LINENO variable holds the line number of the failing command. The $BASH_COMMAND variable holds the command that failed. This output tells the user exactly what went wrong and where, which is more useful than a bare non-zero exit .
The ERR trap and the EXIT trap are separate concerns. ERR reports the failure; EXIT cleans up. Registering both is correct and does not cause double execution, because they fire for different reasons.
Complete Example Session
# ============================================
# PART 1: THE CLEANUP PROBLEM
# ============================================
# Without a trap, interrupted scripts leave debris:
tmpfile=$(mktemp)
# ... long operation ...
# Ctrl+C here means tmpfile is never removed
rm -f "$tmpfile" # never reached
# ============================================
# PART 2: THE BASIC EXIT TRAP
# ============================================
#!/bin/bash
set -euo pipefail
tmpfile=""
cleanup() {
local exit_code=$?
[[ -n "$tmpfile" && -f "$tmpfile" ]] && rm -f "$tmpfile"
exit "$exit_code"
}
trap cleanup EXIT
tmpfile=$(mktemp)
# Cleanup runs on normal exit, set -e failure, and Ctrl+C.
# ============================================
# PART 3: THE DOUBLE-EXECUTION MISTAKE
# ============================================
# WRONG:
# trap cleanup EXIT INT TERM
# On Ctrl+C: cleanup runs as INT handler,
# then shell exits, cleanup runs again as EXIT handler.
# CORRECT:
# trap cleanup EXIT
# On Ctrl+C: shell exits, EXIT handler runs cleanup once.
# ============================================
# PART 4: SIGNAL-SPECIFIC MESSAGES
# ============================================
trap 'echo "Interrupted" >&2; exit 130' INT
trap 'echo "Terminated" >&2; exit 143' TERM
trap cleanup EXIT
# INT handler prints message and exits.
# EXIT handler runs cleanup.
# No double execution.
# ============================================
# PART 5: PRESERVING THE EXIT CODE
# ============================================
cleanup() {
local exit_code=$?
rm -f /tmp/workfile
exit "$exit_code"
}
# $? at the start of cleanup holds the script's exit status.
# exit "$exit_code" propagates it after cleanup.
# ============================================
# PART 6: IDEMPOTENT CLEANUP
# ============================================
cleanup() {
local exit_code=$?
[[ -f /tmp/workfile ]] && rm -f /tmp/workfile
[[ -d /tmp/workdir ]] && rm -rf /tmp/workdir
exit "$exit_code"
}
# Guards prevent errors if cleanup runs after
# the files were already removed.
# ============================================
# PART 7: THE ERR TRAP
# ============================================
trap 'echo "Failed on line $LINENO: $BASH_COMMAND" >&2' ERR
# Under set -e, this prints the line and command
# that caused the script to exit.
# ============================================
# PART 8: TRAPPING SIGHUP
# ============================================
trap 'echo "Terminal disconnected" >&2; exit 129' HUP
# HUP is sent when the terminal closes.
# Exit code 129 = 128 + 1 (SIGHUP number).
# ============================================
# PART 9: VIEWING REGISTERED TRAPS
# ============================================
trap -p
# Output shows the commands registered for each signal.
# Useful for debugging a script that ignores a signal.
# ============================================
# PART 10: COMPLETE PATTERN
# ============================================
#!/bin/bash
set -euo pipefail
lockfile="/var/run/myscript.lock"
workdir=""
cleanup() {
local exit_code=$?
[[ -n "$workdir" && -d "$workdir" ]] && rm -rf "$workdir"
[[ -f "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
trap cleanup EXIT
trap 'echo "Interrupted" >&2; exit 130' INT
trap 'echo "Terminated" >&2; exit 143' TERM
trap 'echo "Error: $BASH_COMMAND" >&2' ERR
touch "$lockfile"
workdir=$(mktemp -d)
# ... script work ...
The ten parts covered the cleanup problem, the basic EXIT trap, the double-execution mistake, signal-specific messages, exit code preservation, idempotent cleanup, the ERR trap, SIGHUP, viewing traps, and the complete pattern.
Quick Reference
Trap syntax
| Form | Effect |
|---|---|
trap 'cmd' EXIT | Run cmd when shell exits |
trap 'cmd' INT | Run cmd on SIGINT (Ctrl+C) |
trap 'cmd' TERM | Run cmd on SIGTERM |
trap 'cmd' HUP | Run cmd on SIGHUP |
trap 'cmd' ERR | Run cmd when set -e triggers |
trap -l | List signal names and numbers |
trap -p | Show registered traps |
trap - INT TERM | Reset INT and TERM to default |
Signal exit codes
| Signal | Number | Conventional exit code |
|---|---|---|
INT | 2 | 130 (128 + 2) |
TERM | 15 | 143 (128 + 15) |
HUP | 1 | 129 (128 + 1) |
The recommended pattern
| Concern | Trap |
|---|---|
| Cleanup | trap cleanup EXIT |
| Interrupt message | trap 'echo ...; exit 130' INT |
| Termination message | trap 'echo ...; exit 143' TERM |
| Error reporting | trap 'echo "line $LINENO: $BASH_COMMAND"' ERR |
Best Practices
โ Do This:
# Register cleanup on EXIT only
trap cleanup EXIT # โ
# Preserve the exit code in cleanup
local exit_code=$?
exit "$exit_code" # โ
# Guard cleanup operations with existence checks
[[ -f "$tmpfile" ]] && rm -f "$tmpfile" # โ
# Use separate handlers for signal-specific messages
trap 'echo "Interrupted"; exit 130' INT # โ
# Use the ERR trap for debugging
trap 'echo "line $LINENO: $BASH_COMMAND"' ERR # โ
โ Don’t Do This:
# Register cleanup on EXIT and signals
trap cleanup EXIT INT TERM # โ (double execution)
# Put cleanup at the bottom of the script only
rm -f "$tmpfile" # after the work # โ (skipped on interrupt)
# Assume a trap fires immediately during a foreground command
# Bash waits for the command to complete before running the trap # โ
# Forget to preserve $? in the cleanup function
cleanup() { rm -f "$tmpfile"; } # โ (exit code lost)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cleanup runs twice | Registered on EXIT and INT/TERM | Register on EXIT only |
Trap does not fire during sleep | Bash waits for foreground command to complete | Use wait instead of sleep for interruptible waits |
| Exit code lost after cleanup | $? not captured or not propagated | Capture $? at function start, exit "$exit_code" |
| Cleanup fails because files already removed | No existence guards | Guard each removal with [[ -f ... ]] |
ERR trap not firing | set -e not enabled | Add set -e to the script |
| Trap fires before variables initialized | set -e triggers on an early command | Initialize variables to empty strings before the trap |
Real-World Examples
1. Basic EXIT cleanup
trap 'rm -f /tmp/workfile' EXIT
2. Cleanup function with exit code preservation
cleanup() { local rc=$?; rm -f /tmp/workfile; exit "$rc"; }
trap cleanup EXIT
3. Signal-specific message for INT
trap 'echo "Interrupted" >&2; exit 130' INT
4. Signal-specific message for TERM
trap 'echo "Terminated" >&2; exit 143' TERM
5. ERR trap with line number
trap 'echo "line $LINENO: $BASH_COMMAND" >&2' ERR
6. HUP handler
trap 'echo "Terminal closed" >&2; exit 129' HUP
7. Lock file cleanup
trap 'rm -f /var/run/myscript.lock' EXIT
8. Temporary directory cleanup
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
9. View registered traps
trap -p
10. Reset traps to default
trap - INT TERM
Visual
The exit paths and the EXIT trap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ EXIT TRAP COVERS ALL PATHS โ
โ โ
โ Normal completion โโโ โ
โ โ โ
โ set -e failure โโโโโโผโโโบ Shell exits โโโบ EXIT trap fires โ
โ โ โ
โ SIGINT (Ctrl+C) โโโโโค โ
โ โ โ
โ SIGTERM (kill) โโโโโโ โ
โ โ
โ One trap on EXIT handles every exit path. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Double execution
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WRONG: trap cleanup EXIT INT TERM โ
โ โ
โ SIGINT arrives โ
โ โ โ
โ โผ โ
โ INT handler runs: cleanup โ
โ โ โ
โ โผ โ
โ Shell exits โ
โ โ โ
โ โผ โ
โ EXIT handler runs: cleanup โ
โ โ
โ cleanup ran TWICE. โ
โ โ
โ CORRECT: trap cleanup EXIT โ
โ SIGINT arrives โโโบ shell exits โโโบ EXIT handler โโโบ once โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Signal handler and EXIT trap separation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SEPARATE CONCERNS โ
โ โ
โ INT handler: โ
โ "What to tell the user" โ
โ echo "Interrupted" โ
โ exit 130 โ
โ โ
โ EXIT trap: โ
โ "What to clean up" โ
โ rm -f "$tmpfile" โ
โ exit "$exit_code" โ
โ โ
โ The INT handler exits. โ
โ The EXIT trap cleans up. โ
โ They do not conflict. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ERR trap output
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ERR TRAP EXAMPLE โ
โ โ
โ Script line 42: cp /nonexistent /tmp/ โ
โ โ
โ set -e causes exit. โ
โ ERR trap fires: โ
โ "line 42: cp /nonexistent /tmp/" โ
โ โ
โ The user sees exactly what failed and where. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Cleanup trap | trap cleanup EXIT |
| Interrupt handler | trap 'echo ...; exit 130' INT |
| Termination handler | trap 'echo ...; exit 143' TERM |
| Hangup handler | trap 'echo ...; exit 129' HUP |
| Error reporting | trap 'echo "line $LINENO: $BASH_COMMAND"' ERR |
| Double execution cause | Registering cleanup on EXIT and INT/TERM |
| Exit code preservation | local rc=$?; exit "$rc" in cleanup |
| Trap viewing | trap -p |
Key takeaways:
- Register cleanup on
EXIT, not on individual signals. TheEXITpseudo-signal fires on every exit path, including signal-induced exits. Registering cleanup onINTandTERMas well causes it to run twice . - Signal-specific handlers exist for messages and exit codes, not for cleanup. An
INThandler that prints “Interrupted” and exits with code 130 is correct. AnINThandler that also does cleanup is redundant with theEXITtrap. - The cleanup function must preserve
$?. Capture the exit code at the start of the function andexit "$exit_code"at the end, or the script’s failure status is lost . - Cleanup must be idempotent. Guard each removal with an existence check so that a second invocation does not produce errors .
- Bash waits for foreground commands before running traps. A
traponINTdoes not fire until the currentsleepor command completes. Usewaitfor interruptible waits . - The
ERRtrap reports failures underset -e. It is a debugging tool, not a cleanup mechanism. Use it to print the line number and command that caused the exit.
Remember: A script without a trap is a script that leaves debris behind when it is interrupted. The trap builtin is how Bash scripts fulfill their cleanup obligations. The pattern is consistent: define a cleanup function that preserves the exit code and guards against missing files, register it on EXIT, and add signal-specific handlers only if the script needs to print a message or use a different exit code when interrupted. The one mistake to avoid is registering the cleanup function on EXIT and INT/TERM simultaneously, because that causes the cleanup to run twice. Register on EXIT, let the signal handlers exit, and let the EXIT trap do the cleanup.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!