Module 13 — Signals and Traps
Updated 3 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — Signals: the messages processes send
A1. A signal is a numbered tap on the shoulder
Processes cannot read each other's memory (Module 12's separate notebooks) — but they can send signals: tiny numbered notifications delivered by the kernel. A process receiving one either performs the signal's default action (usually: terminate), or — if it installed a handler — runs its own response instead. kill PID is the sending tool, and its name oversells it: kill sends a message; by default the message is SIGTERM, a polite request. The daily cast:
| Signal | Number | Meaning / source | Catchable? |
|---|---|---|---|
| SIGINT | 2 | "Interrupt" — what Ctrl-C sends to the foreground job | yes |
| SIGTERM | 15 | "Please terminate" — kill's default; the polite shutdown request | yes |
| SIGKILL | 9 | Immediate destruction by the kernel — the process never sees it | no — cannot be caught, blocked, or ignored |
| SIGHUP | 1 | "Hang-up" — your terminal died; what nohup immunizes against (Module 12) | yes (daemons traditionally reload config on it) |
| SIGPIPE | 13 | "You wrote to a pipe nobody reads" — Module 4's abandoned-writer ending | yes |
And the Module 3 debt, paid: a process killed by signal N exits with status 128+N. 130 = Ctrl-C'd (128+2). 143 = SIGTERMed (128+15). 137 = SIGKILLed (128+9) — the number every container operator learns to dread, because it is also what the out-of-memory killer leaves behind. Exit codes above 128 are death certificates with the cause filled in.
Where the analogy stops working. Evicted humans grumble but comply eventually. A process handling SIGTERM can simply decline forever (a trap that ignores it), and no amount of repeated polite mail changes that — which is precisely why SIGKILL exists and why it is uncatchable: the system needs one guaranteed eviction that requires no cooperation. The cost of guaranteed is no cleanup, ever — Part B prices that out.
🧪 Exercise 13.1 — polite death, rude death, and the death certificates
Both sleepers are killed on purpose.
sleep 30 & pid=$!
kill "$pid" # default: SIGTERM
wait "$pid" 2>/dev/null
echo "after TERM: wait verdict $?"
sleep 30 & pid=$!
kill -9 "$pid" # SIGKILL
wait "$pid" 2>/dev/null
echo "after KILL: wait verdict $?"✅ Expected result — click to reveal (contains deliberate kills)
after TERM: wait verdict 143
after KILL: wait verdict 137What to read out of it: two dead sleepers, two different death certificates — 143 = 128+15 (TERM), 137 = 128+9 (KILL). This is Module 3's reserved-range table completing itself: any $? above 128 names its signal by subtraction. Operational reading practice: a service exiting 137 was either kill -9ed by a human or — far more often — by the kernel's OOM killer; a 143 died politely, probably at the hands of an orchestrator's ordinary shutdown. The number is the incident's first clue.
Interview questions — Part A
🎯 "What is the difference between SIGTERM and SIGKILL — and why is kill -9 a last resort?" — a fixture of every ops screen; published shell-scripting lists barely cover signals (Edureka's and InterviewBit's 60-question sets contain none), so the working corpus is ops folklore and man pages — treat the topic as the question
The direct answer: SIGTERM (15, kill's default) is a catchable request — the process can flush buffers, close connections, remove temp files, then exit. SIGKILL (9) is uncatchable kernel-level termination — instant, unconditional, and cleanup-free.
Going deeper: -9's true cost is everything a graceful path would have done: half-written files, un-flushed buffers, orphaned locks (Module 14's lock files outlive their owner!), un-removed temp dirs, dropped in-flight requests. The professional sequence: kill, wait a grace period, verify, escalate to -9 only for a process that ignored polite mail — which is literally what docker stop automates (TERM, 10 seconds, KILL — Module 12 Ticket 4's whole plot).
The details that separate candidates: the 128+N death certificates (137 vs 143 telling you which death occurred, OOM-kill included); knowing SIGKILL cannot be trapped by design (the system's one guaranteed eviction); and the honest edge that even -9 cannot kill a process stuck in uninterruptible kernel sleep (D state, usually dead storage underneath) — naming that marks real operational scars.
Part B — trap: your script's affairs, in order
B1. The EXIT trap: cleanup that always runs
trap 'commands' SIGNAL tells bash: when that signal arrives, run these commands instead of dying by default. And bash adds one pseudo-signal that makes trap the backbone of reliable scripts: EXIT, which fires when the script ends for any reason — normal finish, exit 1, a failed guard, a caught signal. One line therefore guarantees cleanup on every path:
tmpdir=$(mktemp -d) # mktemp -d: create a unique temp directory, print its path
trap 'rm -rf "$tmpdir"' EXIT # from now on, leaving = cleaning(mktemp gets its full treatment in Module 14; here it is the standard partner of the EXIT trap: create safely, register the cleanup immediately, then work.) The discipline that makes this bulletproof: the trap line comes on the very next line after creating the thing it cleans — any gap is a window where an early death leaks the resource.
Where the analogy stops working. The hotel's procedure runs even if the building burns down? No — and neither does yours: SIGKILL skips every trap, EXIT included (the process never gets a final word). Power loss, OOM-kill, kill -9 — all leave the room dirty. EXIT traps make cleanup overwhelmingly likely, not certain; genuinely critical hygiene needs a second layer (startup-time cleanup of stale leftovers — Module 14's lock-file pattern does exactly this).
🧪 Exercise 13.2 — cleanup on the failure path
The script fails on purpose — and cleans up anyway.
cd ~/bash-course
nano cleanup-demo.sh # five lines:#!/bin/bash
tmpdir=$(mktemp -d)
trap 'echo "cleaning up $tmpdir" >&2; rm -rf "$tmpdir"' EXIT
echo "working in $tmpdir"
exit 1chmod +x cleanup-demo.sh
./cleanup-demo.sh ; echo "script verdict: $?"✅ Expected result — click to reveal (contains a deliberate failure)
working in /tmp/tmp.tAiMj6TyLh
cleaning up /tmp/tmp.tAiMj6TyLh
script verdict: 1What to read out of it (your random suffix differs): the script failed — verdict 1 — and the cleanup line printed anyway: EXIT fired on the failure path with no if-statements, no duplicated rm at every exit point. Verify with ls /tmp/tmp.tAiMj6TyLh (your suffix): No such file or directory — the directory really is gone. Single-line takeaway: acquire, trap, then work — in that order, always.
Part C — Trapping real signals
C1. Graceful shutdown: the worker that drains
Trapping TERM or INT turns "die now" into "finish the current item, then stop" — the graceful-shutdown shape every worker loop wants:
stop=0
trap 'stop=1; echo "shutdown requested" >&2' TERM INT
while [ "$stop" -eq 0 ]; do
process_next_item # each item completes; nothing is half-done
done
echo "drained cleanly"Two behavioral facts to carry. First: bash delays a trap while a foreground command runs — the handler fires after the current command completes (the manual says so verbatim), which is exactly what makes the finish-current-item pattern work, and also why a trap seems "laggy" during a long sleep. Second, a real gotcha: a script started in the background (&, job control off) has SIGINT ignored from birth — a trap … INT in it will simply never fire; use TERM for programmatic shutdown and keep INT for foreground Ctrl-C.
🧪 Exercise 13.3 — a worker that finishes its stitch
The worker is TERMed on purpose — mid-loop.
cd ~/bash-course
nano worker.sh # six lines:#!/bin/bash
trap 'echo "caught TERM mid-work; finishing current step first" >&2' TERM
for i in 1 2 3; do sleep 0.3; echo "tick $i"; done
echo "loop done"chmod +x worker.sh
./worker.sh & wp=$!
sleep 0.4
kill "$wp"
wait "$wp" ; echo "verdict: $?"✅ Expected result — click to reveal (contains a deliberate TERM)
tick 1
caught TERM mid-work; finishing current step first
tick 2
tick 3
loop done
verdict: 0What to read out of it (line order near the kill may vary slightly): the TERM arrived during tick 2's sleep — and the handler message appears only after that sleep finished (the delayed-trap fact, observed). Because this demo's handler only announces (no stop flag), the loop ran to completion and exited 0 — a deliberate simplification so you can see the delivery timing; the production worker above adds stop=1 so the loop condition performs the stopping. Un-trapped, the same kill would have printed nothing and left verdict 143. The difference between 143 and "drained cleanly" is four lines of trap.
Interview questions — Parts B–C
🎯 "How can you handle errors in shell scripts?" — asked verbatim at Hirist, whose answer names traps for cleanup; the trap-specific phrasing ("What is the trap command used for?") circulates in screens without one canonical published wording
The direct answer (the trap half — Module 14 owns the set-flags half): trap 'cleanup_commands' EXIT guarantees cleanup on every exit path; trap 'handler' TERM INT converts kill-requests into graceful shutdown; trap 'echo "failed at line $LINENO" >&2' ERR (with Module 14's set -e) turns silent failures into located ones.
Going deeper: the acquire-trap-work ordering; EXIT as the pseudo-signal that fires on every ending except SIGKILL; and the flag-not-work handler pattern (stop=1 in the trap, the loop does the stopping) — handlers should be tiny, because they interrupt your script at awkward moments.
The details that separate candidates: knowing traps are reset in subshells (a $( ) does not inherit your EXIT trap — Module 12's copies are shallower than they look); that trap -p prints current traps (debuggable state); and the SIGKILL honesty — trap-based cleanup is a strong promise, not an absolute one, so critical resources also get startup-time stale-sweeps.
🎯 "What does Ctrl-C actually do — and why did my backgrounded script ignore it?" — the scenario form in which signal knowledge is really probed; the mechanics live in bash's own Signals documentation rather than any question list
The direct answer: Ctrl-C makes the terminal send SIGINT to the foreground job (the whole process group — every stage of a foreground pipeline gets it). Default action: terminate, exit code 130. Background jobs are not in the foreground group — Ctrl-C never touches them, and bash additionally starts them with SIGINT ignored (job control off), so even an INT trap inside won't fire.
Going deeper: this is why kill %1 or kill $pid (SIGTERM) is the way to stop a background job, and why worker scripts trap TERM rather than INT for programmatic shutdown; INT is the human foreground signal, TERM the automation signal — a division of labor worth stating in exactly those words.
The details that separate candidates: the process-group subtlety (Ctrl-C killing a pipeline kills all its stages at once — which is what makes interrupting a | b | c feel atomic); 130 = 128+2 as the fingerprint of a Ctrl-C'd run in CI logs; and Ctrl-Z as SIGTSTP (stop, resumable with fg/bg — Module 12's rescue sequence) rather than a kill — three keystrokes, three different signals, candidates who can name all three rarely get a second signals question.
Part D — The shutdown protocol, drawn
D1. How processes should die
Diagram source
flowchart TD
A["I need a process<br>to stop"] --> B{"Who am I?"}
B -->|"human at the<br>foreground terminal"| C["Ctrl-C<br>SIGINT to the group"]
B -->|"script / operator<br>with a PID"| D["kill PID<br>polite SIGTERM"]
D --> E{"gone within the<br>grace period?"}
E -->|"yes"| F["done — exit 143<br>or its own code"]
E -->|"no"| G["investigate: D-state?<br>trap ignoring TERM?"]
G --> H["kill -9 PID<br>last resort, no cleanup"]
H --> I["exit 137 — now sweep:<br>stale locks, temp files,<br>half-written output"]
C --> J{"process traps INT/TERM?"}
D --> J
J -->|"yes"| K["handler sets a flag;<br>loop drains and exits 0"]
J -->|"no"| L["default death:<br>128+N certificate"]The diagram's quiet moral: kill -9 is not step one, it is step four — and it always creates the follow-up chore in its own box. Orchestrators encode this exact flowchart with timers; your scripts meet it from the other side by trapping TERM and draining.
Interview questions — Part D
🎯 "A process won't die after kill — walk me through what you do." — the operational scenario that signal questions build toward; asked conversationally in nearly every SRE loop, published nowhere with fixed wording
The direct answer, as a sequence: confirm delivery target (ps -o pid,ppid,stat,comm — right PID? right process?); check state — Z means already dead, un-reaped (kill the parent's negligence, not the zombie; Module 12); D means uninterruptible I/O sleep (no signal will land — investigate the storage/NFS underneath); T means stopped (resume with SIGCONT, then re-evaluate). A live process ignoring TERM may trap it — escalate deliberately: kill -9, then perform the cleanup its skipped handlers would have done (locks, temp files, partial output).
Going deeper: name the delivery subtleties — permissions (you can signal only your own processes unless root), process groups (a negative PID targets a whole group — how orchestration stops a pipeline), and the difference between "signal sent" (kill exits 0) and "process acted on it" (nothing promises that).
The details that separate candidates: the D-state honesty (even -9 waits for the kernel — the fix is below the process, not a bigger signal); and closing the loop with prevention — a service that "always needs -9" has a broken TERM handler, and that is the bug to file. Interviewers ask this question to hear a system-shaped answer, not a bigger hammer.
Part E — Production
E1. 🏭 Production practices
Every script that acquires anything carries an EXIT trap, registered on the next line. Temp files, locks, port-forwards, mounted scratch — acquire, trap, then work.
Long-running workers trap TERM (and INT for foreground use) with a flag-only handler. The loop drains; the handler just raises the flag. Handlers stay tiny.
Shutdown is TERM → grace → verify → KILL, in that order, with the order automated (systemd TimeoutStopSec, docker/K8s grace periods) — and kill -9 in a runbook always pairs with the post-kill sweep of what cleanup was skipped.
Exit codes above 128 are read as death certificates. 137 triggers the OOM question before any code review; 143 during a deploy window is normal; 130 in CI means someone's Ctrl-C, not a flaky test.
Signal roles are kept straight: INT = human foreground; TERM = automation's stop; HUP = terminal death (or daemon config-reload by convention); USR1/USR2 = app-defined (log rotation triggers, state dumps). Scripts document which they honor.
SIGKILL-resilience is designed, not hoped for: startup-time sweeps for stale locks and temp leftovers (the Module 14 lock pattern), idempotent steps (Module 6), so that the one uncleanable death is an inconvenience, not an incident.
E2. Production-practice table
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Service exits 137 at random times | SIGKILL — almost always the kernel OOM killer, not a human | Check kernel logs for oom-kill entries; graph the service's memory | Fix the leak / raise limits; treat 137 as a memory ticket, not a crash ticket |
| Temp files pile up from a script that "has cleanup at the end" | Cleanup lives at the end of the happy path; early exits and TERMs skip it | Read the script: is the rm inside a trap, or just the last line? | trap 'rm -rf "$tmpdir"' EXIT right after mktemp — every path cleans |
| A trap … INT in a backgrounded script never fires | Background jobs start with SIGINT ignored (job control off) — the trap cannot arm | Send TERM instead and watch that trap fire | Trap TERM for programmatic shutdown; reserve INT for foreground humans |
| Trap seems to fire "late" — seconds after the signal | Bash delays traps until the current foreground command completes | Note what long command (sleep, curl) was mid-flight at signal time | Expected behavior — design loops with short steps so drains are prompt |
| After a -9, the next run refuses to start: "lock held" | SIGKILL skipped the lock-removing trap; the lock outlived its owner | Check the lock's PID against a live process | Locks that record PIDs + startup-time staleness sweep (Module 14's pattern) |
| docker stop always takes the full grace period, then kills | PID 1 (a bash wrapper) receives the TERM and never forwards it | ps inside the container — is the app PID 1? | exec the app in the entrypoint (Module 12 Ticket 4); trap TERM in the app |
E3. 🎓 Capstone — four tickets from the queue
🎓 Ticket 1 — "Nightly ETL keeps leaving multi-gigabyte /tmp/etl.* directories. The author points at the cleanup block — it's right there at the bottom of the script, and it works every time we run it by hand."
Diagnosis. Cleanup-at-the-end (E2 row 2): by hand the happy path runs to the bottom; at night, the scheduler's timeout TERMs the long ETL mid-flight, and the bottom of the script is never reached. The leftovers are a calendar of every night the job overran.
Work the steps: correlate leftover timestamps with the scheduler's kill log; reproduce with Exercise 13.3's shape (TERM the script mid-loop, watch the tail never run).
Fix and prevention: promote the cleanup into trap 'rm -rf "$workdir"' EXIT registered at acquisition (13.2's pattern) — TERM now cleans too. Add a startup sweep for the existing debris (find /tmp -maxdepth 1 -name 'etl.*' -mtime +1 … — Module 11's armored pipeline) because past leaks don't fix themselves, and SIGKILL nights will still slip one through.
🎓 Ticket 2 — "Deploys 'work' but the app drops every in-flight request at restart. The team's fix so far: a sleep 15 in the deploy script after the kill, 'to let things settle.' It has not helped."
Diagnosis. No graceful shutdown anywhere: the app neither traps TERM nor drains; the kill defaults to instant-ish death mid-request, and sleeping after the death settles nothing — the requests are already gone. The sleep is cargo cult where a protocol belongs (D1's flowchart, unimplemented on the receiving side).
Work the steps: verify with the death certificate: app exit status 143, instantly, with connection errors clustered at deploy timestamps. Confirm the app has no TERM handler (its logs say nothing at shutdown).
Fix and prevention: implement the receiving half: trap TERM → stop accepting new work → finish in-flight items → deregister → exit 0 (the C1 worker shape, application-sized). Then delete the sleep and let the orchestrator's grace period do its real job: bounding the drain, not replacing it. Measure success as "zero dropped requests during a deploy," not "fewer complaints."
🎓 Ticket 3 — "An engineer's 'quick fix' script for a stuck queue does pkill -9 -f worker every five minutes from cron. The queue is unstuck. Also, we now have data corruption, and occasionally the -9 kills the monitoring agent, whose command line also contains the word 'worker'."
Diagnosis. Three sins compounding: -9 as first resort (no drain, hence the corruption — half-processed messages die with the workers); pattern-matching kills (-f worker matches any command line containing the substring — the monitoring agent's config path did); and killing on a schedule instead of on a condition (healthy workers die every five minutes too).
Work the steps: demonstrate the overmatch harmlessly: pgrep -af worker — read everything the pattern actually selects. Trace one corruption to a kill timestamp.
Fix and prevention: replace the cron hammer with the protocol: detect stuckness (no heartbeat/progress in N minutes — a Module 5 guard on a Module 11-parsed status), then TERM the specific PIDs (from a pidfile or supervisor, never a substring), grace, verify, escalate. And fix the root cause the hammer was hiding — the workers' actual hang. Every -9 in cron is an incident postponed and a root cause protected.
🎓 Ticket 4 — "CI runs show exit code 130 scattered across night builds — different tests each time, no pattern in the failures themselves."
Diagnosis. 130 = 128 + 2 = SIGINT — something is Ctrl-C-ing (or programmatically INTing) the builds. Tests don't fail with 130 on their own merits; the code is the pattern the failures lack. Suspects: a CI timeout mechanism that sends INT, a runner's cancellation (a human or auto-cancel on new commits), or a wrapper's own trap.
Work the steps: read the certificates first (this module's core habit): confirm 130 across runs; correlate timestamps with pipeline cancellations, force-pushes, and the runner's timeout settings; check whether the CI system documents INT-then-KILL as its cancellation protocol (most do — it is D1's flowchart again).
Fix and prevention: if auto-cancel is the cause, that is a feature misread as flakiness — label such runs cancelled, not failed, in reporting. If timeouts, fix the slow tests or the limit. The durable lesson for the runbook: any exit code over 128 is a death certificate — subtract 128, name the signal, and go find the sender before blaming the deceased.
E4. Documentation reference
| Topic | Authoritative reference | What you'll find there |
|---|---|---|
| All signals, defaults, catchability | signal(7) — man7.org | The full signal table; the SIGKILL/SIGSTOP uncatchable rule in canonical words |
| Sending signals | kill(1) — man7.org | TERM-by-default, signal naming, group targeting |
| trap | Bash manual — Bourne Shell Builtins | trap's syntax, EXIT/ERR pseudo-signals, listing and resetting traps |
| How bash itself handles signals | Bash manual — Signals | Delayed trap delivery, background SIGINT ignoring, SIGHUP behavior |
E5. Self-assessment
Answer from memory, out loud or on paper. Every answer is in this module.
- What is a signal, who delivers it, and what two things can a receiving process do with one?
- What does bare kill PID actually send? Why is the command's name misleading?
- SIGINT vs SIGTERM vs SIGKILL — sender, meaning, catchability, and the exit code each death leaves.
- Decode instantly: 130, 137, 143. Which one makes you check memory graphs first?
- Why is SIGKILL uncatchable by design, and what is its unavoidable cost?
- Write the acquire-trap-work pattern for a temp directory, and say why the trap line's position matters.
- What does the EXIT pseudo-signal fire on — and what is the one death it cannot cover?
- Sketch the graceful worker: what does the trap handler do, and what does the loop do? Why keep handlers tiny?
- When does bash actually run a trap that arrived mid-command? What behavior does that explain?
- Why will trap … INT never fire in a backgrounded script, and which signal should automation use instead?
- Walk the won't-die sequence: what do Z, D, and T states each tell you before any -9?
- Why does every kill -9 in a runbook come with a follow-up chore — and name three things typically on it.
E6. Sources
Hirist — Top 30+ Shell Scripting Interview Questions and Answers — "How can you handle errors in shell scripts?" (published Jul 22, 2025; last modified Dec 31, 2025)
A note on the corpus — stated plainly: signals and traps are thin in published shell-scripting question lists — Edureka's and InterviewBit's 60-question sets contain no signal questions at all (verified directly), and the working material lives in tutorials (GeeksforGeeks, LinuxCommand.org, LinuxBlog.io) and man pages. Yet SIGTERM-vs-SIGKILL, Ctrl-C mechanics, and won't-die triage are among the most-asked spoken questions in SRE and DevOps interviews. This module therefore contains more unlabeled-corpus questions than most: each is marked as a screen-style question without canonical published wording rather than given an invented citation. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2; PIDs and temp-directory suffixes are machine-dependent and flagged.