| |

Bash Scripting 22 ๐Ÿง Signal Handling โ€” Catching interrupts and cleaning up with trap

โฑ๏ธ Estimated read: 13 min | |

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

FormEffect
trap 'cmd' EXITRun cmd when shell exits
trap 'cmd' INTRun cmd on SIGINT (Ctrl+C)
trap 'cmd' TERMRun cmd on SIGTERM
trap 'cmd' HUPRun cmd on SIGHUP
trap 'cmd' ERRRun cmd when set -e triggers
trap -lList signal names and numbers
trap -pShow registered traps
trap - INT TERMReset INT and TERM to default

Signal exit codes

SignalNumberConventional exit code
INT2130 (128 + 2)
TERM15143 (128 + 15)
HUP1129 (128 + 1)

The recommended pattern

ConcernTrap
Cleanuptrap cleanup EXIT
Interrupt messagetrap 'echo ...; exit 130' INT
Termination messagetrap 'echo ...; exit 143' TERM
Error reportingtrap '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

PitfallWhy It HappensFix
Cleanup runs twiceRegistered on EXIT and INT/TERMRegister on EXIT only
Trap does not fire during sleepBash waits for foreground command to completeUse wait instead of sleep for interruptible waits
Exit code lost after cleanup$? not captured or not propagatedCapture $? at function start, exit "$exit_code"
Cleanup fails because files already removedNo existence guardsGuard each removal with [[ -f ... ]]
ERR trap not firingset -e not enabledAdd set -e to the script
Trap fires before variables initializedset -e triggers on an early commandInitialize 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

ItemValue
Cleanup traptrap cleanup EXIT
Interrupt handlertrap 'echo ...; exit 130' INT
Termination handlertrap 'echo ...; exit 143' TERM
Hangup handlertrap 'echo ...; exit 129' HUP
Error reportingtrap 'echo "line $LINENO: $BASH_COMMAND"' ERR
Double execution causeRegistering cleanup on EXIT and INT/TERM
Exit code preservationlocal rc=$?; exit "$rc" in cleanup
Trap viewingtrap -p

Key takeaways:

  • Register cleanup on EXIT, not on individual signals. The EXIT pseudo-signal fires on every exit path, including signal-induced exits. Registering cleanup on INT and TERM as well causes it to run twice .
  • Signal-specific handlers exist for messages and exit codes, not for cleanup. An INT handler that prints “Interrupted” and exits with code 130 is correct. An INT handler that also does cleanup is redundant with the EXIT trap.
  • The cleanup function must preserve $?. Capture the exit code at the start of the function and exit "$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 trap on INT does not fire until the current sleep or command completes. Use wait for interruptible waits .
  • The ERR trap reports failures under set -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.

Was this helpful?


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!