Module 13 — Signals and Traps

Updated 3 September 2026

Module 13 — Signals and Traps. What really happens on Ctrl-C, why kill -9 is a last resort, and how trap makes scripts clean up after themselves even when they die mid-run.

🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start. You need Modules 1–12, and Module 12 above all: signals are messages between processes, so the parent/child/PID model is the grammar here. Old IOUs come due: the 128+N exit family (Module 3), SIGPIPE ending abandoned writers (Modules 4 and 11), what Ctrl-C sends (Module 6), what nohup immunizes against (Module 12). Tools: nothing new.

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:

SignalNumberMeaning / sourceCatchable?
SIGINT2"Interrupt" — what Ctrl-C sends to the foreground jobyes
SIGTERM15"Please terminate" — kill's default; the polite shutdown requestyes
SIGKILL9Immediate destruction by the kernel — the process never sees itno — cannot be caught, blocked, or ignored
SIGHUP1"Hang-up" — your terminal died; what nohup immunizes against (Module 12)yes (daemons traditionally reload config on it)
SIGPIPE13"You wrote to a pipe nobody reads" — Module 4's abandoned-writer endingyes

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.

Real-world analogy — ways to end a meeting. SIGINT is knocking on the meeting-room door: "we need the room" — the occupants wrap up. SIGTERM is the office manager's email: "please vacate by five" — formal, expected to be honored, time allowed for packing (saving files, closing connections). SIGKILL is building security cutting the power and carrying everyone out: effective, instant, and nothing gets packed — whiteboards half-erased, coffee still on.

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.

bash
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)
plain text
after TERM: wait verdict 143
after KILL: wait verdict 137

What 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:

bash
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.

Real-world analogy — the hotel checkout clause. An EXIT trap is the hotel's checkout procedure filed at check-in: however your stay ends — planned departure, emergency evacuation, being asked to leave — housekeeping runs the same reset on the room. You wrote the reset once, at the moment you took the key, and stopped thinking about it.

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.

bash
cd ~/bash-course
nano cleanup-demo.sh    # five lines:
bash
#!/bin/bash
tmpdir=$(mktemp -d)
trap 'echo "cleaning up $tmpdir" >&2; rm -rf "$tmpdir"' EXIT
echo "working in $tmpdir"
exit 1
bash
chmod +x cleanup-demo.sh
./cleanup-demo.sh ; echo "script verdict: $?"
Expected result — click to reveal (contains a deliberate failure)
plain text
working in /tmp/tmp.tAiMj6TyLh
cleaning up /tmp/tmp.tAiMj6TyLh
script verdict: 1

What 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:

bash
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.

bash
cd ~/bash-course
nano worker.sh    # six lines:
bash
#!/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"
bash
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)
plain text
tick 1
caught TERM mid-work; finishing current step first
tick 2
tick 3
loop done
verdict: 0

What 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.

Now imagine this at 500 hosts. Graceful shutdown is not politeness at scale — it is data integrity. Orchestrators (Kubernetes, systemd, docker stop) all speak the same protocol: SIGTERM, a grace window, then SIGKILL. A fleet worker that traps TERM and drains — finishes its item, flushes its buffers, deregisters from the load balancer, removes its lock — turns every deploy and every autoscale-down into a non-event. One that doesn't turns each into a small lottery of half-written records, which at 500 hosts is a nightly guarantee. This is also why Module 12's exec entrypoint fix mattered: the TERM must reach the worker to be trapped — signal delivery and signal handling are two halves of one design.

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

Note: the diagram below is a Mermaid code block. Notion does not render it automatically — click the block and switch it from "Code" to "Preview" (or "Split") to see the flowchart.
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

These are the practices in production — how the things this module taught are actually done on real systems:

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

SymptomWhat is really happeningWhat to runThe fix
Service exits 137 at random timesSIGKILL — almost always the kernel OOM killer, not a humanCheck kernel logs for oom-kill entries; graph the service's memoryFix 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 itRead 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 firesBackground jobs start with SIGINT ignored (job control off) — the trap cannot armSend TERM instead and watch that trap fireTrap TERM for programmatic shutdown; reserve INT for foreground humans
Trap seems to fire "late" — seconds after the signalBash delays traps until the current foreground command completesNote what long command (sleep, curl) was mid-flight at signal timeExpected 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 ownerCheck the lock's PID against a live processLocks that record PIDs + startup-time staleness sweep (Module 14's pattern)
docker stop always takes the full grace period, then killsPID 1 (a bash wrapper) receives the TERM and never forwards itps 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

Work each ticket yourself before opening the answer. Everything needed was taught in Modules 1–13.
🎓 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

TopicAuthoritative referenceWhat you'll find there
All signals, defaults, catchabilitysignal(7) — man7.orgThe full signal table; the SIGKILL/SIGSTOP uncatchable rule in canonical words
Sending signalskill(1) — man7.orgTERM-by-default, signal naming, group targeting
trapBash manual — Bourne Shell Builtinstrap's syntax, EXIT/ERR pseudo-signals, listing and resetting traps
How bash itself handles signalsBash manual — SignalsDelayed trap delivery, background SIGINT ignoring, SIGHUP behavior

E5. Self-assessment

Answer from memory, out loud or on paper. Every answer is in this module.

  1. What is a signal, who delivers it, and what two things can a receiving process do with one?
  2. What does bare kill PID actually send? Why is the command's name misleading?
  3. SIGINT vs SIGTERM vs SIGKILL — sender, meaning, catchability, and the exit code each death leaves.
  4. Decode instantly: 130, 137, 143. Which one makes you check memory graphs first?
  5. Why is SIGKILL uncatchable by design, and what is its unavoidable cost?
  6. Write the acquire-trap-work pattern for a temp directory, and say why the trap line's position matters.
  7. What does the EXIT pseudo-signal fire on — and what is the one death it cannot cover?
  8. Sketch the graceful worker: what does the trap handler do, and what does the loop do? Why keep handlers tiny?
  9. When does bash actually run a trap that arrived mid-command? What behavior does that explain?
  10. Why will trap … INT never fire in a backgrounded script, and which signal should automation use instead?
  11. Walk the won't-die sequence: what do Z, D, and T states each tell you before any -9?
  12. Why does every kill -9 in a runbook come with a follow-up chore — and name three things typically on it.

E6. Sources

Interview questions in this module were captured verbatim from:

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.

Next: you can now clean up while dying — time to stop dying accidentally at all. Strict mode (set -euo pipefail) and its sharp edges, safe temp files, lock files, and ShellCheck: the module that turns everything so far into scripts you trust unattended: Module 14 — Error Handling and Defensive Scripting.
Spotted a mistake or want something added? Send me a note.