Signals and safe handling
Dispositions, per-thread masks, and the pending set — then three race-free ways to consume signals as pmon v1 becomes a restarting supervisor: signalfd in C++, runtime channels in Go, the self-pipe trick in Rust, with strace catching Go's 114-call rt_sigaction storm and SIGURG preemption in the act.
Chapter 11 taught pmon to run one child and report how it died. Version 1
makes it a real supervisor: restart a crashing child with a doubling backoff,
shut down cleanly on SIGTERM or SIGINT, and replace the child on SIGHUP. That
sounds like three signal() calls — and that is exactly the trap. The one new
idea of this chapter is that a signal handler is close to lawless territory
where almost no code is safe to run, so well-built programs run no meaningful
code there at all: they convert signals into ordinary data — a file descriptor
read, a channel receive, a byte in a pipe — and consume that data in a plain
loop. Each language implements a different one of the three classic
conversions, on purpose, because together they are the lesson.
The code is in examples/12-signals-and-safe-handling/. ./demo.sh builds
all three implementations and drives a crash-restart-give-up cycle; the
README.md there specifies the CLI, the output lines, and the exit codes all
three languages share.
Tools used —
strace,kill,sh,python3,/proc(host);bpftrace(lab VM, exercised in Part 8). Everything here is checked byscripts/check-host.sh, ships with Fedora, or is preinstalled in the lab VMs.
Disposition, mask, pending
The kernel’s whole signal model fits in three words, and every bug in this area comes from mixing them up.
The disposition is per-process: for each signal, one of SIG_DFL (the
default — terminate, ignore, stop, or dump core, depending on the signal),
SIG_IGN, or a handler function installed with sigaction(2). Dispositions
survive fork; across execve a handler resets to SIG_DFL (the function
pointer would be meaningless in the new image) while SIG_IGN survives.
The mask is per-thread: the set of signals whose delivery is postponed.
A blocked signal is not lost — it waits. The mask survives both fork and
exec, which is a supervisor-shaped landmine: pmon supervise blocks
SIGTERM, and a forked child inherits that mask straight through execvp, so
the supervised program would start life unable to be terminated. This is why
spawn in all three implementations resets the mask in the child, between
fork and exec — the C++ spelling, from
examples/12-signals-and-safe-handling/cpp/src/main.cpp:
if (pid == 0) {
sigset_t none;
sigemptyset(&none);
::sigprocmask(SIG_SETMASK, &none, nullptr);
::execvp(argv[0], argv.data());
constexpr char msg[] = "pmon: error: exec failed\n";
[[maybe_unused]] auto n = ::write(STDERR_FILENO, msg, sizeof msg - 1);
::_exit(127);
}
The pending set is where a generated-but-undelivered signal sits, and for
standard signals it is literally one bit each: a second SIGCHLD arriving
while one is already pending coalesces into the same bit. This is the core
of SIGCHLD semantics: the signal means “one or more children changed
state”, never “exactly one”. The robust consumer treats SIGCHLD as a doorbell
and asks waitpid(…, WNOHANG) what actually happened; pmon has exactly one
child at a time, so a targeted waitpid(child, …, WNOHANG) is the whole
answer — the general reap-until-empty loop arrives with pmon’s later growth.
Delivery itself happens only when a signal is pending and unblocked in some
thread, at an arbitrary instant between two instructions — which is the
problem the next section is about.
Why handlers are a minefield
A handler interrupts your program wherever it happens to be — possibly
halfway through malloc, holding the allocator’s lock. If the handler then
calls malloc (or printf, or anything that might), the same thread waits
on a lock it already holds: deadlock, rarely, under load, in production.
That is why POSIX defines async-signal-safety: the short list of
functions guaranteed re-entrant against this kind of interruption —
write, _exit, sigprocmask, kill, waitpid, and a few dozen friends
(man 7 signal-safety). Not malloc. Not printf. Not most of any
language’s runtime: locking a mutex, growing a Go stack, or formatting a
Rust string are all off the list. The compiler will not warn you; the
program will just deadlock or corrupt state on the unlucky interleaving.
The chapter’s three implementations all draw the same conclusion: get out of
signal context immediately, running either nothing there (C++), or only
handler code someone already proved safe (Go’s runtime, Rust’s
signal-hook). The only place pmon itself must obey the list is the child
branch of spawn above — after fork in a program that may have threads
(Go always does), the child runs with only async-signal-safe calls until
execvp: sigprocmask, execvp, write, _exit. All four are on the
list, and nothing else happens there.
The self-pipe trick, and signalfd as its resolution
The classic escape is the self-pipe trick: at startup, create a pipe;
the handler does exactly one safe thing — write one byte to it — and the
main loop, already blocked in poll, wakes up, drains the pipe, and handles
the event as ordinary code. The pipe is the bridge from signal context to
loop context, and write(2) is async-signal-safe. The Rust implementation
is deliberately the historical exhibit, with the unsafe parts delegated to
signal-hook — each signal is registered twice, a flag and a doorbell, from
examples/12-signals-and-safe-handling/rust/src/main.rs:
/// One registered signal: the flag half of the flag + doorbell pair. The
/// handler owns its dup of the pipe's write end as an OwnedFd; signal-hook
/// makes it non-blocking so the handler can never wedge on a full pipe.
fn flag_for(signal: i32, doorbell: &PipeWriter) -> Result<Arc<AtomicBool>> {
let flag = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal, Arc::clone(&flag)).context("flag::register")?;
let owned: OwnedFd = doorbell.as_fd().try_clone_to_owned().context("dup")?;
signal_hook::low_level::pipe::register_raw(signal, owned)
.context("pipe::register_raw")?;
Ok(flag)
}
Linux then folded the trick into the kernel: signalfd(2) gives you a file
descriptor that is the pipe, with structured signalfd_siginfo records
instead of anonymous doorbell bytes. You block the signals with
sigprocmask — so normal delivery never happens — and read them like any
other fd. The C++ implementation opens with exactly that pair:
// Block the signals we care about, then receive them as fd reads.
sigset_t mask;
sigemptyset(&mask);
for (const int signo : {SIGTERM, SIGINT, SIGHUP, SIGCHLD}) {
sigaddset(&mask, signo);
}
if (::sigprocmask(SIG_BLOCK, &mask, nullptr) < 0) {
die("sigprocmask", errno_ec());
}
UniqueFd sfd(::signalfd(-1, &mask, SFD_CLOEXEC));
if (!sfd.valid()) {
die("signalfd", errno_ec());
}
No handler is ever installed; nothing asynchronous ever runs. Go is the
third design point: the runtime owns real handlers for almost every signal
(you will count them in the cross-check), and those handlers — written once,
by people who proved them safe — forward into the scheduler, where
signal.Notify turns delivery into a channel send:
sigCh := make(chan os.Signal, 8)
signal.Notify(sigCh, unix.SIGTERM, unix.SIGINT, unix.SIGHUP)
// One in-flight wait goroutine per live child; the buffered channel lets
// it finish even when the supervisor consumes the exit via stopChild.
waitCh := make(chan childExit, 1)
Three mechanisms, one shape: signal context shrinks to zero (C++), or to a
write and an atomic store (Rust), or to code you never wrote (Go), and
everything meaningful happens in a loop that reads plain values.
Forwarding, and who gets Ctrl-C
pmon forwards shutdown explicitly: kill(child_pid, SIGTERM), one target.
But when you press Ctrl-C in a terminal, the kernel sends SIGINT to the
foreground process group — pmon and its child both receive it, because
spawn leaves the child in pmon’s group. That is a deliberate v1 choice,
and it composes: the child dies of its default SIGINT disposition, pmon
consumes its copy through its mechanism, reaps, prints the shutdown line —
the same path kill -INT <pmon-pid> exercises alone. The alternative
design, setpgid the child into its own group and signal -pgid to reach a
whole tree, is what shells do to implement job control; pmon does not need
it yet, and neither spelling can catch a child’s grandchildren that
daemonize out of the group — the real fix for that is cgroups, in the
containers part.
How the code works
The supervisor state fits in four variables, identical in all three
languages: the current child’s pid (or handle), a running flag (Go spells
it “is there a timer”), a restart counter checked against --max-restarts,
and the current backoff, which starts at --backoff-ms and doubles after
every crash. A crash moves the machine from running to backoff wait; the
backoff elapsing moves it back by spawning a fresh child; a clean exit,
SIGTERM, or SIGINT leaves through exit 0; exhausting the budget leaves
through exit 1; SIGHUP stays in running but swaps the child and resets
both counter and backoff — a reload should not inherit a crashing child’s
penalties. Here is the consumption loop, three ways:
for (;;) {
int timeout = -1;
if (!running) {
const auto left = deadline - steady_clock::now();
timeout =
left <= 0ns
? 0
: static_cast<int>(
duration_cast<milliseconds>(left).count()) +
1;
}
pollfd pfd{.fd = sfd.get(), .events = POLLIN, .revents = 0};
const int nready = ::poll(&pfd, 1, timeout);
if (nready < 0) {
if (errno == EINTR) {
continue;
}
die("poll", errno_ec());
}
if (nready == 0) {
// Backoff elapsed: bring the child back.
child = start();
running = true;
continue;
}
signalfd_siginfo si{};
const ssize_t nread = ::read(sfd.get(), &si, sizeof si);
if (nread != static_cast<ssize_t>(sizeof si)) {
die("read signalfd", errno_ec());
}
switch (si.ssi_signo) {
case SIGTERM:
case SIGINT: {
if (running) {
stop_child(child);
}
std::println("pmon: shutting down ({})",
si.ssi_signo == SIGTERM ? "SIGTERM" : "SIGINT");
return 0;
}
case SIGHUP: {
std::println("pmon: reload requested");
if (running) {
stop_child(child);
}
restarts = 0;
backoff = opts.backoff_ms;
child = start();
running = true;
break;
}
case SIGCHLD: {
int wstatus = 0;
const pid_t reaped = ::waitpid(child, &wstatus, WNOHANG);
if (!running || reaped != child) {
break; // stale notification (e.g. already reaped on reload)
}
const ChildExit ce = decode(wstatus);
report(child, ce);
if (!ce.signaled && ce.status == 0) {
return 0;
}
if (restarts >= opts.max_restarts) {
std::println("pmon: giving up after {} restarts",
opts.max_restarts);
return 1;
}
++restarts;
std::println("pmon: restart #{} (backoff {}ms)", restarts, backoff);
deadline = steady_clock::now() + milliseconds(backoff);
backoff *= 2;
running = false;
break;
}
default:
break;
}
}
for {
var timerCh <-chan time.Time
if timer != nil {
timerCh = timer.C
}
select {
case s := <-sigCh:
switch s {
case unix.SIGTERM, unix.SIGINT:
if timer == nil {
stopChild(cmd)
}
fmt.Printf("pmon: shutting down (%s)\n", signame(s))
return 0
case unix.SIGHUP:
fmt.Println("pmon: reload requested")
if timer == nil {
stopChild(cmd)
} else {
timer.Stop()
timer = nil
}
restarts = 0
backoff = opts.backoffMs
cmd = start()
}
case ce := <-waitCh:
// The child exited on its own.
report(cmd.Process.Pid, ce)
if !ce.signaled && ce.status == 0 {
return 0
}
if restarts >= opts.maxRestarts {
fmt.Printf("pmon: giving up after %d restarts\n", opts.maxRestarts)
return 1
}
restarts++
fmt.Printf("pmon: restart #%d (backoff %dms)\n", restarts, backoff)
timer = time.NewTimer(time.Duration(backoff) * time.Millisecond)
backoff *= 2
case <-timerCh:
// Backoff elapsed: bring the child back.
timer = nil
cmd = start()
}
}
loop {
let timeout = if running {
PollTimeout::NONE
} else {
let left = deadline.saturating_duration_since(Instant::now());
let ms = left.as_millis().saturating_add(1).min(u16::MAX as u128);
if left.is_zero() { PollTimeout::ZERO } else { PollTimeout::from(ms as u16) }
};
let nready = {
let mut fds = [PollFd::new(reader.as_fd(), PollFlags::POLLIN)];
match poll(&mut fds, timeout) {
Ok(n) => n,
Err(Errno::EINTR) => continue,
Err(e) => return Err(e).context("poll"),
}
};
if nready == 0 {
if Instant::now() < deadline {
continue; // clamped timeout; keep waiting
}
// Backoff elapsed: bring the child back.
child = spawn(&opts.argv)?;
running = true;
continue;
}
// Drain the doorbell (poll said readable; coalesced bytes are fine).
let mut buf = [0u8; 64];
let _ = reader.read(&mut buf).context("read doorbell")?;
let term = got_term.swap(false, Ordering::SeqCst);
let intr = got_int.swap(false, Ordering::SeqCst);
if term || intr {
if running {
stop_child(child)?;
}
println!(
"pmon: shutting down ({})",
if term { "SIGTERM" } else { "SIGINT" }
);
return Ok(0);
}
if got_hup.swap(false, Ordering::SeqCst) {
println!("pmon: reload requested");
if running {
stop_child(child)?;
}
restarts = 0;
backoff = opts.backoff_ms;
child = spawn(&opts.argv)?;
running = true;
continue;
}
// SIGCHLD (or a stale doorbell byte): see whether our child exited.
if !running {
continue;
}
match waitpid(Pid::from_raw(child), Some(WaitPidFlag::WNOHANG)) {
Ok(ws) => {
let Some(ce) = decode_wait(ws) else { continue };
report(child, ce);
if ce.clean() {
return Ok(0);
}
if restarts >= opts.max_restarts {
println!("pmon: giving up after {} restarts", opts.max_restarts);
return Ok(1);
}
restarts += 1;
println!("pmon: restart #{restarts} (backoff {backoff}ms)");
deadline = Instant::now() + Duration::from_millis(backoff);
backoff *= 2;
running = false;
}
Err(Errno::EINTR) | Err(Errno::ECHILD) => continue,
Err(e) => return Err(e).context("waitpid"),
}
}
Structure first: all three block on one wait point — poll on the
signalfd, select over three channels, poll on the doorbell — and the
backoff is not a sleep but that wait’s timeout (Go uses a timer channel
for the same effect). That single decision keeps the supervisor responsive:
a SIGTERM arriving mid-backoff interrupts the wait instead of queuing behind
a sleep. In C++ and Rust the timeout is recomputed each iteration from an
absolute deadline — poll can return early for unrelated reasons, and
re-deriving “time left” from a deadline is immune to that; the + 1
guards the truncation of a fractional millisecond so the loop cannot spin at
zero-timeout just before the deadline.
Three per-language details deserve a closer look. The C++ SIGCHLD arm
checks reaped != child and drops the record: on a reload, stop_child
already reaped the old child synchronously, but the SIGCHLD it generated is
still queued in the signalfd — a stale notification. The state machine
must be driven by waitpid’s answer, never by the signal’s arrival; the
signal is a doorbell, remember, not a fact. Go’s version of the same care
is waitCh having buffer size 1 with one wait-goroutine per child:
stopChild consumes the exit synchronously from the channel, so a reload
cannot leave a goroutine’s send racing the next child’s. Rust’s is the
drain-then-check ordering — read up to 64 doorbell bytes (several signals
may have coalesced into one wakeup, exactly like the pending set), then
swap each AtomicBool to false, then let waitpid(WNOHANG) say whether
SIGCHLD meant anything; a doorbell byte with nothing behind it (StillAlive)
just loops.
Fragile bits, stated plainly: the doubling backoff is uncapped — with
--backoff-ms 1 and --max-restarts 40 the final wait would be 2³⁹ ms,
about seventeen years — so --max-restarts is the only brake, and a
production supervisor would clamp the backoff itself; Rust’s PollTimeout
is a u16, so waits past 65.5 s are clamped and re-entered (the
Instant::now() < deadline check); and run (unchanged from v0) reaps with
the default mask and dispositions, so Ctrl-C during pmon run kills both
processes — supervision semantics belong to supervise only.
Errors, three ways
The contract is unchanged from chapter 11 — pmon: error: … on stderr and
exit 1 for runtime failures, usage on exit 2 — but signals sharpen the
EINTR policy from chapter 4 into three distinct spellings. C++ retries
poll on EINTR explicitly, and reap_blocking loops waitpid the same
way; under supervise the mask makes those interruptions nearly impossible,
but run reaps with the default mask, so the loop is load-bearing there.
Rust matches Errno::EINTR as a value in both poll and waitpid — its
handlers really do interrupt syscalls, as the cross-check will show — and
additionally tolerates Errno::ECHILD (“no child to reap”) as a stale
doorbell rather than an error. Go never sees EINTR at all: the runtime
installs every handler with SA_RESTART and retries interrupted waits
inside cmd.Wait, so user code only ever receives the final verdict.
One deliberate acceptance shows up in all three stop_childs: ESRCH from
kill means the child is already gone mid-shutdown — a race you expect,
handled at the call site, while everything else carries context up to one
printer, exactly the chapter-5 policy.
Concurrency lens
Go’s runtime is the loudest signal user in this book, and now we can catch
it working. Its handlers are installed with sa_mask=~[] (all signals
blocked while handling) and SA_ONSTACK (an alternate stack, so a handler
survives even a blown goroutine stack), and the runtime sends itself
signals: since Go 1.14, a goroutine stuck in a tight loop is preempted by a
tgkill(…, SIGURG) from another runtime thread. An idle supervisor never
needs that, but GC pressure forces it — GOGC=1 makes collections constant,
and the trace shows runtime threads interrupting each other:
[host]$ GOGC=1 strace -f -o urg.trace -e trace=tgkill ./go/bin/app supervise \
--max-restarts 8 --backoff-ms 1 -- sh -c 'exit 1' >/dev/null
[host]$ grep -m1 -A1 'SIGURG' urg.trace
2622691 tgkill(2622690, 2622690, SIGURG) = 0
2622690 --- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=2622690, si_uid=1000} ---
[host]$ grep -c -- '--- SIGURG' urg.trace
4
Four SIGURG deliveries in a supervisor that never asked for any signal —
your Go programs live in this weather all the time, which is why os/exec
retries EINTR for you and why you must never install a C signal handler
into a Go process. The same lens explains a signalfd caveat: the mask is
per-thread, and signalfd only sees signals blocked in every thread that
might otherwise take delivery. The C++ code blocks the mask before any
thread could exist, so the invariant holds trivially; in a threaded program
you must block-then-spawn so children inherit the mask. Rust’s mechanism is
the only one where user-adjacent code truly runs in signal context, and its
concurrency budget is exactly one atomic store plus one non-blocking write
— a full doorbell drops the byte harmlessly, because bytes, like pending
bits, are allowed to coalesce.
Build, run, observe
[host]$ cd examples/12-signals-and-safe-handling && ./demo.sh
Each language builds and runs the crash loop. By hand, from this session —
run still propagates verdicts exactly as v0 did:
[host]$ ./cpp/build/release/app run -- sh -c 'exit 3'; echo "exit=$?"
pmon: started pid 2616819
pmon: child 2616819 exited status=3
exit=3
[host]$ ./cpp/build/release/app run -- sh -c 'kill -9 $$'; echo "exit=$?"
pmon: started pid 2616821
pmon: child 2616821 killed signal=9
exit=137
[host]$ ./cpp/build/release/app supervise --max-restarts 2 --backoff-ms 60 -- sh -c 'exit 1'
pmon: started pid 2616933
pmon: child 2616933 exited status=1
pmon: restart #1 (backoff 60ms)
pmon: started pid 2616934
pmon: child 2616934 exited status=1
pmon: restart #2 (backoff 120ms)
pmon: started pid 2616935
pmon: child 2616935 exited status=1
pmon: giving up after 2 restarts
Three distinct pids, the backoff printed doubling, exit 1. Driving the Go build with signals from another shell produces the reload-then-shutdown story — note the replacement pid:
[host]$ ./go/bin/app supervise -- sleep 300 &
pmon: started pid 2625064
[host]$ kill -HUP <pmon-pid>
pmon: reload requested
pmon: started pid 2625068
[host]$ kill -TERM <pmon-pid>
pmon: shutting down (SIGTERM)
verify.lua asserts all of this behaviorally — 31 checks per language,
including measured backoff elapsed time and post-reload shutdown — and this
session’s runner (python3 scripts/test-all-examples.py --only
12-signals-and-safe-handling) reports PASS PASS PASS for cpp, go, and
rust, with a standalone LSP_LANG=cpp run printing PASS 31 / FAIL 0.
Cross-check, three ways
The syscall shape, under strace. If the three designs are real, the
traces must be unmistakably different. The C++ supervisor’s entire signal
footprint for a one-restart run is ten lines — zero rt_sigaction, one
signalfd4:
[host]$ strace -o cpp.trace -e trace=rt_sigaction,rt_sigprocmask,signalfd4,poll,ppoll \
./cpp/build/release/app supervise --max-restarts 1 --backoff-ms 30 -- sh -c 'exit 1' >/dev/null
[host]$ cat cpp.trace
rt_sigprocmask(SIG_BLOCK, [HUP INT TERM CHLD], NULL, 8) = 0
signalfd4(-1, [HUP INT TERM CHLD], 8, SFD_CLOEXEC) = 3
rt_sigprocmask(SIG_BLOCK, ~[], [HUP INT TERM CHLD], 8) = 0
rt_sigprocmask(SIG_SETMASK, [HUP INT TERM CHLD], NULL, 8) = 0
poll([{fd=3, events=POLLIN}], 1, -1) = 1 ([{fd=3, revents=POLLIN}])
poll([{fd=3, events=POLLIN}], 1, 30) = 0 (Timeout)
rt_sigprocmask(SIG_BLOCK, ~[], [HUP INT TERM CHLD], 8) = 0
rt_sigprocmask(SIG_SETMASK, [HUP INT TERM CHLD], NULL, 8) = 0
poll([{fd=3, events=POLLIN}], 1, -1) = 1 ([{fd=3, revents=POLLIN}])
+++ exited with 1 +++
(The block-all/restore pairs are glibc protecting its own fork internals —
not our code.) No --- SIGCHLD --- stops ever appear: blocked signals are
never delivered, they are read, and the 30 ms poll timeout is the backoff.
The same command with -f on the Go binary tells the opposite story: this
session’s supervisor pid 2617196 issued exactly 114 rt_sigaction
calls — 58 probes reading old dispositions plus 56 installs of the runtime
handler (sa_handler=0x484d40, sa_mask=~[],
SA_ONSTACK|SA_RESTART|SA_SIGINFO) covering SIGHUP through SIGRT_32 — the
identical 114-call storm chapter 4 counted for a program that never touched
os/signal, because it is runtime initialization, not signal.Notify. Zero
signalfd4, zero poll/ppoll (the runtime waits in futex and its
netpoller). Rust sits between: 13 rt_sigaction — probe+install pairs for
our four signals via signal-hook, plus SIGPIPE set to ignore and a
SIGSEGV/SIGBUS pair that std installs for stack-overflow reporting — a
pipe2([3, 4], O_CLOEXEC) for the doorbell, and then real deliveries
interrupting the wait, self-pipe mechanics live on the wire:
poll([{fd=3, events=POLLIN}], 1, -1) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=2624617, si_uid=1000, si_status=1, ...} ---
poll([{fd=3, events=POLLIN}], 1, -1) = 1 ([{fd=3, revents=POLLIN}])
The kernel’s bookkeeping, in /proc. /proc/<pid>/status prints the
three words of this chapter as hex bitmasks — bit n is signal n + 1. A
live C++ supervisor from this session:
[host]$ ./cpp/build/release/app supervise -- sleep 30 &
pmon: started pid 2622949
[host]$ grep -E '^Sig(Pnd|Blk|Ign|Cgt)' /proc/<pmon-pid>/status
SigPnd: 0000000000000000
SigBlk: 0000000000014003
SigIgn: 0000000000000006
SigCgt: 0000000000000000
Decode SigBlk by hand: 0x14003 = bits 0, 1, 14, 16 = signals 1, 2, 15,
17 — SIGHUP, SIGINT, SIGTERM, SIGCHLD, precisely the sigprocmask set. And
SigCgt: 0: this process catches nothing — the whole design in one zero.
(SigIgn: 6 — SIGINT and SIGQUIT — was inherited from the non-interactive
shell that launched this background job: dispositions cross fork and
exec, as promised.) The other two, decoded with three lines of Python
against the same live technique:
[host]$ python3 -c '
import signal
for name, hexmask in [("go SigCgt", "fffffffd7fc1feff"), ("rust SigCgt", "14443")]:
m = int(hexmask, 16)
sigs = [signal.Signals(b + 1).name for b in range(31) if m >> b & 1]
print(name, "->", bin(m).count("1"), "caught;", " ".join(sigs[:6]), "...")'
go SigCgt -> 56 caught; SIGHUP SIGINT SIGQUIT SIGILL SIGTRAP SIGABRT ...
rust SigCgt -> 6 caught; SIGHUP SIGINT SIGBUS SIGSEGV SIGTERM SIGCHLD ...
Go catches 56 of 64 signals — everything except SIGKILL and SIGSTOP (which
no one may catch), the job-control quartet SIGCONT/SIGTSTP/SIGTTIN/SIGTTOU,
and two reserved realtime slots — while its SigBlk line
(fffffffc3bba3a00 on this run) belongs to the main thread only; the mask
is per-thread, and the runtime retunes it constantly, which is the
rt_sigprocmask chatter in its trace. Rust’s 0x14443 is our four signals
(0x14003, the same bits as the C++ blocked set — caught in one design,
blocked in the other) plus bits 6 and 10: SIGBUS and SIGSEGV, the std
stack-overflow guard the strace already showed. Three processes, one hex
format, and each design legible straight from the kernel’s ledger.
On the lab VM unverified — the kernel-side view of this chapter is
bpftraceontracepoint:signal:signal_generateandsignal_deliver, watching the pending-set handoff fleet-wide instead of per-process: which pid raised each SIGCHLD, where each SIGURG landed. That needs root and a disposable kernel, so it is not run here; chapter 30 (Debugging part) exercises exactly this onsystems-target.
What you learned
- A signal’s life is three kernel words: a per-process disposition, a
per-thread mask (which survives fork and exec — reset it in the
child), and a coalescing pending set — which is why SIGCHLD means “one
or more” and
waitpid(WNOHANG)is the only source of truth. - Handlers interrupt arbitrary code, so only the async-signal-safe list may
run there; the professional designs shrink signal context to nothing:
signalfd reads (
SigCgt: 0), runtime-owned handlers feeding channels, or a self-pipe doorbell plus atomic flags. - One blocking wait with the backoff as its timeout keeps a supervisor
responsive to shutdown mid-backoff, and stale SIGCHLD notifications must
be discarded by checking
waitpid’s answer, not the signal’s arrival. - The three designs are visible from outside: 0 vs 114 vs 13
rt_sigactioncalls,signalfd4vs a runtimetgkill(…, SIGURG)storm vs--- SIGCHLD ---interruptingpoll— and/proc/<pid>/statusdecodes each one in hex.
Next, pidfds: the pid-reuse race this supervisor still carries — a recycled pid can send our forwarded SIGTERM to a stranger — and the file descriptor that pins a process’s identity for good.
verified — every number and
output excerpt above was produced on the Fedora 44 reference host this
session: the runner printed 12-signals-and-safe-handling PASS PASS
PASS (3 passed, 0 failed, 0 skipped) and a standalone
LSP_LANG=cpp run printed PASS 31 / FAIL 0; the
run/supervise transcripts (exit 3 → 3, SIGKILL →
137, restarts at 60/120 ms, reload pid 2625064 → 2625068) are unedited
session output; the ten-line C++ strace is the complete filtered trace
(signalfd4 = fd 3, zero rt_sigaction); the Go
supervisor pid made exactly 114 rt_sigaction calls (58 probes +
56 installs of 0x484d40) and, under GOGC=1, 4
tgkill(…, SIGURG) sends with 4 matching deliveries; the Rust
trace showed 13 rt_sigaction, pipe2(O_CLOEXEC),
and ERESTART_RESTARTBLOCK on poll; and the
/proc/<pid>/status masks (SigBlk 14003 /
SigCgt 0, SigCgt fffffffd7fc1feff,
SigCgt 14443) were read from live supervisors and decoded with
the Python shown. The "On the lab VM" bpftrace callout is unverified as
marked and deferred to chapter 30.