Module 04 — Signals, Sessions & Job Control
Updated 21 August 2026
Module 02 gave you two signals as a working minimum: TERM to ask politely, KILL to force it. This module covers the rest — what a signal actually is, why two of them cannot be refused, what Ctrl-C really does, and why closing a terminal sometimes kills your job and sometimes does not.
🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
From Module 01 — the user space / kernel boundary, what a system call is, and how to read /proc.
From Module 02 — PIDs and the process tree, fork and exec, exit status and the 128 + N rule, process states including D.
From Module 03 — file descriptors, and that descriptors 0, 1 and 2 normally point at your terminal.
📡 Part A · What a signal is
A1 · What a signal really is
A signal is the simplest way one part of the system can interrupt a process. It is worth being precise about how little it is:
A signal is a number. That is the entire message.
There is no text, no payload, no sender's address, and no way to reply. Signal 15 arrives and the process knows only that signal 15 arrived. Everything else — what it means, what to do about it — is convention.
Mechanically, delivering a signal is not a message being passed anywhere. The kernel just sets a bit in the target process's record — the record you met in Module 02. Signal 15 pending? Set the bit for signal 15. (In the masks you will read later, signal N is bit N−1, so that is bit 14.)
Then nothing happens until that process next runs. This becomes important in Section A5.
You fit a doorbell that can play thirty different tunes. Everyone in the neighbourhood agrees what each tune means. One means "post has arrived". One means "the building is on fire". One means "pause what you are doing".
Now notice how little the doorbell gives you:
- You cannot tell who rang it. The chime is the whole message.
- You cannot ask a question back. There is no channel for a reply.
- You cannot attach anything to it. No note, no parcel, no explanation.
That is why signals are used for short, blunt instructions — stop, pause, reload your config — and never for passing data. Anything richer needs a different mechanism.
And the meanings are agreed convention, not physics. There is nothing about the number 15 that means "please shut down". Everyone simply agreed.
Where the analogy stops working — and it is Section A5. A doorbell rings whether or not you are home.
A signal is more like a light that comes on above your door. It stays on until you come out and look. If you are shut in a room that cannot be interrupted, the light can stay on forever and you will never see it.
🧪 Exercise A1.1 — See the whole list
# Every signal this system has, with its number
kill -l
# How many NAMES are listed? (kill -l prints a number and a name for each)
kill -l | wc -w
# The ones you will actually meet, by number
for n in 1 2 3 9 15 17 18 19; do
printf "%3s = %s\n" "$n" "$(kill -l $n)"
done✅ Expected result — click to reveal
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
...
$ kill -l | wc -w
124
1 = HUP
2 = INT
3 = QUIT
9 = KILL
15 = TERM
17 = CHLD
18 = CONT
19 = STOPWhat to read out of it.
wc -w counts 124 words, because kill -l prints a number and a name for each one — so that is 62 signals, numbered 1 to 64. Numbers 32 and 33 are reserved by the threading library and are not shown.
Of those, the first 31 are the standard signals. The rest are "real-time" signals that ordinary system administration almost never touches.
You already know two of these numbers from Module 02. When a container exits with 137, that is 128 + 9 — and 9 is right there in the list as SIGKILL. Exit code 143 is 128 + 15, which is SIGTERM. The list you are looking at is what turns those exit codes into a diagnosis.
Notice SIGCHLD at 17. That is the signal a parent gets when a child dies — the notification that a child is waiting to be reaped, from Module 02's Section C3. It was there all along.
The names are stable everywhere. So write kill -TERM and kill -HUP in scripts, not kill -15 and kill -1. It costs nothing and it removes a whole class of portability bug.
A2 · The signals you will actually meet
There are 31 standard signals. You will genuinely use about ten. Here they are, grouped by why they turn up.
| Signal | No. | What it means and where you meet it |
|---|---|---|
| You send these | ||
| SIGTERM | 15 | "Please shut down." The default for kill. Catchable, so the process can clean up first. |
| SIGKILL | 9 | "Stop now." Cannot be caught or ignored. No cleanup happens. |
| SIGHUP | 1 | Originally "your terminal disconnected". Now also the conventional "reload your config" signal for daemons. |
| SIGUSR1 / SIGUSR2 | 10 / 12 | Reserved for applications to define. Nginx uses USR1 to reopen log files after rotation. |
| Your keyboard sends these | ||
| SIGINT | 2 | Ctrl-C. "Interrupt." Catchable — which is why some programs ask "press again to confirm". |
| SIGTSTP | 20 | Ctrl-Z. "Suspend." Catchable, unlike its cousin below. |
| SIGQUIT | 3 | Ctrl-. Like INT but also writes a core dump. Java uses it to print a thread dump. |
| The system sends these | ||
| SIGCHLD | 17 | "A child of yours has finished." This is how a parent knows to call wait — Module 02, Section C3. |
| SIGSTOP / SIGCONT | 19 / 18 | Pause and resume. STOP cannot be caught. These produce the T state from Module 02. |
| SIGPIPE | 13 | "You wrote to a pipe nobody is reading." Module 03's chute with nobody at the bottom. |
| SIGSEGV | 11 | The program touched memory it does not own. The kernel stops it. |
Originally it meant exactly what the name says: hang up. In the days of dial-up terminals, when the phone line dropped, the kernel told everything on that terminal that their connection was gone. Part D is about the modern version of that.
But a daemon has no terminal, so it can never receive HUP for that reason. Which left it free. So by convention it was reused to mean "re-read your configuration" — which is why nginx -s reload, systemctl reload, and countless services all use HUP.
Same signal, two unrelated meanings, decided entirely by whether the process has a terminal. This is worth knowing because it is a favourite interview question and the answer sounds like trivia until you can explain why one signal ended up doing both jobs.
🧪 Exercise A2.1 — Watch different signals produce different exit codes
# From Module 02: killed by signal N gives exit code 128 + N.
# Signal a separate victim process, not the shell itself.
for sig in TERM INT HUP QUIT KILL; do
sleep 5 & p=$!
sleep 0.2
kill -"$sig" "$p" 2>/dev/null
wait "$p" 2>/dev/null
code=$?
printf "SIG%-5s -> exit code %s (128 + %s)\n" "$sig" "$code" "$((code-128))"
done✅ Expected result — click to reveal
SIGTERM -> exit code 143 (128 + 15)
SIGINT -> exit code 130 (128 + 2)
SIGHUP -> exit code 129 (128 + 1)
SIGQUIT -> exit code 131 (128 + 3)
SIGKILL -> exit code 137 (128 + 9)
(you will also see a "Terminated" / "Killed" job message from the shell between lines)What to read out of it.
Every one of those exit codes is 128 + the signal number from the table in Exercise A1.1. The arithmetic works every time, because a process killed by a signal never chose an exit status — it never got the chance — so the shell reports what happened to it instead.
Notice the exercise signals a separate sleep, not the shell itself. That is deliberate: a shell that kills itself with SIGINT or SIGQUIT behaves differently — a non-interactive bash ignores SIGQUIT outright, and an interactive one abandons the loop on SIGINT. Signalling a victim keeps the arithmetic clean.
These five numbers are worth recognising instantly:
- 143 — a normal, polite shutdown. Usually fine.
- 137 — SIGKILL. In a container, nearly always the memory limit or an expired grace period.
- 130 — somebody pressed Ctrl-C.
- 129 — SIGHUP. A terminal disconnected, or a reload went wrong.
- 131 — SIGQUIT, which also leaves a core dump behind if core dumps are enabled.
Those two send you to completely different places, and the raw number is the only clue you get in most CI interfaces.
A3 · The three things a process can do with a signal
For each signal, a process has one of exactly three arrangements in place. This arrangement is called the disposition.
| Disposition | What happens | Example |
|---|---|---|
| Default | The kernel does whatever that signal's built-in action is — usually terminate, sometimes pause, sometimes nothing. | Most signals in most programs |
| Ignore | The signal is discarded on arrival. The process is never told. | A daemon ignoring SIGHUP so it survives its terminal closing |
| Catch | The process installs its own function. When the signal arrives, that function runs, then normal work resumes. | A web server catching TERM to finish in-flight requests |
The default actions themselves come in a few flavours: terminate (most signals), terminate and write a core dump (QUIT, SEGV, ABRT), stop (STOP, TSTP), continue (CONT), and ignore (CHLD, URG).
That last one matters. SIGCHLD's default action is to do nothing. Which is exactly why zombies pile up in Module 02 — the parent is told, and by default the notification is simply discarded.
For each of your thirty chimes, you have decided in advance what happens.
- Default is the standing house rule. The fire alarm chime means everybody leaves the building. Nobody decides anything in the moment; it is simply what happens.
- Ignore is disconnecting that particular chime. It plays to an empty room. You are not making a decision to ignore it — you genuinely never hear it.
- Catch is having your own plan: when the delivery chime sounds, save your work, go to the door, come back and carry on.
The important part is the last four words. You come back and carry on. A caught signal is an interruption, not an ending. Your program stops mid-instruction, runs the handler, and resumes exactly where it was.
Where the analogy stops working — and it causes real bugs. A person answering the door finishes their sentence first.
A signal handler can interrupt your program anywhere, including halfway through writing to a file or halfway through allocating memory. If the handler then touches the same things, it finds them in a half-finished state. This is why only a small, specific set of operations is considered safe inside a handler, and why the usual advice is to have the handler set one flag and get out.
🧪 Exercise A3.1 — All three dispositions, side by side
# 1. DEFAULT: no arrangement made, so the built-in action runs
bash -c 'sleep 30' &
P1=$!; sleep 0.3; kill -TERM $P1; sleep 0.5
echo "default -> still running? $(ps -p $P1 >/dev/null && echo yes || echo no)"
# 2. IGNORE: the signal is discarded on arrival
bash -c 'trap "" TERM; while :; do sleep 1; done' &
P2=$!; sleep 0.3; kill -TERM $P2; sleep 0.5
echo "ignore -> still running? $(ps -p $P2 >/dev/null && echo yes || echo no)"
# 3. CATCH: run our own code, then carry on
# Note the loop rather than a single long sleep - see the callout below.
bash -c 'trap "echo [handler ran]" TERM; while :; do sleep 1; done' &
P3=$!; sleep 0.3; kill -TERM $P3; sleep 0.5
echo "catch -> still running? $(ps -p $P3 >/dev/null && echo yes || echo no)"
kill -9 $P2 $P3 2>/dev/null✅ Expected result — click to reveal
default -> still running? no
ignore -> still running? yes
[handler ran]
catch -> still running? yesWhat to read out of it.
Same signal, sent the same way, three completely different outcomes. Nothing about the sending changed — only what the receiver had arranged in advance.
The third case is the one worth staring at. The handler ran, printed its message, and the process kept running. Catching a signal does not mean the process stops. It means the process gets to decide, and here it decided to do nothing much.
This is exactly why a shutdown script cannot assume kill worked. Module 02 made that point; now you can see all three reasons it might not.
A well-behaved service catches TERM and shuts down cleanly. A badly written one catches TERM, logs something, and forgets to actually exit. From the outside both look identical: kill succeeds and the process is still there.
The way to tell them apart is to look at what it does next. A process that is shutting down will close listening sockets and stop accepting work — visible in ls -l /proc/PID/fd from Module 03. One that logged and forgot will still be holding everything.
With trap ... TERM; sleep 30, sending TERM does nothing visible for up to thirty seconds — the handler is queued behind the sleep. Written as while :; do sleep 1; done, the longest wait is one second, so the handler fires almost immediately.
This is a bash characteristic, not a kernel one, and it catches people out constantly when writing shutdown handlers in shell scripts. If a trap seems not to fire, check what the script is blocked on. The usual fix in real scripts is sleep 30 & wait $!, because wait is interrupted by a signal.
A4 · The two nobody can refuse
Two signals cannot be caught, cannot be ignored, and cannot be blocked:
- SIGKILL (9) — the process is removed. It is never told.
- SIGSTOP (19) — the process is paused. It is never told.
The reason is simple: there has to be something a process cannot argue with. If every signal could be caught, a program could make itself genuinely unstoppable, and the system would have no final say over its own machine.
So these two are handled entirely by the kernel. The process never sees them and gets no chance to react. That is also why neither allows any cleanup — there is nowhere for cleanup code to run.
A zombie. The process already finished. There is nothing left to signal — only a record holding an exit status. kill -9 is aimed at something that is not there.
A process in D state. The process is alive but is inside the kernel waiting for storage, and signals are not delivered during that wait. The KILL is recorded as pending and acted on when the wait ends. If the storage never responds, that is never.
So the accurate statement is: SIGKILL cannot be refused, but it can fail to be delivered. Being able to draw that distinction is one of the strongest things you can say on this topic in an interview.
Every other chime is a request. You choose what to do about it.
SIGKILL is the landlord letting themselves in with the master key and removing you from the building. You are not asked. You are not warned. You do not get to grab your coat.
That is exactly why it is a bad first choice. Nothing was saved, nothing was closed properly, and whatever you were in the middle of is simply left in the middle.
SIGSTOP is the same absoluteness, applied to pausing rather than removing: you are frozen mid-step and cannot object.
Where the analogy stops working — and it explains the two failures above. A landlord with a master key can always get in.
The kernel cannot always act. If you are already gone (a zombie), there is nobody to remove. And if you are in a room that physically cannot be opened until a machine finishes running (D state), the landlord waits outside with the key in their hand. The key works perfectly. The door is the problem.
🧪 Exercise A4.1 — Try to refuse the unrefusable (meant to fail)
# Ask bash to ignore KILL and STOP, then send them anyway.
bash -c 'trap "" KILL; trap "" STOP; trap "echo caught TERM" TERM; while :; do sleep 1; done' &
STUBBORN=$!
sleep 0.5
# What does the process think it is ignoring or catching?
grep -E 'SigIgn|SigCgt|SigBlk' /proc/$STUBBORN/status
# TERM is caught, as arranged
kill -TERM $STUBBORN; sleep 0.5
ps -o pid,stat,comm -p $STUBBORN --no-headers
# Now STOP. Predict whether the trap protects it.
kill -STOP $STUBBORN; sleep 0.5
ps -o pid,stat,comm -p $STUBBORN --no-headers
kill -CONT $STUBBORN; sleep 0.5
# And KILL
kill -KILL $STUBBORN; sleep 0.5
ps -o pid,stat,comm -p $STUBBORN --no-headers || echo "gone"✅ Expected result — click to reveal
$ grep -E 'SigIgn|SigCgt|SigBlk' /proc/7801/status
SigBlk: 0000000000010000
SigIgn: 0000000000000004
SigCgt: 0000000000014002
$ kill -TERM $STUBBORN
caught TERM
7801 S bash
$ kill -STOP $STUBBORN
7801 T bash
$ kill -KILL $STUBBORN
goneWhat to read out of it — and the first line is the most interesting.
SigIgn: 0000000000000004. That is a bitmask. Signal N occupies bit N−1, worth 2^(N−1) — so 0x4 is signal 3, SIGQUIT. (Bash ignores SIGQUIT in background jobs.) There is no bit set for signal 9 or signal 19.
You asked bash to ignore KILL and STOP. Bash accepted the commands without complaint. And the kernel simply did not record them, because it is not possible to record them. The mask shows only what actually took effect.
Then the behaviour follows exactly:
- TERM was caught and printed its message. The arrangement worked.
- STOP put the process into state T, the stopped state from Module 02, despite the trap.
- KILL removed it, despite the trap.
The bitmasks in /proc/PID/status are worth knowing about generally. SigIgn is what a process ignores, SigCgt is what it catches, SigBlk is what it has temporarily blocked.
To read one, remember signal N is bit N−1: SIGHUP (1) is 0x1, SIGQUIT (3) is 0x4, SIGTERM (15) is 0x4000, SIGCHLD (17) is 0x10000. So SigCgt: 0x14002 here is SIGCHLD + SIGTERM + SIGINT, and SigBlk: 0x10000 is bash blocking SIGCHLD while it waits for a child.
On a service that is not responding to shutdown, checking SigCgt tells you whether it has even installed a TERM handler. Exact values vary a little between interactive and script contexts — what matters is which bits are set, not matching these digits.
PID 1 is special. For PID 1, signals whose default action is "terminate" are simply not delivered unless PID 1 has explicitly installed a handler for them. The kernel protects PID 1 from being killed by accident.
In a container, PID 1 is your application. So an application that never installs a TERM handler is, by this rule, immune to docker stop — which then waits out the full grace period and uses KILL. The grace period is 10 seconds for Docker and 30 for a Kubernetes pod, which is the mechanism behind the Module 02 answer about containers taking 30 seconds to stop.
A5 · When a signal is actually delivered
Sending a signal and delivering it are two separate events, and the gap between them explains several things that otherwise look like bugs.
When you run kill, the kernel marks the signal pending in the target's record and returns immediately. Your kill command has finished. Nothing has happened to the target yet.
The signal is delivered the next time the kernel is about to hand that process back the CPU in user space. At that moment the kernel checks the pending bits and acts.
So a signal is only ever noticed at the boundary from Module 01 — on the way from kernel space back into user space.
This explains three things at once:
- A process in D state never notices. It is inside the kernel, not returning to user space, so the check never happens. Its pending bit stays set.
- kill returning success proves nothing. It means "marked pending", not "acted upon". Module 02 said this; now you know why.
- A process can temporarily block signals. Programs do this around short critical sections so they cannot be interrupted halfway. Blocked signals stay pending until unblocked.
Someone rings your desk while you are in a meeting. They do not reach you. What happens is that a light comes on on your phone.
The message is not delivered when it is sent. It is delivered when you get back to your desk and see the light.
That one difference explains everything in this section:
- Somebody who leaves a message and walks away has no idea whether you have seen it. That is kill returning success.
- If you are in a meeting that genuinely cannot be interrupted, the light stays on for as long as the meeting lasts. That is a process in D state.
- And if the meeting never ends — a hung storage device — you never come back, and the light burns forever. That is why kill -9 does nothing to a D-state process.
- You can also put your phone on hold deliberately while you finish something delicate. The light still comes on; you just deal with it a moment later. That is signal blocking.
Where the analogy stops working, in a way that catches people out. Twenty missed calls leave twenty messages.
Twenty identical signals leave one pending bit. Standard signals are not queued — they are a single flag per signal. Send SIGUSR1 a hundred times to a busy process and it may act on it once. Any design that counts signals is broken from the start.
🧪 Exercise A5.1 — Ignored is not the same as pending
# A process that IGNORES TERM for 6 seconds, then installs a real handler.
# We will send TERM while it is ignored and show it does NOT sit pending.
bash -c '
trap "echo [TERM finally handled]" TERM
# bash cannot block signals directly, so we simulate a busy uninterruptible
# period by using a program that ignores it and then stops ignoring it
trap "" TERM
sleep 6
trap "echo [TERM finally handled]; exit 0" TERM
sleep 30
' &
P=$!
sleep 1
echo "--- sending TERM while it is being ignored ---"
kill -TERM $P
sleep 1
grep -E 'SigPnd|SigIgn|SigCgt' /proc/$P/status
ps -o pid,stat,comm -p $P --no-headers
echo "--- now compare with a D-state process ---"
dd if=/dev/zero of=/tmp/sigtest bs=1M count=1500 oflag=direct 2>/dev/null &
D=$!
for i in $(seq 1 15); do
st=$(ps -o stat= -p $D 2>/dev/null)
if [ "$st" = "D" ]; then
kill -KILL $D
echo "sent KILL while in D state; state right after: $(ps -o stat= -p $D 2>/dev/null || echo gone)"
break
fi
sleep 0.2
done
wait $D 2>/dev/null; kill -9 $P 2>/dev/null; rm -f /tmp/sigtest✅ Expected result — click to reveal
--- sending TERM while it is being ignored ---
SigPnd: 0000000000000000
SigIgn: 0000000000004006
SigCgt: 0000000000010000
8102 S bash
--- now compare with a D-state process ---
sent KILL while in D state; state right after: DWhat to read out of it — the two halves show two different things.
First half. SigPnd is all zeros, and the process is still running. An ignored signal is not held pending — it is thrown away the moment it arrives. Ignoring is not deferring. The signal is gone and nothing will ever act on it.
That is an important distinction: ignored means discarded, blocked means deferred. Look at SigIgn and you can see which signals this process is discarding — 0x4004 is signal 3 (0x4, SIGQUIT) plus signal 15 (0x4000, SIGTERM).
Second half, and this is the one that matters operationally. A SIGKILL was sent to a process in D state, and immediately afterwards the process is still there, still in D.
The unrefusable signal did nothing, because the process was not at the boundary where signals get checked. It was inside the kernel waiting for the disk. When the write finished, it left D, hit the boundary, and only then did the KILL take effect.
On a healthy disk this lasts milliseconds and you would never notice. On a hung NFS mount it lasts forever, and that is the unkillable process from Module 02.
A process with a non-zero SigPnd that is not clearing has been sent something it has not acted on. Combined with state D, that is a definitive answer to "why won't this thing die" — not a guess, but the kernel telling you the signal is sitting there undelivered.
🎯 Interview questions — Signals
Q. What is the difference between SIGTERM, SIGKILL and SIGINT?
All three end a process by default, and the differences are about who is in control.
SIGTERM (15) is the polite request. It is what kill sends by default. The process can catch it and shut down cleanly — finish in-flight requests, flush buffers, close connections, deregister from a load balancer. It can also ignore it entirely.
SIGINT (2) is what Ctrl-C sends. Also catchable, but it comes from the terminal and goes to the whole foreground process group, not to one process. That group behaviour is the real difference from TERM.
SIGKILL (9) cannot be caught, blocked or ignored. The kernel removes the process without telling it. No cleanup of any kind.
The correct order is always TERM, wait, then KILL — which is exactly what systemctl stop does with TimeoutStopSec and Kubernetes does with terminationGracePeriodSeconds.
The details that separate candidates:
- Say why SIGKILL has to be uncatchable. There must be one thing a process cannot argue with, or a program could make itself genuinely unstoppable.
- Name what you lose with -9: unflushed writes, connections dropped mid-request, stale lock and PID files, temp files never cleaned up.
- Know the two cases where -9 still does nothing — a zombie (already dead, nothing to signal) and a D-state process (alive, but not at a point where signals are checked). The precise phrasing is that SIGKILL cannot be refused, but it can fail to be delivered.
Q. Can a process ignore SIGKILL? What does kill returning success actually tell you?
No. SIGKILL and SIGSTOP cannot be caught, blocked or ignored. They are handled entirely by the kernel and the process is never informed.
But kill succeeding tells you far less than people assume. It means the signal was marked pending on the target, and nothing more. It does not mean the target noticed, acted, or stopped.
Delivery happens separately, when the kernel is about to return that process to user space. Until then the signal sits as a bit in the process's record.
The details that separate candidates:
- Explain D state properly. A process waiting on storage inside the kernel never reaches the point where pending signals are checked, so even SIGKILL sits there. If the storage never answers — dead disk, hung NFS — it waits forever. You can see the pending bit in SigPnd in /proc/PID/status.
- Distinguish ignored from blocked. An ignored signal is discarded on arrival and is gone. A blocked signal stays pending and is delivered once unblocked. SigIgn and SigBlk show which is which.
- Mention PID 1. For PID 1, signals with a default action of terminate are not delivered at all unless PID 1 installed a handler. This is why a container whose application never handles TERM cannot be stopped politely.
- Standard signals are not queued. Sending the same signal a hundred times to a busy process sets one bit; it may be acted on once. Any design that counts signals is broken.
Q. What is SIGHUP used for?
Two completely different things, and which one applies depends on whether the process has a terminal.
Originally: "the terminal went away." When a session leader exits or a connection drops, the kernel sends SIGHUP to the foreground process group of that terminal. The default action is to terminate, which is why background jobs can die when you close a window. It is literally named after a phone line hanging up.
By convention: "reload your configuration." A daemon has no controlling terminal, so it can never receive HUP for the original reason. That left the signal free, and it was reused. nginx -s reload, systemctl reload for many units, and rsyslog all use HUP to re-read config without restarting and without dropping connections.
The details that separate candidates:
- Explain why one signal ended up with two jobs. Most candidates know both meanings; being able to say why they do not conflict — a daemon has no terminal, so the original meaning cannot reach it — turns trivia into understanding.
- Connect it to log rotation. From Module 03: renaming a log file leaves the daemon writing to a now-nameless inode. logrotate uses a postrotate script to send HUP (or USR1 for nginx) so the daemon reopens the file by name. Without it, the new log file stays empty and the old one keeps growing invisibly.
- Name the ways to survive it: nohup sets the disposition to ignore, disown removes the job from the shell's list so no HUP is sent, and setsid puts the process in a new session with no controlling terminal at all. Three different mechanisms for the same goal — Part D covers them.
✍️ Part B · Sending and handling signals
B1 · Sending — kill, pkill, and choosing your target
kill takes a target and a signal. The interesting part is that the target can mean four different things depending on the number you give it.
| What you write | Who receives it | When you want this |
|---|---|---|
| kill -TERM 1234 | Just PID 1234 | Normal use |
| kill -TERM -1234 | Every process in process group 1234 | Stopping a whole pipeline or job. Note the minus sign. |
| kill -TERM 0 | Every process in your own group | Rare, and easy to do by accident |
| kill -TERM -1 | Every process you are allowed to signal | Almost never. As root this includes nearly everything. |
The second row is the one worth learning, and Part C explains what a process group actually is. For now: a pipeline like a | b | c is three processes in one group, and a single negative-number kill stops all three.
The friendlier tools:
- pgrep finds PIDs by name. pkill sends a signal to whatever pgrep would have found.
- pkill -f matches against the whole command line, not just the program name. Essential when everything is called java or python3.
The habit that prevents accidents: run pgrep -af first, look at what comes back, and only then run pkill. Same pattern, same matching, no signals sent. It costs two seconds.
The classic incident is pkill -f test on a production host, which matches test, latest, contest, and the deployment script with latest in its path.
You are in an office and you need to tell someone something. You have four ways to address it.
- To one person by name. That is kill PID. Precise, and you need to know who.
- To a whole team at once. That is kill -PGID, the negative form. Useful because a job is usually several people working together, and telling only one of them is not enough.
- To your own team, including yourself. That is kill 0, and the "including yourself" part is exactly why it surprises people.
- Over the building tannoy. That is kill -1. Almost never what you want, and as root it reaches nearly everyone.
pkill is different again. It is not addressing anyone — it is saying "whoever is wearing a blue shirt, you are being told this." Very convenient, and the reason you check who is wearing blue before you speak.
Where the analogy stops working. In an office you can see who turns round. Signals give you no acknowledgement of any kind. You never learn who received it or what they did. That is why kill succeeding tells you nothing, and why every shutdown sequence needs a timeout and a check rather than an assumption.
🧪 Exercise B1.1 — Target one process, then a whole group
# A pipeline is several processes. Start one that stays alive.
sleep 300 | cat | cat &
sleep 0.5
# Look at the group: same PGID, different PIDs
ps -eo pid,pgid,comm --no-headers | grep -E 'sleep|cat' | head -5
# Kill only the first process. Does the pipeline stop?
FIRST=$(pgrep -n sleep)
kill -TERM $FIRST; sleep 0.5
echo "--- after killing just the sleep ---"
ps -eo pid,pgid,comm --no-headers | grep -E 'sleep|cat' | head -5
# Now target the whole GROUP with a negative PID
PGID=$(ps -o pgid= -p $(pgrep -n cat) | tr -d ' ')
echo "killing whole group $PGID"
kill -TERM -"$PGID" 2>/dev/null; sleep 0.5
ps -eo pid,pgid,comm --no-headers | grep -E 'sleep|cat' | head -5 || echo "all gone"
# And the safe way to use pattern matching
echo "--- always look first ---"
sleep 200 &
pgrep -af "sleep 200"
pkill -f "sleep 200"✅ Expected result — click to reveal
$ ps -eo pid,pgid,comm --no-headers | grep -E 'sleep|cat'
9101 9101 sleep
9102 9101 cat
9103 9101 cat
--- after killing just the sleep ---
9102 9101 cat
9103 9101 cat
killing whole group 9101
all gone
--- always look first ---
9210 sleep 200What to read out of it.
Three processes, three different PIDs, and one shared PGID of 9101. The group ID is the PID of the first process in the pipeline — the group leader. That is where process groups come from, and Part C covers it properly.
Killing just the sleep left both cat processes running. This is the thing that catches people out: stopping the first stage of a pipeline does not stop the pipeline. The cat processes are still there waiting for input that will never arrive.
Using the negative PGID took all three out in one go. That minus sign is the whole difference between "this process" and "this job".
And pgrep -af showed exactly what pkill -f was about to hit, before it hit it.
It is also exactly why systemd does not rely on signalling a single PID. It puts every process of a unit into a cgroup and stops all of them together — a mechanism that does not depend on process groups, parents, or anyone forwarding anything.
B2 · Catching — trap
In a shell script, trap installs a handler. The form is:
trap 'commands to run' SIGNAL [SIGNAL...]Three special cases are worth knowing:
- trap '' TERM — empty string means ignore this signal.
- trap - TERM — a dash means go back to the default behaviour.
- trap 'cleanup' EXIT — EXIT is not a real signal. It is a shell feature that runs your code whenever the script exits, for any reason.
That last one is the most useful line in shell scripting, and most people never learn it.
A script that creates a temporary file has several ways to end: finishing normally, hitting an error, being Ctrl-C'd, or being sent TERM. Handling each of those separately means four handlers and a good chance of missing one.
trap 'rm -f "$TMPFILE"' EXIT covers all of them with one line, because the shell runs EXIT handlers on every path out.
The one thing it cannot cover is SIGKILL, since nothing can. That is why temporary files should also be created somewhere that gets cleaned up regardless — or, as Module 03 showed, created and immediately unlinked so the kernel cleans up when the process dies.
Section A3 said catching a signal means having your own plan. trap is where you write the plan down.
The important property is one people get wrong: a plan is not an ending. If your plan for the delivery chime is "sign for it and go back to work", you go back to work. The interruption happened and life continued.
So a shutdown handler that logs a message and does not exit means the process keeps running — it was interrupted, it did its thing, and it carried on. This is the single most common bug in shutdown handling, and Exercise A3.1 showed you exactly what it looks like from the outside.
And trap ... EXIT is the standing instruction that runs on your way out of the building, whichever door you use — front door, fire exit, or being escorted out. One rule, every exit path.
Where the analogy stops working — and it is why real handlers are kept tiny. A person answering the door finishes their sentence first.
A signal handler interrupts the program at an arbitrary instruction, possibly halfway through writing a file or allocating memory. If the handler then does the same kind of work, it finds those things half-finished. This is why the standard advice for real programs is: set one flag in the handler and get out, then do the actual work in the main loop where the state is consistent.
🧪 Exercise B2.1 — A script that always cleans up after itself
cat > /tmp/cleanup_demo.sh <<'EOF'
#!/bin/bash
TMPFILE=$(mktemp /tmp/demo.XXXXXX)
echo "working file is $TMPFILE"
# One line, every exit path
trap 'echo "[EXIT handler] removing $TMPFILE"; rm -f "$TMPFILE"' EXIT
# And a friendly message for a polite shutdown
trap 'echo "[TERM] shutting down cleanly"; exit 143' TERM
trap 'echo "[INT] you pressed Ctrl-C"; exit 130' INT
echo "sleeping - send me a signal"
# A loop, not one long sleep: bash defers traps until the current command ends
while :; do sleep 1; done
EOF
chmod +x /tmp/cleanup_demo.sh
# 1. Normal exit path - run a version that finishes on its own
sed 's|while :; do sleep 1; done|sleep 1|' /tmp/cleanup_demo.sh > /tmp/cleanup_once.sh
chmod +x /tmp/cleanup_once.sh && /tmp/cleanup_once.sh
# 2. Killed with TERM
/tmp/cleanup_demo.sh & P=$!; sleep 1; kill -TERM $P; wait $P; echo "exit code: $?"
# 3. Killed with KILL - predict whether the temp file is removed
/tmp/cleanup_demo.sh & P=$!; sleep 1
TMP=$(ls -t /tmp/demo.* 2>/dev/null | head -1)
kill -KILL $P; wait $P 2>/dev/null
echo "after SIGKILL, does $TMP still exist?"; ls -l "$TMP" 2>&1 | tail -1
rm -f /tmp/demo.* /tmp/cleanup_demo.sh /tmp/cleanup_once.sh✅ Expected result — click to reveal
# 1. Normal exit
working file is /tmp/demo.k3Xa9Q
sleeping - send me a signal
[EXIT handler] removing /tmp/demo.k3Xa9Q
# 2. TERM
working file is /tmp/demo.pR7mZt
sleeping - send me a signal
[TERM] shutting down cleanly
[EXIT handler] removing /tmp/demo.pR7mZt
exit code: 143
# 3. KILL
working file is /tmp/demo.9Lw2Vc
sleeping - send me a signal
after SIGKILL, does /tmp/demo.9Lw2Vc still exist?
-rw------- 1 zaeem zaeem 0 Aug 20 14:02 /tmp/demo.9Lw2VcWhat to read out of it — the three cases are the whole lesson.
Normal exit: the EXIT handler ran and the file was removed. Nothing was signalled at all.
TERM: both handlers ran, in order. The TERM handler printed its message and called exit 143, and that exit then triggered the EXIT handler. This is the pattern to copy: signal handlers do the signal-specific part, and EXIT does the cleanup, so cleanup is never duplicated and never missed.
The exit code is 143, which is 128 + 15 from Module 02. It was chosen deliberately in the handler, so the process reports the same code it would have had if it had simply been killed. That matters — a supervisor reading exit codes should not see a different number just because you handled the signal nicely.
KILL: no handler ran, and the temporary file is still there. Not because the script is badly written, but because nothing runs after SIGKILL. This is the concrete cost of kill -9, in one line of output.
The stale-lock incident is extremely common and is prevented by one line.
B3 · What survives fork and exec
Module 02 showed that a child inherits a great deal from its parent. Signal arrangements are inherited too — but fork and exec treat them differently, and the difference is not obvious.
| Disposition | After fork | After exec |
|---|---|---|
| Default | Inherited | Stays default |
| Ignored | Inherited | Still ignored |
| Caught (a handler) | Inherited | Reset to default |
fork copies everything, which makes sense — it is a copy of the same program, so its handlers are still valid code.
exec is different. The old program is gone, so its handler functions no longer exist. There is nothing to reset to except the default. But ignore is not a function — it is just a setting — so it survives.
That asymmetry is a real interview question and a real source of bugs.
Someone joins your team. They inherit the department's standing rules on day one: which alarms to respond to, which notices to ignore. That is fork — a copy of everything.
Now that person changes job entirely, into a different department. That is exec.
- Their personal working methods do not come with them. Their old plans referred to an old job that no longer exists. Everything reverts to the standard rules for the new role. Handlers are reset.
- But "you are permanently excused from fire drills" is a note in their file, not a working method. It follows them across. Ignored stays ignored.
This is exactly why nohup works. nohup sets SIGHUP to ignored and then execs your command. Ignoring survives the change of program, so the new program starts life already immune to HUP — without needing a single line of code to support it.
Where the analogy stops working, and it is the bug people hit. A person who was excused fire drills usually knows they were.
A program that inherits an ignored signal has no idea. A service started from a script that ignored SIGTERM inherits that, and then cannot be stopped — and nothing in its own code explains why. This is a genuinely nasty bug because the cause is in a completely different program that has already exited.
🧪 Exercise B3.1 — Handlers vanish, ignores persist
# Set up a handler AND an ignore, then exec a new program and inspect it.
bash -c '
trap "echo caught" TERM # a handler - should NOT survive exec
trap "" QUIT # an ignore - SHOULD survive exec
exec sleep 60
' &
P=$!
sleep 0.5
echo "--- what does the NEW program (sleep) have? ---"
grep -E 'SigIgn|SigCgt' /proc/$P/status
# TERM should now work, because the handler was reset to default
kill -TERM $P; sleep 0.5
ps -o pid,stat,comm -p $P --no-headers || echo "TERM worked - handler did not survive"
# Now show the nohup version of the same trick
nohup sleep 60 >/dev/null 2>&1 &
N=$!
sleep 0.5
echo "--- nohup'd process: what is it ignoring? ---"
grep SigIgn /proc/$N/status
kill -HUP $N; sleep 0.5
ps -o pid,stat,comm -p $N --no-headers && echo "survived SIGHUP"
kill -9 $N 2>/dev/null✅ Expected result — click to reveal
--- what does the NEW program (sleep) have? ---
SigIgn: 0000000000000004
SigCgt: 0000000000000000
TERM worked - handler did not survive
--- nohup'd process: what is it ignoring? ---
SigIgn: 0000000000000001
9455 S sleep
survived SIGHUPWhat to read out of it.
Read the two bitmasks from the first block:
- SigCgt: 0000000000000000 — the new program catches nothing. The TERM handler that was installed a moment earlier is gone. exec replaced the program, and the handler function went with it.
- SigIgn: 0000000000000004 — bit 3 is set, which is signal 3, SIGQUIT. The ignore survived, exactly as the table says.
Then TERM killed the process, proving the handler really was reset rather than merely invisible.
The nohup block shows the same mechanism put to work. SigIgn: 0000000000000001 is bit 1 — SIGHUP. nohup did nothing clever; it set HUP to ignored and execd sleep. The ignore survived, and sleep is now immune to a signal it has never heard of.
"A handler is a function in the old program's memory, and exec throws that memory away — so there is nothing left to run and it resets to default. Ignore is not code, it is a flag, so it survives."
Then give the consequence: this is exactly how nohup works, and it is also why a wrapper script that ignores a signal can silently make every program it starts un-stoppable.
B4 · Graceful shutdown — the pattern that matters
Everything in Part B comes together here, because this is what you will actually be asked to get right.
A graceful shutdown is always the same shape, whether it is systemd, Docker, or Kubernetes doing it:
Diagram source
sequenceDiagram
participant S as Supervisor
participant A as Your application
S->>A: SIGTERM - please stop
A->>A: stop accepting new work
A->>A: finish requests already in progress
A->>A: flush buffers, close connections
A->>S: exit with a status
Note over S,A: if it has not exited by the deadline
S->>A: SIGKILL - no choice, no cleanupThe whole design rests on one number: how long the supervisor waits between the two signals.
| Where | Setting | Default |
|---|---|---|
| systemd | TimeoutStopSec | 90 seconds on most distributions |
| Docker | docker stop -t | 10 seconds |
| Kubernetes | terminationGracePeriodSeconds | 30 seconds |
A pub closing follows exactly this sequence.
"Last orders." That is SIGTERM. The bar stops serving new drinks, but nobody is thrown out. People already holding a drink finish it. The staff start cashing up and cleaning.
Twenty minutes pass. That is the grace period. It is chosen to be long enough for someone to finish a pint, and short enough that the staff get home.
"Right, out." That is SIGKILL. Lights on, doors open, everybody leaves regardless of what is in their glass.
The two failure modes are both familiar:
- The grace period is too short. People are pushed out mid-drink. In production: requests cut off mid-flight, a database shut down before it finished writing.
- Somebody ignores last orders entirely. They sit there until the lights come on. In production: an application with no TERM handler, which always takes the full grace period and always ends in SIGKILL.
Where the analogy stops working, and it is the detail that gets missed. The pub tells everyone at once.
In Kubernetes, SIGTERM and removal from the load balancer happen in parallel, not in order. So a pod can receive TERM and still be sent new requests for a second or two afterwards. That is why a correct handler does not exit immediately — it fails its readiness check, keeps serving for a few seconds, and only then shuts down. Exiting the instant TERM arrives causes dropped requests during every single deploy.
🧪 Exercise B4.1 — Build a service that shuts down properly
cat > /tmp/graceful.sh <<'EOF'
#!/bin/bash
RUNNING=1
INFLIGHT=0
trap 'echo "[TERM] no new work; finishing $INFLIGHT in-flight"; RUNNING=0' TERM
trap 'echo "[EXIT] final cleanup"; rm -f /tmp/graceful.lock' EXIT
touch /tmp/graceful.lock
echo "started as PID $$"
while [ "$RUNNING" -eq 1 ]; do
INFLIGHT=1
sleep 1 # pretend this is one unit of work
INFLIGHT=0
done
echo "[shutdown] draining for 2 seconds"
sleep 2
echo "[shutdown] done"
exit 143
EOF
chmod +x /tmp/graceful.sh
# Run it and shut it down politely
/tmp/graceful.sh & P=$!
sleep 2
echo ">>> sending TERM"
kill -TERM $P
wait $P; echo "final exit code: $?"
ls -l /tmp/graceful.lock 2>&1 | tail -1
rm -f /tmp/graceful.sh /tmp/graceful.lock✅ Expected result — click to reveal
started as PID 9601
>>> sending TERM
[TERM] no new work; finishing 1 in-flight
[shutdown] draining for 2 seconds
[shutdown] done
[EXIT] final cleanup
final exit code: 143
ls: cannot access '/tmp/graceful.lock': No such file or directoryWhat to read out of it — the order of those lines is the whole pattern.
The TERM handler did not exit. It set a flag. The loop noticed the flag, finished the unit of work it was in the middle of, and only then left the loop. Nothing was cut off halfway.
Then the drain — a deliberate pause before exiting. In a real service that is the window in which the load balancer stops sending you traffic.
Then the EXIT handler removed the lock file, on the way out, exactly as in Section B2.
And the exit code is 143, the same code the process would have reported if it had simply been killed by TERM. A supervisor reading exit codes sees a normal shutdown, not a mysterious custom number.
Compare this with a handler that just calls exit immediately: the in-flight work would have been abandoned, the drain would not have happened, and the lock file would still be there if EXIT were not also trapped.
- Stop accepting new work first, before anything else.
- Finish what is already in flight rather than abandoning it.
- Drain — keep serving for a few seconds while the load balancer notices you are going. In Kubernetes this matters because TERM and endpoint removal happen at the same time, not in sequence.
- Exit with a sensible status, and make sure the grace period is longer than steps 1 to 3 take. If your drain is 30 seconds and terminationGracePeriodSeconds is 30, you will be SIGKILLed every time.
Point 3 is the one most candidates have never thought about, and it is the one that causes dropped requests on every deploy.
🎯 Interview questions — Sending and handling
Q. How would you implement a graceful shutdown for a service?
Catch SIGTERM and shut down in this order:
- Stop accepting new work. Close the listening socket, or fail the readiness probe.
- Finish requests already in progress. Do not abandon them.
- Drain. Keep serving for a few seconds while the load balancer or service mesh notices you are going away.
- Flush and close. Buffers to disk, database connections, open files.
- Exit with a sensible status — conventionally 128 + 15 = 143.
The supervisor sends TERM, waits, then sends KILL. That wait is TimeoutStopSec in systemd, terminationGracePeriodSeconds in Kubernetes (default 30s), and docker stop -t (default 10s).
The details that separate candidates:
- Point 3 is the one most people miss. In Kubernetes, SIGTERM and endpoint removal happen in parallel. Your pod can still receive new requests for a second or two after TERM. Exiting immediately drops them, on every deploy. A short sleep before shutting down is the standard fix, along with a preStop hook.
- The handler should set a flag, not do the work. A signal handler interrupts the program at an arbitrary point, so only a small set of operations is safe inside one. Set a flag, return, let the main loop shut down cleanly.
- Make the grace period longer than your drain. If they are equal you get SIGKILLed every time, and it looks like a crash.
- Check PID 1. If your entrypoint is a shell script that starts the app as a child, the shell gets TERM and the app never hears it. Use exec in the entrypoint so the app becomes PID 1 — the Module 02 and Module 03 mechanism, applied.
Q. What happens to signal handlers across fork and exec?
fork copies everything: default stays default, ignored stays ignored, and handlers are inherited, because the child is running the same program so the handler code still exists.
exec is different, and the asymmetry is the point of the question:
- Handlers are reset to default. The handler was a function in the old program's memory, and exec replaced that memory. There is nothing left to call.
- Ignored signals stay ignored. Ignoring is not code — it is a flag on the process — so there is nothing to lose.
- Default stays default.
The details that separate candidates:
- Give the one-line reason, not just the table: "a handler is code and exec throws the code away; ignore is a flag and flags survive."
- Name the practical use: this is exactly how nohup works. It sets SIGHUP to ignored, then execs your command. The ignore survives, so the program is immune to HUP without containing any code about it.
- Name the practical bug: a wrapper script that ignores a signal passes that on to everything it starts. A service that inherits an ignored SIGTERM cannot be stopped politely, and nothing in its own source explains why. grep SigIgn /proc/PID/status is how you find it.
Q. You need to stop a pipeline or a job, not just one process. How?
Signal the process group, not the PID: kill -TERM -<PGID>. The leading minus sign means "every process in this group".
A pipeline like a | b | c is three processes sharing one process group, whose ID is the PID of the first process. Killing only the first leaves the others running and waiting for input that will never come.
Find the group with ps -o pgid= -p PID.
The details that separate candidates:
- Know why systemd does not use this. Process groups can be changed by the processes themselves, and a program that calls setsid escapes entirely. So systemd tracks every process of a unit in a cgroup and stops all of them together, which nothing can escape from the inside. That is a much stronger guarantee, and it is why systemctl stop reliably cleans up things a kill would miss.
- Be careful with pkill -f. It matches a substring of the full command line and will happily match more than you intended. Always run pgrep -af <pattern> first to see the targets before sending anything.
- Mention kill 0 and kill -1 as the two easy accidents: kill 0 signals your own process group including yourself, and kill -1 as root reaches nearly every process on the machine.
🖥️ Part C · Terminals, groups and sessions
C1 · What a terminal actually is
In Module 03 your shell's descriptors 0, 1 and 2 all pointed at /dev/pts/0. It is time to say what that is.
Long ago a terminal was a physical device: a screen and keyboard on the end of a cable. The kernel had a driver for it, exactly like any other device from Module 01.
Today there is no such device. When you open a terminal window or connect over SSH, the kernel creates a pseudoterminal — a matched pair of ends that behave like that old cable:
- The master end is held by whatever is drawing the window: your terminal application, or sshd.
- The slave end appears as a file, /dev/pts/0, and is what your shell uses.
Anything written to one end comes out of the other. It is a pipe from Module 03, with one important addition.
The addition is the point. A pseudoterminal is not just a pipe. There is a line discipline sitting in the middle — a piece of kernel code that inspects what passes through. It handles backspace, it buffers a line until you press Enter, it echoes what you type back to the screen, and — the reason it is in this module — it turns certain keys into signals.
Two rooms are joined by an intercom. You speak into one end, it comes out of the other. So far, that is a pipe.
But this intercom has been modified. Between the two handsets sits a small box with a few buttons wired into it.
Most of what you say passes straight through. But press the red button and it does not send the word "red" to the other room — it sets off the alarm in there. The message never arrives as text, because it was intercepted and turned into something else entirely.
That box is the line discipline. Ctrl-C is the red button. The character never reaches your program; it is converted into a signal on the way.
The box does other quiet work too. It lets you correct your typing before you press Enter, which is why backspace works in every program without a single program implementing backspace. And it echoes what you type back so you can see it.
Where the analogy stops working — and it explains an odd thing you have already seen. The box can be switched off.
Programs like vim, less and top turn most of it off, because they want every keystroke immediately rather than a tidy line. That is why Ctrl-C behaves differently inside them, and why a crashed full-screen program can leave your terminal with no echo and no working Enter key. reset or stty sane switches the box back on.
🧪 Exercise C1.1 — Find your terminal and watch it work as a file
# Which pseudoterminal am I on?
tty
# It is a file, with an owner and permissions - Module 03 applies here too
ls -l "$(tty)"
# Everyone currently connected, and on which terminal
who
# It really is a file: write to it directly
echo "written straight to the terminal device" > "$(tty)"
# The line discipline settings - look for intr, susp and quit
stty -a | tr ';' '\n' | grep -E 'intr|quit|erase|susp'✅ Expected result — click to reveal
$ tty
/dev/pts/0
$ ls -l "$(tty)"
crw--w---- 1 zaeem tty 136, 0 Aug 20 14:30 /dev/pts/0
$ who
zaeem pts/0 2026-08-20 14:12 (10.191.44.1)
$ echo "written straight to the terminal device" > "$(tty)"
written straight to the terminal device
$ stty -a | tr ';' '\n' | grep -E 'intr|quit|erase|susp'
intr = ^C
quit = ^\
erase = ^?
werase = ^W
susp = ^Z
$ stty -a | head -3
speed 38400 baud; rows 44; columns 178; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; ...
isig icanon iexten echo echoe echok -echonl -noflsh -tostopWhat to read out of it.
ls -l shows crw--w----. The leading c means a character device, not a regular file. And it is owned by you — the kernel hands ownership of the pseudoterminal to whoever logged in, which is what stops other users writing to your screen.
echo > $(tty) printed to your screen. That is not a trick or a special case: your terminal genuinely is a file, and writing to it is an ordinary write call to a descriptor. Everything from Module 03 applies.
Now the stty output, which is the mapping table for the red buttons:
- intr = ^C — Ctrl-C produces SIGINT
- quit = ^\ — Ctrl- produces SIGQUIT
- susp = ^Z — Ctrl-Z produces SIGTSTP
These are settings, not laws. stty intr ^X would move interrupt to Ctrl-X, and Ctrl-C would then be an ordinary character.
The second command shows the mode flags, which live on their own line rather than in the control-character list. isig is what makes those keys produce signals at all — turn it off and Ctrl-C becomes an ordinary character. icanon means line mode is on, so your program sees nothing until you press Enter. echo means your typing is shown back to you.
All three are the line discipline, and all three are what vim and less switch off.
reset or stty sane fixes it, and you often have to type it blind. Worth knowing before it happens to you on a production box.
C2 · Process groups
You already met process groups in Section B1 without a definition. Here it is.
A process group is a set of processes that get signalled together. Every process belongs to exactly one, identified by a PGID.
The shell creates one group per job. So when you run sort big.txt | uniq -c | head, that is three processes in one group. They are one piece of work, and they should start, stop and be interrupted as a unit.
The PGID is the PID of the group leader, which is the first process in the pipeline. That is why you saw 9101 as both a PID and a PGID in Exercise B1.1.
This is the only reason Ctrl-C works properly. Pressing it must interrupt all three commands, not just one — and the terminal has no idea what your pipeline looks like. It just signals the group.
Three people are sent to fix a leak: one shuts off the water, one does the repair, one clears up. They are a crew. They arrived together and they leave together.
If you need to call them off, you do not phone one of them and hope he passes it on. You call the crew, and all three stop.
That is a process group. sort | uniq | head is a crew. Telling only sort to stop leaves the other two standing around waiting for work that will never arrive — which is exactly what Exercise B1.1 showed.
The crew is identified by its first member's name, which is a slightly odd convention but a convenient one: you always have a way to name the crew without needing a separate register of crews.
Where the analogy stops working, and it is why systemd does not rely on it. A real crew cannot resign from the crew.
A process can. Any process may call setsid and put itself in a brand-new group, out of reach of anything aimed at the old one. That is exactly how a daemon escapes — and exactly why signalling a process group is not a reliable way to stop everything a service started.
🧪 Exercise C2.1 — See jobs as groups
# Start two separate jobs. Each is its own group.
sleep 300 | cat &
sleep 300 | cat &
sleep 0.5
# PID, process group, session, and the terminal's foreground group
ps -eo pid,pgid,sid,tpgid,comm --no-headers | grep -E 'sleep|cat|bash' | head -8
echo "--- my shell ---"
ps -o pid,pgid,sid,tpgid,comm -p $$
# Stop ONE job by its group
JOBPGID=$(ps -o pgid= -p $(pgrep -n cat) | tr -d ' ')
echo "stopping group $JOBPGID"
kill -TERM -"$JOBPGID" 2>/dev/null
sleep 0.5
ps -eo pid,pgid,comm --no-headers | grep -E 'sleep|cat' | head
kill %1 %2 2>/dev/null✅ Expected result — click to reveal
$ ps -eo pid,pgid,sid,tpgid,comm --no-headers | grep -E 'sleep|cat|bash'
3901 3901 3901 9820 bash
9801 9801 3901 9820 sleep
9802 9801 3901 9820 cat
9803 9803 3901 9820 sleep
9804 9803 3901 9820 cat
--- my shell ---
PID PGID SID TPGID COMMAND
3901 3901 3901 9820 bash
stopping group 9801
9803 9803 sleep
9804 9803 catWhat to read out of it — read the four columns as four different questions.
PGID groups the jobs. 9801 and 9802 share PGID 9801. 9803 and 9804 share PGID 9803. Two jobs, two groups, and the PGID of each is its first process's PID.
SID is the same for everything — 3901, your shell's PID. All of this belongs to one login session, which is Section C3.
TPGID is the interesting one. It is the process group that currently owns the terminal — anything you type goes to that group and nothing else. Section C4 is entirely about this column.
It is the same on every line of one ps run, because they were all sampled at the same instant. It changes between runs (9820, then 9831), because the foreground group at that moment is whichever job is running ps. The number is not a fixed property of your session; it is a snapshot of who is holding the keyboard.
Then killing group 9801 removed both of its processes and left the other job completely untouched. One command, one job, precise.
Which processes are one job? Same PGID.
Which belong to one login? Same SID.
Who is currently listening to the keyboard? The group named in TPGID.
Which process has escaped its shell entirely? PID equals PGID equals SID, and TPGID is -1. That last pattern is the signature of a proper daemon, and you will produce one deliberately in Section D4.
C3 · Sessions and the controlling terminal
One level up from groups. A session is a collection of process groups that belong to one login. Its ID is the SID, and the process that started it is the session leader — normally your shell.
A session may have one controlling terminal. That link is what makes the next two things possible:
- The terminal knows where to send SIGINT when you press Ctrl-C.
- The kernel knows who to tell when the terminal goes away.
Diagram source
flowchart TD
S["SESSION - one login<br>SID = 3901<br>controlling terminal: /dev/pts/0"]
G1["Process group 3901<br>bash - the session leader"]
G2["Process group 9801<br>sleep and cat - job 1"]
G3["Process group 9803<br>sleep and cat - job 2"]
S --> G1
S --> G2
S --> G3
G1 -.->|"only ONE group at a time<br>owns the terminal"| T["/dev/pts/0"]So the shape is three levels: session → process groups → processes. A login is a session, a job is a group, and a command is a process.
A session is a shift. One supervisor clocks in, several crews work under them, and the whole thing ends when the shift ends.
The controlling terminal is the site radio. There is one, and it belongs to the shift.
Now the two rules that matter:
- Only one crew can hold the radio at a time. Whatever is said over it reaches that crew and nobody else. That is the foreground process group, and Section C4 is about it.
- When the shift supervisor leaves, everyone on the shift is told to go home. That is SIGHUP, and it is Section D3.
A crew that wants to keep working past the end of the shift has to do one specific thing: stop being on that shift's list. Not hide, not ignore the radio — actually leave the shift and start their own, with no radio at all. That is setsid, and it is how every daemon on your machine came to exist.
Where the analogy stops working. A supervisor can hand the shift over to someone else.
A session leader cannot. Once a process is a session leader it stays one, and setsid fails if you are already a group leader. This is why the standard way to become a daemon is to fork first and have the child call setsid — the child is guaranteed not to be a group leader, so it always works.
🧪 Exercise C3.1 — Leave your session
# Your shell is the session leader: PID = PGID = SID
ps -o pid,pgid,sid,tty,comm -p $$
# A normal background job stays in your session
sleep 200 &
NORMAL=$!
ps -o pid,pgid,sid,tty,comm -p $NORMAL
# setsid puts a process in a brand-new session with NO terminal
setsid sleep 200 </dev/null >/dev/null 2>&1 &
sleep 0.5
ESCAPED=$(pgrep -n sleep)
ps -o pid,ppid,pgid,sid,tty,comm -p $ESCAPED
echo "--- compare the SID and TTY columns above ---"
kill $NORMAL 2>/dev/null; kill $ESCAPED 2>/dev/null✅ Expected result — click to reveal
$ ps -o pid,pgid,sid,tty,comm -p $$
PID PGID SID TT COMMAND
3901 3901 3901 pts/0 bash
$ ps -o pid,pgid,sid,tty,comm -p $NORMAL
PID PGID SID TT COMMAND
10011 10011 3901 pts/0 sleep
$ ps -o pid,ppid,pgid,sid,tty,comm -p $ESCAPED
PID PPID PGID SID TT COMMAND
10015 1 10015 10015 ? sleepWhat to read out of it — compare the three lines column by column.
Your shell: PID, PGID and SID are all 3901. It is its own group leader and its own session leader. Terminal pts/0.
The normal background job: its own group (10011), but SID is still 3901. It is a different crew on the same shift. Still attached to pts/0.
The setsid job: everything changed.
- SID = 10015, its own — a new session.
- TT = ? — no controlling terminal at all. There is no radio to hear.
- PPID = 1 — its parent is gone, so it was re-parented, exactly as Module 02 described for orphans.
That third line is what a daemon looks like. Not hiding from signals, not ignoring anything — simply not on the list any more. Nothing sent to your session can reach it, because it is not in your session.
It is a useful audit: a setsid-ed process owned by an ordinary user, running something unexpected, is worth a second look. It is a common way to leave something running after logging out without it being obvious.
C4 · Who gets Ctrl-C
A session can have many jobs running at once, but you only have one keyboard. So exactly one process group is the foreground process group — the one that currently owns the terminal.
That group is what TPGID showed in Exercise C2.1. When you press Ctrl-C:
- The line discipline sees the character and, because ISIG is on, does not pass it through.
- It sends SIGINT to every process in the foreground process group.
- Background groups get nothing at all.
This is why Ctrl-C stops a whole pipeline. And it is why Ctrl-C does nothing to a job you put in the background — that group is not in the foreground, so it is never signalled.
There is a matching rule for input. A background process that tries to read from the terminal is sent SIGTTIN and stopped, because two programs reading your keystrokes at once would be chaos. That is why a background job that asks for input silently goes to the stopped state.
Several crews are working. There is one microphone, and one crew has it.
Say something into it and that crew hears it. Nobody else does. Not because the others are ignoring you — the sound genuinely does not reach them.
Ctrl-C is shouting "stop!" into the microphone. Whoever is holding it stops. The crews working quietly in the back rooms carry on, entirely unaware.
And there is a rule for the other direction: a crew in a back room is not allowed to shout questions at you. If one tries, it is told to wait until it has the microphone. That is SIGTTIN stopping a background job that tried to read from the terminal.
Where the analogy stops working, and it is the one that catches people. In a real room you can hear who has the microphone.
You cannot see which process group is in the foreground. It looks like your terminal is just "busy". ps -o tpgid= tells you the number, which is the only reliable way to know who your keystrokes are actually reaching.
🧪 Exercise C4.1 — Prove background jobs never hear you
# A foreground job would catch INT. Run one in the BACKGROUND instead.
bash -c 'trap "echo [BACKGROUND JOB got SIGINT]" INT; sleep 60' &
BG=$!
sleep 0.5
# Who owns the terminal right now?
echo "terminal is owned by group: $(ps -o tpgid= -p $$ | tr -d ' ')"
echo "background job is in group: $(ps -o pgid= -p $BG | tr -d ' ')"
# Send INT to the FOREGROUND group, the way Ctrl-C does.
# The background job is not in it, so it should hear nothing.
FG=$(ps -o tpgid= -p $$ | tr -d ' ')
kill -INT -"$FG" 2>/dev/null
sleep 0.5
echo "background job still alive? $(ps -p $BG >/dev/null && echo yes || echo no)"
# Now aim directly at its own group instead
kill -INT -"$(ps -o pgid= -p $BG | tr -d ' ')" 2>/dev/null
sleep 0.5
# And the other direction: a background job reading the terminal
bash -c 'read -r line' &
R=$!
sleep 1
ps -o pid,stat,comm -p $R --no-headers
kill -9 $BG $R 2>/dev/null✅ Expected result — click to reveal
terminal is owned by group: 3901
background job is in group: 10201
background job still alive? yes
[BACKGROUND JOB got SIGINT]
10240 T bashWhat to read out of it — three separate results, all worth having.
One. The terminal is owned by group 3901 (your shell). The background job is in group 10201. Different numbers, which is the whole reason for what follows.
Two. Sending INT to the foreground group left the background job completely untouched. It is not that the job ignored it — the signal never went anywhere near it. This is exactly what happens when you press Ctrl-C with a job in the background.
Three. Aiming at the job's own group reached it immediately, and its handler ran. The job was perfectly capable of receiving SIGINT all along. It simply was not being sent one.
And the last line is the other rule. The background job that ran read is in state T — stopped. It tried to read from the terminal while not in the foreground, and was stopped by SIGTTIN.
That is a genuinely useful thing to recognise: a background job that mysteriously stops itself is almost always one that tried to prompt for input. fg gives it the microphone and it carries on.
With no terminal at all, or as a background job, that read either fails or stops the job. Fixes: sudo -n, ssh -o BatchMode=yes, DEBIAN_FRONTEND=noninteractive, and redirecting stdin from /dev/null so a stray read fails immediately instead of waiting forever.
🎯 Interview questions — Terminals and sessions
Q. What actually happens when you press Ctrl-C?
The character never reaches your program. The terminal's line discipline intercepts it and converts it into a signal.
Step by step: you press Ctrl-C, the line discipline sees the configured interrupt character (stty -a shows intr = ^C), and because ISIG is enabled it sends SIGINT to every process in the foreground process group of that terminal. The default action for SIGINT is to terminate, which is why things stop.
Two consequences worth stating:
- It goes to a whole group, not one process. That is why Ctrl-C stops an entire pipeline rather than just the first command.
- Background jobs receive nothing. They are not in the foreground group, so the signal never reaches them.
The details that separate candidates:
- It is catchable, which is why some programs ask "press Ctrl-C again to confirm" and why vim does not exit. Contrast with SIGKILL, which cannot be caught.
- The key mapping is configurable, not fixed. stty intr ^X moves it, and full-screen programs turn ISIG off entirely so they can handle keys themselves.
- Ctrl-Z sends SIGTSTP and Ctrl- sends SIGQUIT — same mechanism, different characters. And SIGTSTP is catchable while SIGSTOP is not, which is exactly why a program can decline Ctrl-Z but nothing can decline kill -STOP.
Q. Explain the difference between a process group and a session.
A process group is a set of processes that are signalled together. The shell puts each job in its own group, so a pipeline a | b | c is one group of three. The PGID is the PID of the first process. kill -TERM -<PGID> signals all of them.
A session is a collection of process groups belonging to one login. Its leader is normally your shell, and it may have one controlling terminal. Within a session, exactly one group at a time is the foreground group — the one that receives keyboard signals.
So: session = one login, process group = one job, process = one command.
The details that separate candidates:
- Name the columns: ps -eo pid,pgid,sid,tty,tpgid. TPGID is the group that currently owns the terminal, which is the single most useful and least known of the four.
- Explain how a daemon escapes. setsid creates a new session with no controlling terminal, so nothing aimed at the old session can reach it. The signature in ps is PID = PGID = SID with TT showing ?.
- Say why setsid needs a fork first. It fails if the caller is already a process group leader, so the standard daemon pattern is fork, let the parent exit, and have the child — which cannot be a group leader — call setsid.
- Explain why systemd does not rely on any of this. A process can move itself out of a group or a session at will, so signalling a group is not a reliable way to stop everything a service started. systemd puts every process of a unit in a cgroup, which nothing can escape from the inside.
Q. A script works when you run it by hand and hangs when it runs from cron. Why?
Several causes share this symptom, and a good answer works through them:
- It is waiting for input it will never get. Under cron there is no terminal. Anything that prompts — sudo without -n, ssh confirming a host key, a package manager asking to confirm — blocks forever or is stopped by SIGTTIN. Redirect stdin from /dev/null so a stray read fails immediately rather than waiting.
- The environment is almost empty. cron gives you a minimal PATH and none of your shell startup files. A command that works interactively is simply not found. Use absolute paths, or set PATH explicitly at the top of the script.
- No controlling terminal. Anything that requires one — ssh -t, some interactive tools, anything calling tput — behaves differently or fails.
- A different umask, from Module 03, so files come out with unexpected permissions.
The details that separate candidates:
- Name the diagnostic: run it the way cron does, with env -i /bin/sh -c '/path/to/script', rather than trying to reproduce it in your own shell where the environment is doing half the work for you.
- Recognise the stopped-job signature. A process sitting in state T that nobody stopped is almost always SIGTTIN — a background job that tried to read the terminal.
- Say what to do instead. For anything that matters, a systemd timer is better than cron: real logging through the journal, proper environment control, dependency ordering, and no silent failures because output was mailed to a mailbox nobody reads.
🎛️ Part D · Job control and surviving logout
D1 · What the control keys really send
Three keys send signals, and the differences between them are the whole of job control.
| Key | Signal | What happens, and whether the program gets a say |
|---|---|---|
| Ctrl-C | SIGINT (2) | Terminate. Catchable — the program can decline, or clean up first. |
| Ctrl-Z | SIGTSTP (20) | Suspend. Catchable — a program can decline to be suspended. |
| Ctrl-\ | SIGQUIT (3) | Terminate and write a core dump. Catchable. The blunt option when Ctrl-C is ignored. |
Two things are worth noticing.
All three are catchable. None of them is guaranteed. A program that ignores Ctrl-C is not misbehaving — it is using a normal option.
Ctrl-Z sends SIGTSTP, not SIGSTOP. These are different signals and the difference matters: TSTP can be caught, STOP cannot. That is why vim can decline Ctrl-Z and tidy up your terminal first, while kill -STOP on the same vim freezes it instantly with no say in the matter.
Ctrl-D is not in this table at all, because it is not a signal. It marks the end of input on the descriptor — which is why cat finishes and why it logs you out of a shell. A completely different mechanism.
Someone is deep in a task and you need to interrupt them.
- Ctrl-C is "stop what you're doing." A reasonable request. They will usually stop. They might say "one second, let me finish this line" — that is catching the signal.
- Ctrl-Z is "pause, we'll come back to it." They freeze exactly where they are, everything still in front of them, ready to carry on from that precise point.
- Ctrl-\ is "stop, and write down everything you were thinking." That written record is the core dump. Useful for working out what went wrong, and quite large.
And Ctrl-D is not an interruption at all. It is "that's everything, I have nothing more to say." You are not stopping them — you are telling them the input has ended.
Where the analogy stops working. A person can always be interrupted eventually. kill -STOP cannot be declined by anything, and a process in D state cannot be reached at all. Ctrl-Z is a request; kill -STOP is not.
🧪 Exercise D1.1 — A program that declines your interruptions
# Catches INT and TSTP, and refuses to act on either
bash -c '
trap "echo \" -> caught SIGINT, not stopping\"" INT
trap "echo \" -> caught SIGTSTP, not suspending\"" TSTP
echo "running (PID $$)"
while :; do sleep 1; done
' &
P=$!
sleep 0.5
echo "sending INT (what Ctrl-C sends):"
kill -INT $P; sleep 0.5
echo "sending TSTP (what Ctrl-Z sends):"
kill -TSTP $P; sleep 0.5
ps -o pid,stat,comm -p $P --no-headers
echo "now SIGSTOP, which cannot be caught:"
kill -STOP $P; sleep 0.5
ps -o pid,stat,comm -p $P --no-headers
kill -CONT $P; kill -9 $P 2>/dev/null✅ Expected result — click to reveal
running (PID 10502)
sending INT (what Ctrl-C sends):
-> caught SIGINT, not stopping
sending TSTP (what Ctrl-Z sends):
-> caught SIGTSTP, not suspending
10502 S bash
now SIGSTOP, which cannot be caught:
10502 T bashWhat to read out of it.
Both keyboard signals were caught and both were declined. The state is still S — sleeping normally, exactly as before. Neither Ctrl-C nor Ctrl-Z achieved anything, and that is entirely legitimate.
Then SIGSTOP moved it to T immediately. No handler ran, because none could. This is the pair from Section A4 in action: TSTP is a request, STOP is not.
This explains a difference you have probably noticed. vim catches Ctrl-Z so it can restore your terminal settings before suspending, which is why your screen looks normal afterwards. If Ctrl-Z sent SIGSTOP, vim would freeze mid-redraw and leave your terminal in a mess.
"Ctrl-Z sends SIGTSTP, which is catchable, so a program can tidy up before suspending — that is why vim restores your terminal. kill -STOP sends SIGSTOP, which cannot be caught, so it freezes the process wherever it happens to be."
Then add that a stopped process still holds all its memory, its open files and any locks. It uses no CPU and frees nothing. A process left in T can hold a database lock indefinitely, which is a genuinely nasty way to hang a system.
D2 · jobs, fg and bg
Now that groups, sessions and the foreground group are clear, job control is just three commands moving the microphone around.
| Command | What it does | What actually happens underneath |
|---|---|---|
| jobs | List this shell's jobs | Reads the shell's own private table. Nothing kernel-side. |
| bg %1 | Resume job 1 in the background | Sends SIGCONT to that process group. Does not hand over the terminal. |
| fg %1 | Bring job 1 to the foreground | Makes it the foreground process group, then sends SIGCONT. |
| & | Start in the background | Runs it in a new group that is never made the foreground group. |
jobs is your shell's private list. It is not a kernel concept and there is no system-wide equivalent.
So jobs in a new terminal shows nothing, even though your background job is still running. %1 means nothing to any other shell. And when that shell exits, the list is gone.
That is why the answer to "I started something in a terminal and lost track of it" is not jobs but ps -eo pid,ppid,sid,comm — the kernel still knows, even though your new shell does not.
Carrying on from Section C4, where one crew holds the microphone.
- Foreground is the crew standing at your desk. They have your attention and the microphone.
- Background is a crew working in the back room. Getting on with it, not hearing anything you say.
- Stopped is a crew frozen mid-task, holding all their tools exactly where they were, waiting to be told to carry on.
bg is "carry on, but stay in the back room." fg is "come to my desk and carry on."
And jobs is the note on your own desk listing who is where. That is why it is empty at a different desk — the crews are still working, you just do not have the note.
Where the analogy stops working, and it is the practical point. A note can be copied. Your shell's job list cannot. There is no way to hand a job to another shell. If something needs to outlive the desk you are sitting at, that has to be arranged before you leave — which is Section D4, and the reason tmux exists.
🧪 Exercise D2.1 — Move a job between foreground and background
# Start a job, then stop it the way Ctrl-Z would
sleep 300 &
J=$!
sleep 0.3
kill -TSTP $J
sleep 0.3
jobs -l
echo "state now: $(ps -o stat= -p $J | tr -d ' ')"
# bg resumes it - by sending SIGCONT
bg %1 >/dev/null 2>&1
sleep 0.3
echo "after bg: $(ps -o stat= -p $J | tr -d ' ')"
# Prove bg is just SIGCONT: stop it and continue it by hand
kill -TSTP $J; sleep 0.3
echo "stopped: $(ps -o stat= -p $J | tr -d ' ')"
kill -CONT $J; sleep 0.3
echo "after CONT: $(ps -o stat= -p $J | tr -d ' ')"
# And prove the jobs list is private to this shell
echo "--- what does a DIFFERENT shell see? ---"
bash -c 'jobs -l; echo "(that was a different shell - empty)"'
echo "--- but the kernel still knows ---"
ps -o pid,pgid,sid,stat,comm -p $J
kill $J 2>/dev/null✅ Expected result — click to reveal
$ jobs -l
[1]+ 10701 Stopped sleep 300
state now: T
after bg: S
stopped: T
after CONT: S
--- what does a DIFFERENT shell see? ---
(that was a different shell - empty)
--- but the kernel still knows ---
PID PGID SID STAT COMMAND
10701 10701 3901 S sleepWhat to read out of it.
The state moved T → S → T → S, and kill -CONT did exactly what bg did. bg is not special: it sends SIGCONT and updates the shell's own note. That is all of it.
Then the important half. A different shell running jobs printed nothing, while ps found the process immediately, still in session 3901.
The job did not go anywhere. The list is what was missing. jobs, %1, fg and bg are conveniences your shell provides on top of process groups. The kernel has never heard of any of them.
The fix is to decide before you start, not after. tmux or screen keeps a session alive on the server so you can reattach from anywhere. For anything that matters, systemd-run is better still, and Section D4 explains why.
D3 · SIGHUP — why closing a terminal kills things
Here is the full sequence when you close a terminal window or your SSH connection drops.
- The master end of the pseudoterminal goes away.
- The kernel sends SIGHUP to the foreground process group of that terminal.
- The session leader — your shell — receives it.
- Bash then sends SIGHUP to all of its own jobs before exiting.
- The default action for SIGHUP is terminate, so those jobs die.
Step 4 is the one people do not know about. The kernel did not kill your background jobs. Your shell did, deliberately, on its way out. That matters, because it means there is more than one place to intervene.
Diagram source
flowchart TD
A["Connection drops"]
B["Kernel sends SIGHUP to the<br>foreground group of the terminal"]
C["bash receives SIGHUP"]
D["bash sends SIGHUP to<br>all of ITS OWN jobs"]
E["Jobs terminate - default action"]
A --> B --> C --> D --> E
F["nohup: signal is IGNORED<br>so the job survives"]
G["disown: job removed from<br>bash's list, so nothing is sent"]
H["setsid: different session entirely,<br>so step 2 never involves it"]
D -.->|"blocked by"| F
D -.->|"blocked by"| G
B -.->|"blocked by"| HThat diagram is also the answer to the next section: three tools, intervening at three different points in the same chain.
The site radio goes dead. That is the connection dropping.
The supervisor is told first: the shift is over. And before leaving, the supervisor does something specific. They walk round and tell every crew on their list to go home.
The crews are not sent home by the site closing. They are sent home by the supervisor.
Once you see that, the three ways to keep working become obvious, and each blocks a different step:
- nohup — the crew has a standing exemption: "we do not go home when told." They are told, and they carry on. Blocks the effect.
- disown — the crew is quietly crossed off the supervisor's list. Nobody tells them anything, because they are not on it. Blocks the message.
- setsid — the crew left this shift entirely and started their own, with no radio. The supervisor does not know they exist. Blocks the relationship.
Where the analogy stops working, and it is the practical warning. A crew still on site has a manager somewhere.
A nohuped or disowned job has nobody supervising it at all. Nothing restarts it if it dies, nothing collects its output, nothing notices it has stopped. It is running, and it is nobody's responsibility.
🧪 Exercise D3.1 — Watch SIGHUP take a job down, then survive it
# 1. A plain background job - default action for HUP is terminate
sleep 200 &
PLAIN=$!
sleep 0.3
kill -HUP $PLAIN; sleep 0.5
echo "plain job: $(ps -p $PLAIN >/dev/null && echo alive || echo dead)"
# 2. nohup - HUP set to IGNORED before exec, and the ignore survives exec
nohup sleep 200 >/dev/null 2>&1 &
NOHUP=$!
sleep 0.3
grep SigIgn /proc/$NOHUP/status
kill -HUP $NOHUP; sleep 0.5
echo "nohup job: $(ps -p $NOHUP >/dev/null && echo alive || echo dead)"
# 3. disown - still in the session, just removed from bash's job list
sleep 200 &
DIS=$!
disown $DIS 2>/dev/null
sleep 0.3
echo "still in jobs list? $(jobs -l | grep -c $DIS)"
echo "still running: $(ps -p $DIS >/dev/null && echo alive || echo dead)"
echo "now send HUP directly - predict the result first:"
kill -HUP $DIS; sleep 0.5
echo "disowned job: $(ps -p $DIS >/dev/null && echo alive || echo dead)"
kill -9 $NOHUP 2>/dev/null✅ Expected result — click to reveal
plain job: dead
SigIgn: 0000000000000001
nohup job: alive
still in jobs list? 0
still running: alive
now send HUP directly - predict the result first:
disowned job: deadWhat to read out of it — the third case is the one worth understanding.
Plain job: SIGHUP arrived, default action is terminate, it died. As expected.
nohup job: SigIgn: 0000000000000001 — bit 1 set, which is SIGHUP. It genuinely ignores the signal, so sending HUP directly did nothing. This is the Section B3 mechanism at work: set to ignore, then exec, and the ignore survives.
disowned job: removed from the jobs list, still running — and it died when sent SIGHUP directly.
That is the distinction most people get wrong. disown does not make a process immune to SIGHUP. It only takes it off the shell's list so the shell does not send one. If a HUP reaches it by any other route, it dies exactly as before.
So the two tools solve the same problem in genuinely different ways:
- nohup changes the process — it now ignores HUP from anyone.
- disown changes the shell — it no longer sends HUP to that job.
In bash this is off by default on most distributions, which means a shell exiting normally does not always HUP its jobs — and that is why background jobs sometimes survive a logout with no nohup at all.
So the honest answer to "do background jobs die when you log out?" is "it depends", followed by what it depends on: the shell, that setting, and whether the session leader actually received a HUP. That is a much stronger answer than a flat yes.
D4 · nohup, disown, setsid — and what to use instead
Three tools, three different mechanisms — and one recommendation that beats all of them.
| Tool | What it changes | Limitations worth knowing |
|---|---|---|
| nohup cmd & | Sets SIGHUP to ignored, then execs. Also sends output to nohup.out. | Still in your session, still has a controlling terminal. Only protects against HUP. |
| cmd & disown | Removes the job from the shell's list, so the shell does not signal it on exit. | Not immune to HUP from any other source. Cannot be arranged before starting. |
| setsid cmd | New session, new group, no controlling terminal. | Strongest of the three. You must redirect output yourself. |
| systemd-run --user | Hands it to systemd as a real transient unit. | Logged, supervised, stoppable by name, and in its own cgroup. |
nohup, disown and setsid all leave you with a process that is running and unsupervised. If it dies at 3am nothing restarts it. Its output goes to a file in whatever directory you happened to be in, or nowhere at all. Nothing records that it existed, and finding it again means grepping ps.
systemd-run --user --unit=myjob mycommand gives you the same detachment plus everything that was missing:
- Output goes to the journal: journalctl --user -u myjob
- Status at any time: systemctl --user status myjob
- Clean stop by name: systemctl --user stop myjob
- Every process it starts lives in a cgroup, so stopping the unit stops all of them — the guarantee that process groups in Section B1 could not give.
The habit worth forming: nohup for a throwaway command you are watching, systemd-run for anything you would be annoyed to lose.
Continuing the shift from Section D3:
- nohup is an exemption note. You are still on the shift and you still hear the announcements. You simply do not act on the one that says go home.
- disown is being crossed off the list. Nobody tells you anything, because as far as the supervisor is concerned you are not there. But if the message reaches you another way, you go home.
- setsid is leaving and starting your own shift, with no radio at all. Nothing from the old shift can reach you.
All three leave you working on site with nobody responsible for you. No rota, no record, nobody to notice if you stop.
systemd-run is being properly taken on. You are on the books, someone knows you are there, your work is written down, and if you collapse somebody notices.
Where the analogy stops working. Being on the books usually means more paperwork. Here it is genuinely less — one command, instead of nohup ... > log 2>&1 & plus remembering the PID plus finding a stray file later.
🧪 Exercise D4.1 — Compare all four side by side
echo "my shell: SID=$(ps -o sid= -p $$ | tr -d ' ') TTY=$(tty)"
echo
nohup sleep 300 >/dev/null 2>&1 &
sleep 0.3; N=$(pgrep -n sleep)
setsid sleep 300 </dev/null >/dev/null 2>&1 &
sleep 0.3; S=$(pgrep -n sleep)
sleep 300 & D=$!; disown $D 2>/dev/null
sleep 0.3
printf "%-9s %-7s %-7s %-7s %-7s %s\n" TOOL PID PPID SID TTY IGNORES-HUP
for pair in "nohup:$N" "setsid:$S" "disown:$D"; do
name=${pair%%:*}; p=${pair##*:}
# read, not set -- : a detached process shows TTY as '?', which would glob
read -r ppid sid tt < <(ps -o ppid=,sid=,tty= -p "$p" 2>/dev/null)
ign=$(awk '/SigIgn/{print $2}' /proc/$p/status 2>/dev/null)
hup=$([ $(( 0x${ign:-0} & 1 )) -eq 1 ] && echo yes || echo no)
printf "%-9s %-7s %-7s %-7s %-7s %s\n" "$name" "$p" "$ppid" "$sid" "$tt" "$hup"
done
echo
echo "--- the supervised alternative ---"
systemd-run --user --unit=demo-job --collect \
/bin/bash -c 'echo "job started"; sleep 30' 2>&1 | head -2
sleep 1
systemctl --user status demo-job --no-pager 2>/dev/null | head -7
systemctl --user stop demo-job 2>/dev/null
kill -9 $N $S $D 2>/dev/null✅ Expected result — click to reveal
my shell: SID=3901 TTY=/dev/pts/0
TOOL PID PPID SID TTY IGNORES-HUP
nohup 11201 3901 3901 pts/0 yes
setsid 11205 1 11205 ? no
disown 11209 3901 3901 pts/0 no
--- the supervised alternative ---
Running as unit: demo-job.service
● demo-job.service - /bin/bash -c echo "job started"; sleep 30
Loaded: loaded (/run/user/1000/systemd/transient/demo-job.service; transient)
Active: active (running) since Wed 2026-08-20 15:02:11 +08; 1s ago
Main PID: 11220 (bash)
Tasks: 2 (limit: 2273)
CGroup: /user.slice/user-1000.slice/[email protected]/app.slice/demo-job.service
└─11220 bashWhat to read out of it — take the table one column at a time.
nohup: same SID as your shell, still on pts/0, and ignores HUP: yes. It is still fully part of your session. It just refuses that one signal.
setsid: everything changed. Its own SID (11205). PPID = 1, because its parent exited and it was re-parented, exactly as Module 02 described. TTY = ? — no controlling terminal at all. And note ignores HUP: no. It does not need to. It has left the session that would ever send one. This is what a real daemon looks like.
disown: same SID, same terminal, ignores HUP: no. From the kernel's point of view nothing at all changed. The only thing that changed is a list inside bash.
Three tools, three genuinely different mechanisms, visible in three columns.
Then the systemd-run block. It is running, it has a status you can query by name, its output goes to the journal rather than a stray file, and look at the last two lines: it has its own cgroup, and Tasks: 2 is counting every process inside it. Stopping the unit stops all of them — the guarantee Section B1 said process groups cannot provide.
The same command under systemd-run --user is queryable by name from any shell, logged in the journal alongside everything else on the host, and stops cleanly along with everything it started.
For an interactive session you want to reattach to, tmux is the right tool. For a job that should simply run to completion, systemd-run is.
🎯 Interview questions — Job control and detaching
Q. What is the difference between nohup, disown and setsid?
They solve the same problem at three different points in the chain.
nohup sets SIGHUP to ignored and then execs the command. Because ignore survives exec, the program becomes immune to HUP without containing any code about it. It also redirects output to nohup.out. The process stays in your session with your controlling terminal.
disown removes the job from the shell's job list, so the shell does not send SIGHUP on the way out. From the kernel's point of view the process is unchanged — same session, same terminal — and it is not immune to SIGHUP from any other source.
setsid creates a new session with a new process group and no controlling terminal. Nothing aimed at the old session can reach it. This is the strongest of the three, and it is how daemons are made.
The details that separate candidates:
- The disown distinction is the separator. Most candidates say all three "make a process immune to hangup". disown does not — it stops one sender. Prove it: disown a job, then kill -HUP it, and it dies.
- Explain why nohup works at all — the ignore-survives-exec rule from fork/exec. That turns it from a magic command into a consequence of something you already know.
- Name what to use instead. All three leave an unsupervised process: nothing restarts it, nothing collects its output, nothing knows it exists. systemd-run --user --unit=name cmd gives detachment plus journal logging, status by name, a clean stop, and a cgroup so everything it started stops with it. For interactive work, tmux.
Q. Why do background jobs die when you close a terminal, and how do you prevent it?
Because of a chain with two separate steps, and knowing both is the point:
- The terminal goes away and the kernel sends SIGHUP to the foreground process group — which includes your shell, the session leader.
- Bash then sends SIGHUP to all of its own jobs before exiting. Default action is terminate, so they die.
Step 2 is done by the shell, not the kernel. That is precisely why there are several places to intervene: make the job ignore HUP (nohup), take it off the shell's list (disown), or put it in a different session entirely (setsid).
The details that separate candidates:
- Say that bash kills the jobs, not the kernel. Most answers stop at "the kernel sends SIGHUP", which cannot explain why disown works at all.
- Mention shopt -s huponexit, which is off by default in bash on most distributions. That is why background jobs sometimes survive a logout with no nohup. The honest answer to "do they die?" is "it depends", followed by what it depends on.
- Distinguish a clean logout from a dropped connection — they are not identical paths and the behaviour can differ.
- Give the real recommendation: tmux or screen for anything interactive you want to return to, systemd-run for anything that should simply finish. Both beat a detached, unsupervised process.
Q. A process is stuck in T state and nobody stopped it. What happened?
T means stopped, so something sent SIGSTOP, SIGTSTP, SIGTTIN or SIGTTOU. The last two are the interesting answers, because nobody sent them deliberately.
SIGTTIN is sent automatically when a background process tries to read from the terminal. SIGTTOU is the equivalent for writing, when that is configured. The kernel stops the process rather than letting two programs fight over one keyboard.
So the usual cause is a background job that tried to prompt for something — sudo asking for a password, ssh asking to confirm a host key, a package manager asking to confirm.
Other causes: someone ran kill -STOP, a debugger attached, or Ctrl-Z in a job whose shell has since gone.
The details that separate candidates:
- Reach for SIGTTIN, because it is the one that happens by accident and the one most candidates have never heard of. It is the standard explanation for "the script hangs under cron but works by hand".
- Say that a stopped process frees nothing. It holds all its memory, its file descriptors and any locks, while using no CPU. A process left in T can hold a database lock indefinitely and hang things that look completely unrelated.
- Give the fix and the prevention: kill -CONT resumes it, or fg if it is your job. Prevent it by redirecting stdin from /dev/null and using non-interactive flags (sudo -n, ssh -o BatchMode=yes) so a stray read fails immediately instead of stopping the job.
🏁 Part E · Practice, capstone, reference and review
E1 · Production practice
| Situation | What you now run | What it tells you |
|---|---|---|
| Container exits with 137 or 143 | Subtract 128 | 9 = SIGKILL (memory limit or expired grace period). 15 = a normal shutdown |
| Service ignores systemctl stop | grep SigCgt /proc/PID/status | Whether it has even installed a TERM handler. All zeros means it has not |
| kill -9 appears to do nothing | ps -o stat= -p PID and grep SigPnd /proc/PID/status | Z = already dead. D • pending signal = waiting on storage, undeliverable |
| Process cannot be stopped at all | grep -E 'SigIgn\|SigBlk' /proc/PID/status | What it is discarding or deferring — often inherited from a wrapper script |
| Container takes the full grace period | Read the entrypoint. Is PID 1 a shell? | The shell gets TERM and never forwards it. Fix is exec in the entrypoint |
| Killed the process, service half-alive | kill -TERM -"$(ps -o pgid= -p PID \| tr -d ' ')" | Signal the group, not the PID. Note the leading minus sign |
| Need to kill by name safely | pgrep -af PATTERN first, then pkill -f | Shows exactly what you are about to hit. -f matches far more than you expect |
| Script hangs under cron, fine by hand | ps -o stat= -p PID — looking for T | SIGTTIN: a background job tried to read the terminal. Redirect stdin from /dev/null |
| Which group owns my keyboard? | ps -eo pid,pgid,sid,tpgid,comm | TPGID is the foreground group. TTY = ? means fully detached |
| Log rotated, new file stays empty | kill -HUP the daemon (or -USR1 for nginx) | It is still writing to the old, now nameless inode — Module 03, Section B4 |
| Terminal broken after a crash | reset or stty sane | The program turned the line discipline off and died before restoring it |
| Long job that must survive logout | systemd-run --user --unit=name cmd | Detached and supervised, logged, stoppable by name, in its own cgroup |
| Script leaves stale lock files | trap 'rm -f "$LOCK"' EXIT | One line, covers every exit path except SIGKILL |
E2 · Capstone exercise
🧪 CAPSTONE — Four signal incidents, diagnosed from scratch
Build each fault, then diagnose it as if you had walked in cold.
# ---------- FAULT 1: the service that will not stop ----------
bash -c 'trap "echo received but not exiting" TERM; while :; do sleep 1; done' &
F1=$!; sleep 1
kill -TERM $F1; sleep 1
ps -o pid,stat,comm -p $F1 --no-headers
grep -E 'SigCgt|SigIgn' /proc/$F1/status
# a) kill reported success. Why is it still running? Name the two possibilities.
# b) Which /proc field tells you which of the two it is?
# c) What would a supervisor do next, and after how long?
kill -9 $F1
# ---------- FAULT 2: half a pipeline survives ----------
sleep 300 | cat | cat &
sleep 0.5
kill -TERM $(pgrep -n sleep); sleep 0.5
ps -eo pid,pgid,comm --no-headers | grep -c cat
# d) You killed it and two processes remain. Why?
# e) What is the correct single command to stop the whole job?
# f) Why does systemd not rely on this mechanism at all?
pkill -f "sleep 300" 2>/dev/null; pkill cat 2>/dev/null
# ---------- FAULT 3: the job that stopped itself ----------
bash -c 'read -r x' &
F3=$!; sleep 1
ps -o pid,stat,comm -p $F3 --no-headers
# g) Nobody sent a signal. Why is it in T state?
# h) Give the one-line prevention for a script that must run unattended.
kill -9 $F3 2>/dev/null
# ---------- FAULT 4: it died at logout ----------
sleep 300 & F4=$!
disown $F4 2>/dev/null
kill -HUP $F4; sleep 0.5
ps -p $F4 >/dev/null && echo "alive" || echo "dead"
# i) It was disowned and it still died. Explain precisely why.
# j) Which tool would have prevented this, and which is better than all of them?✅ What a good answer looks like — click to reveal
You are marked on whether your reasoning explains the evidence.
a–c, the service that will not stop. kill succeeding only means the signal was marked pending, never that it was acted on. Two possibilities: the process caught TERM and chose not to exit, or it is ignoring it. SigCgt versus SigIgn in /proc/PID/status distinguishes them — a bit set in SigCgt means a handler is installed and ran. Here it is caught and the handler simply does not exit, which is the single most common shutdown bug. A supervisor waits out its timeout (TimeoutStopSec, terminationGracePeriodSeconds, default 30s in Kubernetes) and then sends SIGKILL. A complete answer also mentions that if this were PID 1 in a container with no handler at all, TERM would not even be delivered.
d–f, the half-dead pipeline. A pipeline is three processes in one process group. You signalled one PID, so the other two are still there, waiting for input that will never arrive. The correct command is kill -TERM -<PGID> with the leading minus sign, which signals the whole group. systemd does not rely on this because a process can leave its group or session at will — setsid escapes entirely — so systemd puts every process of a unit in a cgroup, which cannot be escaped from the inside.
g–h, the job that stopped itself. A background process that tries to read from the terminal is sent SIGTTIN and stopped, so two programs cannot fight over one keyboard. Nobody sent it deliberately; the kernel did. Prevention for unattended scripts: redirect stdin from /dev/null and use non-interactive flags (sudo -n, ssh -o BatchMode=yes) so a stray read fails immediately instead of hanging.
i–j, it died anyway. disown does not make a process ignore SIGHUP. It only removes the job from the shell's list so the shell does not send one. A HUP arriving by any other route still terminates it. nohup would have prevented this by setting the disposition to ignore; setsid would have removed it from the session entirely. Better than all three is systemd-run --user, because the others leave an unsupervised process with no logging and no way to stop it cleanly.
The habit being tested is the same one as in Modules 02 and 03 — check the state before acting. Here that means reading /proc/PID/status and the ps state letter before reaching for kill -9.
E3 · Official documentation reference
| Topic | Official page | Offline equivalent |
|---|---|---|
| Signals — the whole model | signal(7) | man 7 signal |
| Handlers and dispositions | sigaction(2) | man 2 sigaction |
| What is safe inside a handler | signal-safety(7) | man 7 signal-safety |
| Sending signals | kill(1) | man 1 kill |
| Finding and signalling by name | pgrep(1) — pgrep and pkill | man 1 pgrep |
| PIDs, groups, sessions | credentials(7) | man 7 credentials |
| Creating a new session | setsid(2) · setsid(1) | man 2 setsid |
| Pseudoterminals | pts(4) | man 4 pts |
| Line discipline, control keys | termios(3) | man 3 termios |
| Surviving hangup | nohup(1) | man 1 nohup |
| Pending and ignored masks | proc_pid_status(5) | man 5 proc_pid_status |
| Process states | ps(1) | man 1 ps |
| The standard | POSIX.1-2024 Base Specifications Issue 8 | man 7 standards |
Then man 7 credentials for the process group and session model, which makes Parts C and D fall into place.
And if you ever write a signal handler in a real language, read man 7 signal-safety before you do. It explains why printf inside a handler is unsafe, and it is short.
E4 · Self-assessment
Answer out loud, without scrolling up.
- What information does a signal actually carry? Why can you not send data with one?
- Which two signals cannot be caught, and why must at least one exist?
- kill returned 0 and the process is still running. Name three different reasons.
- What is the difference between a signal being ignored and being blocked?
- What happens to signal handlers across fork? Across exec? Explain the difference in one sentence.
- How does nohup actually work — what does it change, and why does that survive exec?
- Press Ctrl-C. Trace exactly what happens, from the key to the process dying.
- Why does Ctrl-C stop an entire pipeline rather than one command?
- A background job is in T state and nobody stopped it. What happened, and how do you prevent it?
- disown a job and then send it SIGHUP. Does it survive? Explain precisely why.
- Describe a correct graceful shutdown. Name the step most people miss.
- Why is systemd-run --user better than nohup for a long-running job?
E5 · Sources
Interview questions were taken from published 2026 question sets and then extended with operational detail beyond the published answers.
- 45+ Operating System Interview Questions — GoLinuxCloud
- Linux Interview Questions 2026 (With Real Answers) — KodeKloud
- SIGKILL vs SIGTERM: A Developer's Guide to Process Termination — SUSE
- Understanding Linux Signals — SIGTERM vs SIGKILL — Penguin Gym Linux
- Linux Signals: SIGTERM, SIGKILL, SIGHUP Explained — Command in Line
- Mastering nohup: Running Unix Processes Without Hangups
- 100+ Linux Troubleshooting Interview Questions (2026) — WeCreateProblems
Technical content is sourced from the official documentation listed in E3.