One delivery path
A cancellation reaches a pipeline’s processes exactly once, through the collector. ral had two deliveries — the cancel tree’s and a signal relay’s — and a process cannot tell them apart, so it received the same interrupt twice.
Decision
- The relay is deleted.
PipelineRelay,RELAY_PGIDS,relay_signal_to_groups, andrelay_handlerare gone. The interactive SIGINT disposition isinterrupt_handler, which callsrequest_foreground_cancel(Interrupt)and nothing else. (Later,3e43ce37: it callsrequest_interrupt, forwarded asControl::Interruptto the dispatch in flight.) - One send per process.
CollectState::cancel_all(group, cause, delivered)is ral’s whole delivery: it cancels every live stage, then sends one grace signal —kill(-pgid, sig)andSIGCONTfor a group it owns, per pid viaWatch::signalfor one it joined — waitsTEARDOWN_GRACE, kills, drains. - A signal the kernel already delivered is not re-sent. The tty gives a
foreground pgid its own copy; the anchor swallows that copy and reports it,
and
Event::Witnessed(cause)carriesdelivered: true, so teardown cancels the thread stages and waits the grace but sends nothing. (LaterEvent::Heard(signal), a key only to a terminal loan: the-lent-terminal-returns-the-gesture.) grace_signal(cause)is the one cause→signal table, read byRunningChild::terminateandcancel_allalike:Interrupt→ SIGINT,Explicit/Deadline/Terminate→ SIGTERM,ReaderGone/RootAbort→None, straight to the kill at every nesting level.- Attribution lives in
from_outcome.CommandFailure::from_outcome(outcome, sent)attributes the death to the causesentnames, so a pipeline external torn down under a deadline says so in the same words a standalone external does;attribute_tois private tooutcome.rs. (Later,5803377b: both gave way toWaitOutcome::classify(cause, enveloped), whose attributed death isChildEnd::Cancelled(cause), minting the sameError::cancelledas any poll point, so a cause’s status no longer depends on whether it landed before or after the spawn.)
Rejected shapes
- Keep the relay and de-duplicate by flag. Two delivery paths that must
agree, forever, about who already sent what — the flag is a second copy of
the fact
deliveredalready carries, kept in the wrong place. - Keep the relay for detached workers’ pipelines only. It contradicts the law that a detached worker is spared by Ctrl-C (concurrency-primitives-detached-vs-structured): the cancel tree spares it deliberately, and a relay reaching around the tree would kill it anyway.
Consequences
- One Ctrl-C is one SIGINT per external, measured at two before. This left
one gap, closed later: an external inside a thread stage heard the owner’s
group signal and its own waiter’s by pid. Its waiter now holds a
Group::Joinsmembership and opens only with a cause the stage scope does not hold, so it too hears one. !{ yes | cat } | head -1exits 0. The nested pipeline’s reader-gone teardown no longer sends a catchable SIGTERM, soyesis not laundered intoyes: killed by signal 15.- A pipeline external under a deadline reports the deadline, not the signal.
- A
spawned worker’s pipeline survives a Ctrl-C at the prompt: the cancel tree spares the detached worker, and nothing relays around it any more. Windows kept two fan-outs past this decision — the console handler’s Ctrl-Break to every group andTerminateJobObjectof every job, and exarch’srelay_interrupt, which spared only groups born detached — and they fell to the same rule later: the console handler raises the interrupt and nothing else, and a group hears Ctrl-Break only from the teardown of the scope that owns it. - The anchor-witness race between the anchor’s exec and its handler install is
unchanged and benign: a signal in that window kills the anchor, whose own
WatchreportsEvent::Cancelledand tears the pipeline down anyway.
See also pipeline-execution, cancellation, one-reaper-one-fold, the-cut-is-at-the-write, concurrency-primitives-detached-vs-structured; map runtime, io-process.