Shell Scripting Signals Traps Intermediate May 2026

Shell Scripting Signal Handling & Traps

Master Linux signals, trap for cleanup and graceful shutdown, ERR and EXIT pseudo-signals, nested trap management, signal forwarding to child processes, and real-world daemon-style script patterns.

Every running process in Linux can receive signals — notifications from the kernel or other processes asking it to stop, reload, or respond in some way. Without signal handling, your scripts leave temp files behind on Ctrl+C, corrupt output mid-write, or miss important shutdown events. With trap, you control exactly what happens.

SignalNumberDefault actionCommon use
SIGHUP1TerminateTerminal close; reload config in daemons
SIGINT2TerminateCtrl+C — interrupt from keyboard
SIGQUIT3Core dumpCtrl+\ — quit with core dump
SIGKILL9TerminateForce kill — cannot be caught or ignored
SIGTERM15TerminatePolite termination — default from kill
SIGUSR110TerminateUser-defined — reopen log files
SIGUSR212TerminateUser-defined — toggle debug mode
SIGCHLD17IgnoreChild process changed state
EXITPseudoBash: script exits for any reason
ERRPseudoBash: any command returns non-zero
DEBUGPseudoBash: before every command
RETURNPseudoBash: after sourced file or function
BASH
# trap 'handler_command' SIGNAL [SIGNAL ...]

# ── Single-line handler ───────────────────────────────────
trap 'echo "Caught SIGINT — exiting"' INT
trap 'echo "Caught SIGTERM"; exit 0' TERM

# ── Function handler (recommended for anything non-trivial) ─
handle_exit() {
  echo "Script is exiting..."
  rm -f /tmp/myapp.lock /tmp/myapp_temp.*
}
trap handle_exit EXIT

# ── Multiple signals to same handler ─────────────────────
trap handle_exit EXIT INT TERM HUP

# ── Reset a trap to default behaviour ────────────────────
trap - INT          # reset SIGINT to default
trap - EXIT INT     # reset multiple

# ── Ignore a signal ───────────────────────────────────────
trap '' HUP         # ignore SIGHUP (e.g. stay alive after logout)
trap '' INT         # ignore Ctrl+C during critical section

# ── Show current traps ────────────────────────────────────
trap -p             # print all active trap commands
trap -p EXIT        # print trap for specific signal
BASH
#!/usr/bin/env bash
# The EXIT trap runs no matter HOW the script exits:
# - normal completion
# - set -e error exit
# - explicit exit N
# - SIGINT (Ctrl+C)
# - SIGTERM
# This makes it the ideal place for ALL cleanup

set -euo pipefail

TMPDIR=$(mktemp -d)
LOCKFILE=/tmp/myapp.lock
START_TIME=$(date +%s)

cleanup() {
  local exit_code=$?
  local elapsed=$(( $(date +%s) - START_TIME ))

  echo ""
  echo "── Cleanup (exit=${exit_code}, elapsed=${elapsed}s) ──"

  # Remove temp directory
  [[ -d "${TMPDIR}" ]] && { rm -rf "${TMPDIR}"; echo "  Removed tmp dir"; }

  # Release lock
  [[ -f "${LOCKFILE}" ]] && { rm -f "${LOCKFILE}"; echo "  Released lock"; }

  # Kill child processes
  jobs -p | xargs -r kill 2>/dev/null || true

  if (( exit_code == 0 )); then
    echo "  ✔ Script completed successfully"
  else
    echo "  ✘ Script failed with exit code ${exit_code}" >&2
  fi
}

trap cleanup EXIT

# Acquire lock
echo $$ > "${LOCKFILE}"

# Main work happens in TMPDIR
echo "Working in ${TMPDIR}"
# ... rest of script ...
BASH
#!/usr/bin/env bash
# Long-running worker script with graceful shutdown

SHUTDOWN=false
CURRENT_JOB=""

handle_sigterm() {
  echo "  SIGTERM received — will stop after current job"
  SHUTDOWN=true
}

handle_sigint() {
  echo "  SIGINT (Ctrl+C) — stopping immediately"
  [[ -n "${CURRENT_JOB}" ]] && echo "  Warning: job ${CURRENT_JOB} was interrupted"
  exit 130  # 128 + 2 (SIGINT)
}

handle_sigusr1() {
  echo "  SIGUSR1 received — dumping status"
  echo "  Current job: ${CURRENT_JOB:-idle}"
  echo "  Uptime: $(( $(date +%s) - START ))s"
}

trap handle_sigterm TERM
trap handle_sigint  INT
trap handle_sigusr1 USR1

START=$(date +%s)
echo "Worker started (PID $$)"
echo "  Send SIGTERM to stop gracefully"
echo "  Send SIGUSR1 to dump status"

while ! "${SHUTDOWN}"; do
  CURRENT_JOB="job_$(date +%s)"
  echo "  Processing: ${CURRENT_JOB}"
  sleep 5   # simulate work
  CURRENT_JOB=""
done

echo "  Worker stopped cleanly"
BASH
#!/usr/bin/env bash
# Wrapper script that forwards signals to child process

CHILD_PID=""

forward_signal() {
  local sig="${1}"
  [[ -n "${CHILD_PID}" ]] && kill "-${sig}" "${CHILD_PID}" 2>/dev/null
}

trap 'forward_signal TERM' TERM
trap 'forward_signal INT'  INT
trap 'forward_signal HUP'  HUP

# Start child process
./long_running_app &
CHILD_PID=$!
echo "Child PID: ${CHILD_PID}"

# Wait for child — also wakes on signal
wait "${CHILD_PID}"
EXIT_CODE=$?
echo "Child exited with: ${EXIT_CODE}"
exit "${EXIT_CODE}"

# ── Protect critical section from interruption ───────────
echo "Starting database migration (DO NOT INTERRUPT)"
trap '' INT TERM   # ignore signals during critical section
./run_migration.sh
trap - INT TERM    # restore default handlers
echo "Migration complete — signals restored"
Key Signal received Clean exit Status info
bash — graceful shutdown demo
vriddh@prod-01:~/scripts$./worker.sh &
Worker started (PID 18421)
Send SIGTERM to stop gracefully
Processing: job_1746360842
Processing: job_1746360847
vriddh@prod-01:~/scripts$kill -USR1 18421
SIGUSR1 received — dumping status
Current job: job_1746360847
vriddh@prod-01:~/scripts$kill 18421
SIGTERM received — will stop after current job
Worker stopped cleanly
✔ Trap rules — Always use trap cleanup EXIT — it runs for every exit including set -e failures. Capture $? at the start of the handler before it's overwritten. Use SIGTERM for graceful shutdown requests and SIGUSR1/2 for custom events like reload or status dump. Never rely on signals alone for cleanup — the EXIT pseudo-signal covers everything.