Registering and Managing Traps for Multiple Signals at Once
AI generated
$_
#!/
Bash · Signal Handling · Process Control · Linux
Traps for Multiple Signals
registered at once, told apart, and managed cleanly

A Bash script running as a daemon or long-lived process must handle SIGINT, SIGTERM and SIGHUP without leaving behind orphaned lock files or half-written output. A single trap call can register the same handler for several signals, but telling the signals apart inside the handler and avoiding double runs when combined with an EXIT trap takes a few extra tricks.

16 min read trap · SIGINT · SIGTERM · SIGHUP Bash 4.x · 5.x · Daemon scripts

1. Why signal handling matters in production Bash scripts

A script running as a background service, inside a cron job, or as part of a deployment pipeline over an extended period sooner or later receives a signal from the outside: a user presses Ctrl+C and sends SIGINT, an orchestrator such as systemd or Docker sends SIGTERM on shutdown, and a closing terminal sends SIGHUP to its child processes. Without its own handling, Bash terminates the process using that signal's default behavior, usually an immediate abort with no cleanup logic at all.

Clean signal handling ensures lock files get removed, open file descriptors get closed, buffered data gets written, and the process ends with a traceable log line instead of simply vanishing. Especially for deployment scripts holding resources like temp directories or database locks, good signal handling decides whether an aborted run blocks the next run or not.

2. Trap basics: trap 'command' SIGNAL and multiple signals in one call

The basic syntax is trap 'command' SIGNAL, where command runs upon receiving SIGNAL. Conveniently, trap accepts several signal names in a single call: trap 'cleanup' INT TERM HUP registers the same command for all three listed signals, without needing three separate trap lines.

Signal names without the SIG prefix, such as INT instead of SIGINT, work equivalently in Bash and are the usual notation in scripts. Numeric signal codes such as 2 instead of INT also work, but are less portable, since the numbering of some signals differs between systems while the names stay stable.


#!/usr/bin/env bash
set -euo pipefail

cleanup() {
  echo "Cleaning up before exit..."
  rm -f /tmp/mironsoft-deploy.lock
}

# One trap call registers the same handler for three signals
trap cleanup INT TERM HUP

echo "Working... press Ctrl+C to test"
sleep 30

3. Registering one shared handler for INT, TERM and HUP

The combination of INT, TERM and HUP covers the three most common termination requests: INT represents an interactive interruption by the user, TERM a regular termination request from an orchestration tool, and HUP, originally meant for a closed terminal, is nowadays additionally interpreted by many daemons as a request to reload configuration.

For simple scripts, a shared cleanup handler for all three signals is entirely sufficient, as long as the reaction is the same in every case: cleanly terminate the current operation. But once HUP is supposed to trigger a configuration reload instead of a shutdown, while INT and TERM should actually terminate the process, the handler needs to know which signal fired.


#!/usr/bin/env bash
set -euo pipefail

readonly LOCK_FILE="/tmp/mironsoft-deploy.lock"
touch "$LOCK_FILE"

cleanup() {
  echo "Received termination signal, shutting down gracefully"
  rm -f "$LOCK_FILE"
  exit 0
}

trap cleanup INT TERM HUP

for i in {1..10}; do
  echo "Working step $i"
  sleep 2
done

4. Distinguishing the triggering signal inside the handler

A single trap cleanup INT TERM HUP call does not by itself tell the handler which of the three signals actually arrived. To add that, register the same handler with three separate trap calls instead, each passing the signal name as an argument: trap 'cleanup INT' INT, trap 'cleanup TERM' TERM, and trap 'cleanup HUP' HUP.

Inside the function, the passed signal name is then available as $1 and can be evaluated for different behavior, for example a different exit code per signal or a different log message. This technique is the most practical route, since Bash itself offers no built-in variable that automatically holds the most recently received signal.


#!/usr/bin/env bash
set -euo pipefail

handle_signal() {
  local sig="$1"
  case "$sig" in
    HUP)
      echo "Received HUP: reloading configuration, staying alive"
      ;;
    INT|TERM)
      echo "Received $sig: shutting down"
      exit 0
      ;;
  esac
}

trap 'handle_signal HUP'  HUP
trap 'handle_signal INT'  INT
trap 'handle_signal TERM' TERM

while true; do
  sleep 5
done

5. Multiple trap calls for the same signal: order and overwriting

Bash does not stack multiple handlers for the same signal. If a script calls trap first_handler INT and later trap second_handler INT, the second call fully replaces the first, and first_handler never runs again. This differs noticeably from event systems in other languages, which typically run through several registered listeners in sequence.

Anyone who wants to run several independent cleanup steps on a single signal, for example a library module that wants to register its own cleanup logic without overwriting the script's main trap, builds a dedicated dispatcher function that calls a list of registered cleanup functions internally in sequence, instead of relying on multiple trap calls.


#!/usr/bin/env bash
set -euo pipefail

declare -a cleanup_steps=()

register_cleanup() {
  cleanup_steps+=("$1")
}

run_all_cleanups() {
  local step
  for step in "${cleanup_steps[@]}"; do
    "$step"
  done
  exit 0
}

trap run_all_cleanups INT TERM HUP

register_cleanup 'rm -f /tmp/mironsoft-deploy.lock'
register_cleanup 'echo "Closed database connection"'

6. EXIT trap combined with signal traps: interaction and avoiding double runs

A trap cleanup EXIT fires on every kind of script termination, whether normal, via an explicit exit call, or through a signal, provided the signal handler itself ends with exit. If the signal handler calls exit, that additionally triggers the registered EXIT trap afterward, which without a safeguard runs the cleanup logic twice.

A simple safeguard is a guard flag: the cleanup function checks a variable such as cleanup_done at the start, runs the actual logic only on the first call, and sets the variable afterward. That keeps the function idempotent, regardless of whether it is reached through the signal trap, the EXIT trap, or both in sequence.


#!/usr/bin/env bash
set -euo pipefail

cleanup_done=0

cleanup() {
  if [[ "$cleanup_done" -eq 1 ]]; then
    return
  fi
  cleanup_done=1
  echo "Running cleanup exactly once"
  rm -f /tmp/mironsoft-deploy.lock
}

trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM

7. Practical example: a clean shutdown script for a background daemon

A realistic daemon script combines every technique so far: INT and TERM end the main loop in an orderly way and write out any pending data, while HUP merely reloads the configuration and lets the loop keep running. That matches the behavior tools like systemd expect when they stop a service with SIGTERM or ask it to reload with SIGHUP.

It also matters to give the orchestrator enough time for an orderly shutdown before a hard SIGKILL follows, which can never be caught. A daemon that reacts promptly to SIGTERM avoids the hard kill and, with it, the loss of any data not yet written.


#!/usr/bin/env bash
set -euo pipefail

running=1
config_file="/etc/mironsoft/deploy-daemon.conf"

reload_config() {
  echo "Reloading configuration from $config_file"
  # shellcheck source=/dev/null
  source "$config_file"
}

shutdown_daemon() {
  echo "Shutting down after current iteration"
  running=0
}

trap reload_config HUP
trap shutdown_daemon INT TERM

while [[ "$running" -eq 1 ]]; do
  echo "Daemon tick $(date +%H:%M:%S)"
  sleep 5
done

echo "Daemon stopped cleanly"

8. Pitfalls: traps in subshells and inheritance into functions

A trap registered in the main script does not automatically apply inside a subshell started with parentheses ( ... ). Every component of a pipeline also runs in its own subshell, which is why a signal arriving during a running pipe may only reach the main trap after the pipeline finishes, instead of taking effect immediately in the pipeline component.

Inside functions and command substitutions of the same shell, a registered trap stays valid, since unlike parentheses, these constructs do not open their own subshell. A job started in the background with &, on the other hand, gets the default signal disposition again and must, if needed, register its own trap explicitly.

9. Signal behavior compared: which signal for what

Each of the common signals carries its own, historically grown meaning and its own default behavior when no trap is registered. A script meant to be production ready should know these meanings and stick to the established conventions as closely as possible in its own reaction, instead of introducing surprising semantics of its own.

Signal Default behavior Typical trigger Recommended reaction
SIGINT Process terminates Ctrl+C in a terminal Immediate, orderly shutdown
SIGTERM Process terminates systemd, Docker, kill Orderly shutdown with a time buffer
SIGHUP Process terminates Terminal closed, reload request Reload configuration or terminate
SIGKILL Process terminates immediately kill -9, last escalation step Cannot be caught, ensure cleanup runs before

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Traps for Multiple Signals in Bash: The Essentials at a Glance

Multiple signals, one call

trap cleanup INT TERM HUP registers the same handler for three signals in a single line.

Telling signals apart

Separate trap calls passing the signal name as an argument, e.g. trap 'cleanup INT' INT, let the handler recognize which signal fired.

No handler stacking

A new trap call for the same signal fully overwrites the previous one, multiple steps need a dedicated dispatcher list instead.

Guarding the EXIT trap

A guard flag prevents a double run when a signal handler ends with exit and thereby also triggers the registered EXIT trap.

11. FAQ: Traps for Multiple Signals in Bash: The Essentials at a Glance

1Can a single trap call cover multiple signals at once?
Yes. trap 'command' INT TERM HUP registers the same command for all three listed signals in a single line, with no separate trap calls needed.
2How does my handler know which signal triggered it?
Register the same handler with separate trap calls, each passing the signal name as an argument, for example trap 'cleanup INT' INT. Inside the function, the name is then available as $1.
3What happens if I call trap twice for the same signal?
The second call fully overwrites the first. Bash does not stack multiple handlers for one signal, only the most recently registered command runs.
4Why does my cleanup code run twice?
If the signal handler ends with exit, that additionally triggers a registered EXIT trap. A guard flag that runs the cleanup logic only on the first call makes the function idempotent.
5Why handle SIGINT, SIGTERM and SIGHUP together?
They cover the three most common termination requests: an interactive interruption, a regular termination request from an orchestration tool, and a closed terminal or reload request.
6Can I catch SIGKILL with a trap?
No. SIGKILL can never be caught or ignored, the process is terminated immediately by the kernel. Cleanup logic must be reached via SIGTERM before any escalation to SIGKILL happens.
7Does a registered trap apply inside a subshell too?
Not automatically. A subshell started with parentheses, as well as every component of a pipeline, has its own signal disposition independent of the main shell's trap.
8How should a daemon react to SIGHUP?
Historically SIGHUP meant an immediate abort, but today many daemons instead interpret it as a request to reload configuration without terminating the main process.
9Do I need to register my own trap inside a background job started with &?
Yes, if the background job needs to handle its own signals. Such a job does not automatically inherit the main shell's traps and starts with the default signal disposition.
10How much time should a daemon get for an orderly shutdown after SIGTERM?
Enough to cleanly finish ongoing operations and persist data, but not so much that the orchestrator escalates to a hard SIGKILL. Systemd typically uses a configurable timeout of a few seconds for this.