Pipeline execution: byte edges, one process group, threads and processes

The design makes | a positional byte wire. Every interior edge is an operating-system pipe from the left stage’s stdout to the right stage’s stdin, alike for every pair; only the final stage may report a value. The machine’s CompKind::Pipeline arm (the evaluator machine) reduces a single-stage form to its inner closure and hands a multi-stage form to PipeNode::launch then PipeNode::join, in the same rule — no frame is pushed, since nothing runs beneath the node — resolve, launch, join (drive then fold) are the spine below. A stage’s own stack is empty by construction: no stage runs in the parent, so none can be in tail position, and no frame ever crosses into a stage — only ⟨comp, captured env⟩ rides along, the environment being the pipeline node’s own lexical environment rather than shell.env; the stage thread’s own shell.env is the parent’s session. Ordinary application and bind compose values in the machine and do not enter this pipeline runtime.

Resolve freezes a StageLaunch per stage from resolve-time facts. resolve_pipeline (pipeline/resolve.rs) settles the terminal plan and calls resolve_launch per stage — the single launch decision, reading the head’s resolution, its redirects, and whether a !{…} audit captures bytes:

  • Direct { id, args } — an external command or bundled tool spawned straight into the group as its own process, no ral in front of it; the resolved identity and its already-closed argument values are all launch needs, and only this arm evaluates argv here, a Thread stage evaluating its own inside the thread;
  • Thread — the stage’s ral computation evaluated on its own OS thread, over a cloned Shell. An external head becomes a Thread stage too whenever a redirect or a byte-capturing audit rules Direct out; the external is then spawned from inside that thread instead. The stage’s redirects are then interpreted by RedirectState, the one interpreter a block and a standalone external share; a Direct stage has none, and wire_stdio wires its stage pipes without reading a redirect list.

No type is consulted: a stage’s classification cannot depend on what it returns, because the choice must be observationally transparent. A final stage the checker wrapped in Capture (let x = a | b) is no Exec, so it is a Thread stage whose stdin is the pipe and whose cap buffer takes what the command writes. Launch consumes the frozen decisions; it does not re-derive a transport mode. There is no in-process pipeline fold and no typed value channel between stages.

Every interior route is an operating-system byte pipe, allocated from stage position alone. open_stage_routes opens one os_pipe per interior edge and zips the stdins Parent : ins against the stdouts outs ++ [Parent], so a stage’s endpoints are decided by where it sits and by nothing else. Nothing in that allocation reads a type, and nothing in it reads whether a stage is a thread or a process — a StageRoute is the same value either way, and Thread/Direct differ only in how the route is wired into an Io versus a child’s stdio. A non-final stage’s returned value is simply discarded, and no returned value is ever serialised onto an edge (pipes-are-positional-byte-wires).

No stage is told whether it is the last one. Every stage reports whatever it settled on its own StageObservation, and the value the pipeline keeps is read out by position in the fold — stages fold in launch order, so the value kept is the last Ok, which is the final stage’s whenever that stage is Ok and is unread when it is not, every other stage contributing only its verdict and its audit.

A non-final stage cannot observe its reader’s death by EPIPE. The parent holds a duplicate of each interior edge’s read end — route::HeldEdge { edge, reader } — until that edge’s writer stage is killed or reaped, so no interior edge ever delivers a broken-pipe signal or a write error to the stage that writes it: a dead edge is felt only through the mechanism below, at a write, never through the OS’s own broken-pipe path. crate::io::Edge is the edge’s own fate (is_dead/mark_dead), shared by the writer and the HeldEdge that watches it. CollectState::file emits Effect::ArmEdge(ix - 1) when a reader stage at ix is filed and its writer still runs: StageHandle::arm marks the edge dead — before the sentinel snapshots what is pending, so a write completing between the two is caught by the sink’s own post-check rather than lost — and starts pipeline/sentinel.rs::listen(reader, slot) on the HeldEdge’s read end (an Arc<PipeReader> the HeldEdge and the sentinel hold jointly, so the fd closes when the writer’s filing drops the HeldEdge and the sentinel has returned), whatever kind the writer is. A Thread stage’s Sink::Pipe { writer, edge, stage } checks the same edge itself after each chunk it lands, and raises WriteStopped — read back by Shell::write_sink as Break::stopped(ReaderGone, Some(stage)), a stop naming the writing stage’s own node — so the break lands at the write, exactly; the sentinel hears the same chunk and its redundant KillStage finds a stage already unwinding. An external stage’s write ral cannot see directly, so the sentinel is what hears it: it reads whatever bytes are already pending — owed to a reader that left, and discarded — and the first byte written after them is Event::Wrote(ix), which carries nothing but the index. yes | !{ return 5 } terminates this way: yes’s own next write finds the edge dead. Neither endpoint of a pipe promises traffic, so a producer with nothing left to write for is the ordinary case, not an error path.

Bytes reach a stage thread through Sink::Pipe and a scoped SourceReader. A Thread stage’s Io is built straight from its StageRoute: ByteOut::Downstream becomes Sink::Pipe { writer, edge, stage } as the stage’s stdout, so a discarded statement’s bytes go down the wire like every other byte it writes. stage is the stage’s name, for its reader-gone stop; the scope a write polls is the writer’s own current scope, handed in at the write (Sink::write_under), so a cleanup’s write is masked with its shield. A final stage has no edge, so ByteOut::Parent takes the parent’s stdout: under !{ … | … } that is a capture buffer, and the final stage writes into it (capture). On the way in, ByteIn::Upstream becomes Source::Reader(SourceReader::pipe(r).interruptible(scope)), the pipe reader beside the scope a blocked read samples between slices (POLL_SLICE), the scope read first so an always-ready stream still polls. A cause in force reads as EOF, never as an interrupted-read error, so std::io::copy and friends cannot spin on it. A builtin’s reader is re-scoped to the current scope where it is obtained (stdin_reader), so a cleanup’s reads are masked with the cleanup. Source::reader never takes a source — every consumer, a builtin, an external’s stdin, a nested stage, receives a dup(2) — so a stage’s stdin outlives every command that reads it; the scope is stripped before a child inherits the source, so it is never a child’s problem. ByteIn::Parent at stage 0 resolves against the shell’s own stdin the same way, with one deliberate exception: a source with no reader on a tty-owning pipeline resolves to Source::Empty, never to the controlling terminal — a thread in the shell process reading a tty whose foreground belongs to the externals’ group would raise SIGTTIN against the whole shell. !{ from-line } | cat at an interactive prompt therefore sees EOF, exactly what it already saw under capture (docs/SPEC.md §7.6).

Pipelines run as one process group, held open by an anchor on every platform. Every multi-stage pipeline — including one whose stages are all ral-implemented — shares one pgid the parent ral process is not a member of. PipelineGroup::prepare (pipeline/group.rs) spawns ral --ral-pipeline-anchor before any stage exists: stderr null, stdin a pipe whose write end the parent keeps, stdout a pipe the parent polls, SIGTSTP/SIGTTIN/SIGTTOU all set SIG_IGN so the anchor essentially never stops (ral-does-not-suspend) — unlike the rest of the group, which the kernel can still stop directly. Every termination signal (SIGINT/SIGTERM/SIGHUP/SIGQUIT) is caught rather than obeyed: the handler writes the signal number to stdout and the anchor lives on. That immunity is what makes the group addressable from the moment prepare returns, rather than from a first real stage spawn: the anchor is a genuine member already, so the pgid is never child-less. On Unix, the anchor writes a zero readiness byte after installing all signal dispositions; prepare waits for it before exposing the group. A key on the lent terminal therefore cannot race the anchor’s handler installation. PipeNode::launch lends the terminal as soon as the group exists, before any edge is opened and so before any stage runs — PipelineGroup::lend (group.rs) borrows the &TerminalLease, and only an owning group lends. The TerminalLoan goes to the collector, and LaunchCx.holds_terminal freezes whether it was made. With the terminal settled before user code exists, no start-gate frame is needed.

The anchor is the group’s witness for a signal — one dedicated thread; its own stop is never witnessed at all, resumed by the reaper like any other watched pid’s. The shell is not a member of the pgid, so while the terminal belongs to the group the shell never receives the Ctrl-C or Ctrl-Z the tty delivers to it. With an external in the group a Ctrl-C would at least kill that external and the pipeline would drain; with none — !{ a } | !{ b } at the prompt — the anchor is the only process the kernel can reach, and it swallows the signal. PipelineGroup::prepare spawns the anchor and starts its witness in one act, so no signal it swallows can land before someone is reading for it: one dedicated thread blocks to EOF on the anchor’s stdout — one byte per swallowed signal, sending Event::Heard(signal) raw, since only the pipeline’s TerminalLoan may read a signal as a key — and the anchor’s own ChildHandle goes to the reaper as a Watch whose posting closure maps its every exit to Event::AnchorDied. ral does not suspend (ral-does-not-suspend): the anchor’s own rare stop is answered with SIGCONT by the reaper itself, inside its own scan, and never reaches this channel at all — no anchor waiter thread exists to see one. A death, however it left, ends the loop. An anchor that died on its own ends the pipeline (Terminated); one that died inside a teardown died of the collector’s own kill, and changes nothing — an Interrupted teardown must not end Terminated.

A heard key strikes; the scope decides. Heard is the kernel’s own delivery — the tty gave that signal to every member of the pgid before the anchor reported it — and only a lent terminal makes it a key: with no loan (exarch, a capture, a stage thread, a worker’s pipeline) it is inert, and so is a signal no terminal sends, such as a stray SIGTERM. Hearing a key tears nothing down: the loan strikes the run’s node (Mooring::home) and records pressed, and the teardown follows from the current scope the collector reads at its next slice, which is what lets a cleanup’s shield hold it off. pressed is read only by cancel_all, to skip the group’s grace signal the tty already delivered. Event::Cancelled is minted by recv alone, for a cause on the scope stronger than the teardown so far, so a teardown can escalate; step answers it with Effect::CancelAll { cause }. The report reader and the reaper’s watch on the anchor’s own pid are two separate producers into the same channel, so the two can race: whichever event arrives first is the one step answers.

The anchor ends before the fold. join is drive, end_anchor, fold. The tty signals the whole group in one pass, so the anchor’s SIGINT is pending before any member can die; its handler writes the byte before read(0) can return EOF; and finish drops the release end and joins the reporter, which leaves only on the report pipe’s EOF. After end_anchor, then, every report is on the channel, even when every stage ended before drive read one. fold hears them all out — each late key struck on the node as it is heard — and settles each external stage under sent joined with the cause on the current scope; a key the tty delivered is attributed through that scope or not at all. The loan drops with the collector at the end of fold, returning the terminal and striking nothing: the stages may all have trapped the key and exited 0, and the strike already on the node is what the run’s own poll then reads. Ending the anchor first is safe: drive returns only with no stage live, so nothing addresses the pgid again.

A single-stage pipeline never reaches any of this: the machine’s Pipeline arm reduces it to its inner closure inline, and PipeNode::launch never sees it.

Every external a stage’s own evaluation spawns joins the group. A Thread stage’s Io carries Some(membership) as its stage — a Membership pairing the group’s pgid with the stage thread’s own cancel scope — so any external it spawns — at its own root, or nested arbitrarily deep — resolves PgidPolicy::Join(pgid) and lands in the same group as a Direct stage would. A process group is its owner’s to signal. Such an external’s waiter holds Group::Joins(membership), and its terminate opens with a grace signal of its own only for a cause Membership::owes: one stronger than the stage scope holds, struck beneath the stage. Every cause the stage holds is one the owner cancelled it with and delivers to the whole group, so the member hears it once. An enveloped external is the exception that proves the rule: placed NewLeader whatever was asked, it leads its payload’s group, and Launch::spawn returns that group — a spawn returns a pgid only for a group the child leads — so its waiter holds Group::Owns and signals the payload itself, which neither bwrap’s pid nor the pipeline’s group can reach. A spawn worker is outside that evaluation: its Io is minted fresh with stage: None, so the externals it launches lead groups of their own and outlive the pipeline without ever touching its pgid. The member that outlives a stage is instead a descendant the stage forked (sh -c 'sleep 30 &'), which the group owns like any other. A stop reaching either is answered exactly as any other wait answers one, so neither is parked by Ctrl-Z (ral-does-not-suspend).

A stage thread can itself launch a pipeline, which joins rather than owns. The machine’s Pipeline arm is PipeNode’s only caller and steps identically inside a stage thread, so a stage whose body is itself a pipeline launches its own nested stages. That nested PipeNode reads shell.io.stage: for Some(m) it builds PipelineGroup::joining(m) instead of preparing one — no anchor, no foreground claim, and a Group::Joins(m) where an owner holds Group::Owns(pgid), an anchor’s presence being the whole of what ownership means: only the owning top-level group may address the pgid, so the collector below reads Joins and falls back to acting per pid. The nested pipeline’s own thread stages are handed that same membership (PipelineGroup::membership returns a joiner’s own, and pairs an owner’s leader with the stage’s scope), so a member at any depth answers to the outermost owner’s stage scope. A stop reaching any member of the nested group, direct or joined, is answered with SIGCONT by the one process-wide reaper — the same rule wherever in the nesting the watched pid sits, with nothing to forward upward and nothing for a joining collector to do about a stop at all. A joining collector’s own teardown therefore reaches its live externals by pid, one signal each, and only for a cause its membership owes: whatever the outer stage holds, the owner has already delivered to the whole group. That is every cause it can hear — its cancel arrives on the very scope its membership reads — so a joining collector signals nothing and waits out the owner’s delivery; owes earns its keep in a member’s own waiter, whose scope sits beneath the stage’s and can be struck alone.

A thread stage cuts itself at its own next write; an external is heard by the sentinel and killed at the first byte after the discard. Marking an edge dead (Effect::ArmEdge) changes nothing for a stage not currently writing — its own account continues regardless of writer kind. A Thread stage’s own write needs no cancel to be cut: its Sink::Pipe checks edge.is_dead() after each chunk it lands — judged, not refused, so a child’s bytes carried through a pumped sink under --audit reach the edge and are heard by the sentinel like any external’s, and a write already in flight when the edge dies finishes before it is judged — “a write not complete when the edge dies does not complete.” A blocked write waits a slice at a time: each slice reads the writer’s scope, and a chunk that cannot land on a dead edge (the pipe full, nobody draining) is the reader-gone stop too, judged at the slice. The resulting Break::stopped(ReaderGone, Some(stage)) is an escape, so no try in the stage sees it; the stage’s guard cleanups run, shielded, on the way out. The fold forgives a thread stage by name: every stop naming the stage’s own node is the collector’s own doing — its reader-gone stop, or the teardown it ran — and is consumed (StageObservation::names, forgiven); a stop naming any other node is forwarded, and so is a cleanup that halted on anything else, which becomes the verdict. A reader-gone stop naming any node ranks under everything in the fold (rank 0): its owner is a collector above, which will consume it. A child the stage thread spawned holding the edge (!{ sh -c … } | true) is heard by the sentinel like any external; the Effect::KillStage that follows writes ReaderGone on the stage’s node, and its RunningChild::wait reads it at the next slice, kills the child by pid and mints the same stop. A stage that never writes again after its edge dies is never cut and keeps running: sleep 1000 | true waits for the sleep.

An External stage has no write ral can see, so pipeline/sentinel.rs::listen(reader, slot) reads the held duplicate in its place, discards whatever bytes are already pending — owed to a reader that left — and turns the first byte written after them into Event::Wrote(ix), which names the stage and nothing else. Only then, not at the reader’s own terminal event, does the process die: step emits Effect::KillStage(ix), and StageHandle::cut performs it — the kill itself, and the ReaderGone it records on that handle’s own sent, joined by max with whatever cause the collector had already sent it. grace_signal offers no catchable signal for ReaderGone, so wherever a reader-gone teardown runs — a stage’s own RunningChild::terminate, a nested pipeline’s cancel_all — the kill is outright. The kill addresses the stage’s pid alone, so a descendant the stage forked outlives it and may still hold the pipe a pump of that stage reads; an external ended for a dead write therefore detaches its pumps rather than joining them (command::Pumps::join_under, skipped in StageEnd::settle from the sent the filed handle carried onto its StageEnd) — its remaining bytes are owed to nobody, and the join would otherwise wait on the descendant. Every other external settlement joins its pumps a slice at a time and, on a cause in force, kills an owned group once, so a survivor holding the pipe cannot hold the run past the stop. A direct external has no node; the collector’s own record of what it sent (sent) is its name, and a death by what was sent is consumed as a thread’s is, by the collector reading sent. The stage’s own external child (if it has one) is torn down by pid inside that stage’s own RunningChild::wait. A nested pipeline’s stages are cancelled transitively the same way, each stage’s own edge check or sentinel ending only that stage’s own write. This is also why the kill can never land on a stage that already finished: Effect::ArmEdge and the Effect::KillStage it can lead to both require the writer still Some (unsettled), so !{ echo a; exit 3 } | head -1 stays honest. Its audit fragment is folded either way: the verdict is the collector’s doing, what the stage observed still happened. A stage thread’s end is its own Event::Returned, sent as its last act; a panic in the body is caught at the stage’s base (ThreadEnd::of) and reported as ThreadEnd::Panicked. A panic is a defect, not a signal: the collector tears the pipeline down with Aborted, granting no grace, and fold re-raises it on the run thread (resume_unwind), where the top restores its checkpoint. A SettleOnDrop guard, constructed before the closure runs and moved into it, is each index’s own dead-man’s switch: an unwind anywhere on that thread — even before the closure body starts — drops the guard still armed, and the drop itself posts the index’s Returned (“ral pipeline stage ended without reporting”). An external stage has no waiter thread of its own left to carry a matching guard: the reaper posts its exit and the collector’s own fold is where anything about it can go wrong, not a thread this collector must outlive.

ral does not suspend, so Ctrl-Z has no story left to tell at the collector at all — a stop never reaches its channel. The kernel still stops what it can stop directly on Unix — the anchor and every external in the group — but none of that is this collector’s problem any more: the process-wide reaper (io-process) answers every watched pid’s stop with SIGCONT inside its own scan and posts nothing for it, whether the pid is a direct external stage’s or the anchor’s own. A stage thread’s own interior wait, for whatever child it spawned, still answers a stop inline itself, through the same reaper — RunningChild::wait receives a slice at a time over a channel the reaper’s watch feeds, reading its scope between slices. Event has no Stopped variant at all — there is nothing for step to answer, because nothing a stop does is ever visible above the reaper that resumed it (ral-does-not-suspend). This holds whether the group is the top-level owner or a nested joining one: there is no role distinction left to make about a stop, since no party above the reaper ever learns one occurred. !{ a } | !{ b }, a pipeline with no external at all, no longer parks on Ctrl-Z either — the anchor’s own watch answers it and the pipeline runs on, exactly as vim does.

Windows never had a foreground handoff to race and still has none. There is no tcsetpgrp, and the terminal plan never selects ForegroundExternalGroup. The anchor still exists on Windows — it is what keys the Job Object in GROUPS — but it witnesses nothing there, Windows having no group-wide delivery to hear, and it swallows every console event, the group’s own Ctrl-Break grace included; every external spawned inside a stage thread still joins the Job Object through the same PgidPolicy::Join resolution, assigned at creation under the suspended create → assign → resume path.

Collection is one channel, a pure fold, and a thin interpreter. Every lifecycle edge — an external’s raw exit, a stage settling, a cancel, a sentinel’s dead write — arrives as one [Event] on one channel; a stop is not among them, answered and forgotten by the process-wide reaper before it could become one. A stage thread computes its own [StageObservation] and sends Event::Returned as its last act; a direct external needs no dedicated waiter thread at all — ChildHandle::into_watch’s own closure posts its raw [crate::process::WaitOutcome] straight onto the collector’s channel as Event::Ended(ix, outcome), the reaper being the one party that ever calls waitid on it; the anchor’s report reader sends Event::Heard for a swallowed signal, and its own Watch posts Event::AnchorDied for the anchor’s own death. The scope is no producer: CollectState::recv reads the launching computation’s current scope before each receive and receives at most a slice (POLL_SLICE), minting Event::Cancelled itself for a cause stronger than the teardown so far. A stage’s address on that channel is its Slot { ix, tx }, minted by PipeNode::launch’s own spawn loop, carried by every one of that stage’s producers, and wrapped by the SettleOnDrop a stage thread’s closure owns. Every index-owning producer — a stage thread’s closure — carries its own [SettleOnDrop], so its Returned reaches the channel by its own send or by that guard’s drop; the anchor’s report reader and its own watch hold plain sender clones and outlive every stage, but neither owns an index, so their long life cannot make a stage’s own end go missing. The collector keeps no sender at all: the sending half is a local of PipeNode::launch, cloned into each Slot and gone when launch returns. A recv that returns None with a stage still live — every producer of that index gone without settling it — is this collector’s own defect, not a panic to recover: drive returns, and the unobserved stage is Drop’s to kill. A stage the fold then finds never observed folds as a failure — “ral pipeline stage vanished without reporting” — never silently as an absence, so no defect can leave the pipeline reporting success. step is pure over CollectState: it never blocks, and it performs no kill, signal, or cancel — those are the [Effect] a thin interpreter (CollectState::run) performs, and one event justifies at most one of them, so step answers with an Option<Effect>. Filing an observation is not one: reaping an already-exited pid and reclaiming a thread already returning is bookkeeping this collector owns. So the whole corner-case space is a transition table, testable by feeding step a sequence of events and asserting the effect, no process, sleep, or retry loop needed. Completion is neither an effect nor a flag but the state itself — the pipeline is finished exactly when no stage handle is left — so drive is while live() { recv; step; run }, the receive sliced so the scope is sampled at least every slice. step folds six kinds of event — Ended, Returned, Wrote, Cancelled, AnchorDied, Heard — a process stop is not among them at all: it is answered and forgotten by the reaper’s own scan, never reaching the channel (ral-does-not-suspend). step’s reader-gone rule is two effects, one per event that justifies it: on a stage’s own terminal event at index ix, if stage ix - 1 still holds a stage handle, emit Effect::ArmEdge(ix - 1); on Event::Wrote(ix - 1) — the sentinel’s report that ix - 1’s next write found the edge dead — emit Effect::KillStage(ix - 1), the ReaderGone being recorded by the cut that effect stands for, on the handle’s own sent. Both check the writer is still Some (unsettled) before acting, so a stage that already finished keeps its outcome. The shell-touching finish this implies — reading a filed stage’s sent back against what happened, forgiving a ReaderGone kill — waits for [CollectState::fold], the one place &mut Shell reaches an external’s settlement, since no reaper-posting closure or stage thread may hold one; the verdicts it ranks join by stronger over rank: a reader-gone stop naming any node contributes nothing, then Error < Exit < Stopped c, stops ordered by cause, ties going to the earlier stage. A stage’s escape tears the pipeline down at once: a filed Returned carrying an exit, or a stop the stage did not get from this collector, answers Effect::CancelAll with its cause (Cancelled for an exit), the siblings having no use left — their stops then name their own nodes and are consumed, so the escape is the verdict: !{ exit 3 } | !{ /bin/sleep 100 } is exit 3 at once. With no panic and no stage halted, the teardown’s own cause is the verdict, the collector’s closing observation, so a teardown every stage survived as a clean EOF still ends the pipeline stopped. Each interior edge’s held-open read end — an Arc the HeldEdge and any sentinel on it share — closes once that edge’s writer’s observation completes and the sentinel has returned, which also releases any descendant of that edge still blocked writing into it. Verdicts fold in launch order regardless of settle order, so which stage the collector kills when never changes which failure the fold reports.

Teardown is kill-first, bar a cleanup’s grace. A pipeline that ends before every stage has been observed ends by one of two mechanisms, and both put the death of what this collector launched before anything that blocks indefinitely. CollectState::cancel_all(cause) is the one that observes on purpose — a stronger cause on the scope, a stage’s escape or panic, the anchor’s death — and spells the order out for both an owning and a joining group, its own Group saying which of the two it is: (1) escalate records the cause as teardown and writes it on every live stage in one sweep, so their cleanups share one strike — StageHandle::cancel recording it on that handle’s own sent, and for a thread writing it on the stage’s node, the collector being that node’s one writer; an external is left untouched here, since a process hears a cancellation only as a signal; a cause teardown already covers returns at once; (2) that signal is grace_signal(cause) — Interrupted → SIGINT, Cancelled/TimedOut/Terminated → SIGTERM, ReaderGone/Aborted → none at all, straight to the kill — sent once per process, a thread stage’s member adding no copy of its own, to every Address the collector enumerates (CollectState::addresses): the whole pgid (then SIGCONT, since a stopped member cannot act on the first until it runs) for Group::Owns, or per pid via Watch::signal for a joining collector, which has no pgid of its own, and only when Group::owes the cause: one the outer stage holds is the owner’s, already sent to every member — and, beside either, each confined stage’s envelope, the payload group bwrap’s --new-session put in a session of its own, which Launch::spawn learned from the --info-fd and ExternalStage::envelope kept. A key the tty already delivered (pressed() >= cause) leaves out the pipeline’s own addresses, never the envelopes: the kernel already gave every member of the foreground group that signal, and a second copy would be a second interrupt — but an envelope’s session was never in that group; (3) a bounded grace of at most TEARDOWN_GRACE (500 ms, shared with the standalone command’s own terminate), drained a slice at a time, filing every stage’s own terminal event and escalating as it goes — a filed panic to Aborted, a stronger cause on the scope to itself — other events being moot once the group is dying, and then, unless the teardown is Aborted, a wait for live thread stages bounded by the run’s cleanup_grace, since a thread stage may be in a shielded cleanup whose children share the group; no shield masks Aborted, so under it that wait would end on its own; (4) the kill — kill_live, the same addresses once more: SIGKILL -pgid for an owned group, else a pid-wise Watch::kill over every stage still Some, a joining collector having no pgid to SIGKILL either, and every envelope by its own pgid; (5) a further blocking drain, unbounded, for whatever the grace did not already account for — a cancelled and killed member having nowhere left to block; (6) the anchor last, in Drop, after every stage handle has gone: its report reader is joined and its own Watch dropped, which reaps (the report reader’s read end closes only once it sees the anchor’s own EOF, so it is never dropped while the anchor could still SIGPIPE on a stray write into it).

Because grace_signal is the one cause→signal table, and RunningChild::terminate reads it too, a reader-gone teardown kills outright at every nesting level: !{ yes | cat } | head -1 ends the inner yes without a catchable SIGTERM ever being attributed to it. What the collector sent is read back in fold, through WaitOutcome::classify, so a pipeline external torn down for its own stage is consumed, and the teardown’s cause — timed out, status 124, for a wall — is the pipeline’s stop, exactly as a standalone external’s (one-delivery-path, cancellation).

CollectState::drop is the other, and it covers every forced end that drops rather than observes: a launch that failed part-way, an unwind between launch and fold. When a stage is still unobserved — a condition derived from what the collector already knows, not a flag a caller must set — it writes Terminated on every live thread stage in one sweep, so the joins in the stages’ own drops share one grace, then kills the owned pgid, or, for a joining collector with no pgid of its own, its own live externals by pid; either way the descendants below it die before the handles join. Every stage observed is the ordinary end, which a descendant a stage forked outlives.

Killing before joining is what makes the joins terminate: a stage’s own kill reaches its pid alone, and a pumped descendant that survived it would hold the pump’s pipe open forever. The two graces nest without adding — the group’s SIGKILL ends whatever the standalone command’s own terminate grace is waiting on for a stage’s interior child. Descendants of a killed external are the group owner’s, as for any stage’s own kill. PipeNode’s field order carries the drop half — collector, then group — and the routes are a local of PipeNode::launch declared after the node, so an error part-way through the spawn loop closes the routes it never consumed first, and a half-wired neighbour sees EOF rather than a live write end held by a node already tearing down. The collector’s kill must reach the pgid it named, and it is the live anchor that keeps that pgid from being reused; the anchor is waited last precisely so nothing that could still need its pgid joinable is waiting on it first. On Windows the grace is Ctrl-Break to every member of an owned group (break_pipeline_group: one event per pid on the job’s member list, nested members included, each stage being its own console-group root), which the anchor swallows; a pid gets no grace, the Job Object kill following alone, and Drop releases the group’s GROUPS entry once the anchor is reaped.

The terminal lease never moves for a stop. A foreground pipeline that takes SIGTSTP does not become a parked job, and there is nothing for the terminal to hand back: the reaper answers the stop with SIGCONT inside its own scan, invisibly to everything else, so the TerminalLoan and the pgid’s tcsetpgrp ownership are never released and never need reacquiring (ral-does-not-suspend). Ctrl-Z on sleep 10 | cat at the prompt is therefore invisible: the pipeline keeps the foreground throughout, exactly as if the signal had not arrived.

See also pipelines, evaluator-machine, capability-enforcement, stages-are-threads, ral-does-not-suspend, the-cut-is-at-the-write, one-delivery-path, delimiters-are-handlers-for-poll, a-cleanup-is-shielded-within-its-grace; map runtime, io-process. docs/SPEC.md §7, §7.6, §11.6; RATIONALE §“The pipe is the operating system’s”.