A stage ral stopped has no failure
Past a |, a stage lives exactly as long as its reader needs it: ral itself
kills a non-final stage once its reader stage is gone, and the only death a
pipeline forgives is that kill. No interior edge can deliver a broken-pipe
signal or a write error to the stage that writes it, so a producer’s own
disposition toward that signal never again decides its pipeline’s verdict.
Decision
- ral holds a duplicate of each interior edge’s read end until that edge’s writer stage is reaped, closing only its own write-end copy as each stage finishes. EOF is a fact about the write end, so a reader’s ordinary EOF discipline is untouched; SIGPIPE and EPIPE are facts about the read end, so with a second read end always open, neither can ever reach the stage that writes into that edge. A non-final stage has no channel left through which to observe its reader’s death.
- Collection observes stages as they end, in whatever order that happens;
once a writer stage’s reader is reaped, ral sends the writer
SIGKILLon Unix, a distinctive-codeTerminateProcesson Windows, so the cascade runs tail-ward. That kill is the pipeline’s one forgiven death — a non-final stage ral itself ended keeps no failure, because the rest of its output was owed to nobody. A stage that stops, wherever it sits, parks the whole pipeline at once rather than wedging a collector blocked on its neighbour. - The kill is
SIGKILL, not a caught signal, because any catchable signal reopens negotiation with the producer’s handler table: a handler that ignores, delays, or reinterprets it hands the disposition question straight back to the producer’s own code, which is exactly the dependence this decision removes. - An exit status, once recorded, is never overwritten. The kill is sent only before the stage is waited, so it lands on a live process or a zombie — never a recycled pid, since an unreaped pid cannot be reused — and a zombie’s recorded status is untouchable. The dangerous direction (a real failure silently forgiven) is therefore structurally impossible, not merely unlikely: forgiveness reads the wait status, and no kill can rewrite one.
- The held-open read end is what makes the kill exact rather than a race
against the producer’s own exit. Without it, a producer blocked in
write(2)is woken byEPIPEthe moment its reader’s read end closes — the same event that makes the reader reapable — while ral still needs a scheduler wake to notice the reader is gone and act. A producer that checks its own write errors beats the kill through that window, and the disposition-dependence this decision removes returns at microsecond scale. Holding the read end removes the event the producer would have raced on, rather than trying to win the race. - The residual nondeterminism is honest: a producer that would exit on its own account inside the kill’s few-microsecond latency races the kill, and both outcomes are correct. The race selects which event occurs — the producer’s own exit or ral’s kill — never how a recorded status is judged. A status that was recorded, whichever event produced it, is kept.
- Cancellation outranks forgiveness: a death attributed to a cancellation
already in force (Ctrl-C teardown) is kept even when the signal ral sent
was
SIGKILL. The kill this decision forgives is the collector’s own, raised for exactly one reason — the reader is gone — and aSIGKILLraised for a different, already-recorded reason is not that kill. - A parked pipeline (
SIGTSTP) abandons its held read ends along with its stage handles and reverts to raw OS pipe behaviour; its verdict was already only its leader’s exit, so no forgiveness question arises for a job that never finishes collecting. - The kill is conditioned on a stage’s stdout being able to reach the
interior edge at all. A stage whose own redirect statically diverts every
fd-1 byte to a file (
cmd > file | next) is exempt: its reader’s death bears on nothing it produces, so “owed to nobody” was never true of it in the reader-gone sense — it was never owed to that reader in the first place. This is a fact resolve already has statically (the same fact that routes such a stage to a helper rather than a direct spawn), not a runtime guess.
Rejected shapes
The causal SIGPIPE rule
(a-producer-that-outlived-its-reader):
forgive exactly the death SIGPIPE itself caused, and report anything else the
producer manages to exit with. Measured on Unix: yes | head -1 succeeds,
but python3 -c "for i in range(10**7): print(0)" | head -1 fails with
Python’s own exit status, because Python sets SIGPIPE to SIG_IGN, takes
EPIPE from the write call instead, and exits on its own account — the same
cause, the opposite verdict, decided by a disposition the producer’s author
chose for reasons that have nothing to do with the pipeline it happens to sit
in. Rust’s std ignores the signal by the same default, so a plain
println! past a dead reader joins the same class. A rule that reads the
verdict off somebody else’s program’s signal handling is not reading a fact
about the pipeline.
The Unix exit-order clock, already recorded as rejected in
a-producer-that-outlived-its-reader:
giving Unix the same reader-then-producer clock Windows used would have
subsumed the causal test, but Unix records no exit instant, and a two-thread
harness stamping the reaping wake time got the order wrong on a double-digit
percentage of runs in both directions — including turning a genuine failure
(sh -c 'exit 1' | head -1) into a silent success. A probabilistic verdict is
not a verdict, and this decision does not revive the clock on either
platform: it removes the event the clock was trying to read, rather than
reading it more precisely.
A catchable stop signal (SIGTERM, or a signal the producer could install a
handler for): rejected because it reopens exactly the negotiation the whole
decision exists to close. A caught signal’s exit status is chosen by the
producer’s handler, which returns the disposition-dependence problem in a new
shape — the handler’s choices, not the write-error path this decision already
closed, but the same shape of problem.
Consequences
!{ yes ; exit 5 } | head -1goes from exiting 5 to exiting 0: the escape no longer occurs, because ral stops the stage mid-yes, before itsexit 5ever runs.- A producer loses the SIGPIPE-ignore opt-out bash affords it: a producer
that must run to completion regardless of whether anything reads it can no
longer rely on surviving a broken pipe. The rewrite is the one the
stdin-feed refusal already teaches (pipelines): run it
as its own statement, or
spawnit. - Pipelines gain a liveness property they did not have under the causal rule:
a producer that computes forever without writing no longer wedges a
… | head -0-shaped pipeline, because the pipeline’s extent is its final stage’s extent, not its slowest producer’s. - The two platforms read one rule instead of two: Windows’ exit-timestamp
machinery —
ChildHandle::exited_at,GetProcessTimes, the wholeoutlivedcomputation — is deleted, anddocs/SPEC.md§7.6 states one forgiveness rule true on both platforms rather than a causal rule on one and a clock-approximated rule on the other. - Measured on Linux (a standalone C harness over
pipe2/fork/exec, any-orderwaitpid, killing an unreaped writer on reaping its reader), 120 trials per case, once idle and once under load, 960 trials total, zero variance, 2026-08-20:yes | head -1and the Python producer above are both forgiven 120/120 in every run;sh -c 'exit 1' | head -1keeps its 1 in every run; and a producer that writes, sleeps 50ms past its reader’s exit, then exits 4, is forgiven 120/120 — theexit 4never occurs, the sleep dwarfing the kill’s latency. - The redirect exemption above closed a real regression, found via Windows
CI and reproduced on Unix once suspected:
cmd > file | next, withnexta stage that never reads its stdin (Windows’cmd /c rem; Unix’s/usr/bin/true), letnextsettle beforecmd’s own atomic write committed its rename. The kill landed mid-rename, was forgiven as intended, and the pipeline reported success over a file that was never created — correct forgiveness of the wrong kill.catas the reader had hidden this for years, since a reader that blocks on stdin can never settle first.
See pipelines, failure, a-producer-that-outlived-its-reader and process.