Event-driven pipeline collector
The producer side is superseded by one-reaper-one-fold: a stage’s settlement, an external’s stop, and the anchor’s witness no longer each get their own dedicated blocking-wait thread — the reaper is the one thread that owns every child wait, process-wide, and a stop never reaches this collector’s channel at all.
Report/Settlement,SettleOnDrop’s external-waiter twin,Witnessed,wait_anchor,ExternalWaiter,run_pipeline_stage, and the collector’s own cancel timer all go with it. The fold andEffectbelow survive untouched:stepis still pure overCollectStateand oneEvent, still returns the sameKillStage/CancelAll/Done, anddriveis stillloop { for e in step(&mut st, rx.recv()?) { run(e) } }— only what feeds the channel changed.Superseded in part again (
99226d37): the purity rule stands, but the shapes moved.Effect::Doneis gone — completion is!live(), no stage handle left —stepreturnsOption<Effect>rather than a list, anddriveiswhile live() { recv; step; run }.
Every event in a pipeline’s lifecycle is an edge — a stop reported once, a
byte written once, a thread returning once — so the collector that used to
poll for them is now a pure fold over one channel. CollectState::step
inspects the observation vector, the group’s role, and the tracked stop
levels, and returns the Effects a thin interpreter performs; drive is
loop { for e in step(&mut st, rx.recv()?) { run(e) } }. One dedicated
thread per blocking wait — a stage’s own settlement, an external’s stop, the
anchor’s witness — replaces CollectState::drive’s 5–100 ms backoff,
RunningChild::wait’s own poll loop for a pipeline stage, and StageGate’s
20 ms condvar timeout for a thread’s interior stop.
Decision
- The event sum is total,
Event::Settled(usize, StageObservation)for both stage kinds —Stopped/Continued/Witnessedarecfg(unix), nothing stopping on Windows. A thread stage’s own closure already computes itsStageObservationin-process (phase 2). An external’s own waiter thread cannot: an external’s finish still needs&Shellfor audit synthesis, exit-hint lookup, and sandbox-denial augmentation, and a producer thread holds none. The channel’s actual wire item is thereforeReport, one layer underEvent:Report::Settledcarries aSettlement—Thread(StageObservation), already finished, orExternal { name, failure }, the rawSettled<Option<CommandFailure>>a waiter thread can compute without a shell.CollectState::resolveis the one place&Shellreaches it, turning aReportinto theEventstepfolds over. This is the one seam where the implementation’s wire type doesn’t literally match the plan’sEvent— a Shell can’t cross threads, not a redesign of what crosses the channel. - One waiter thread per direct external stage, the sole owner of its
wait.
command::RunningChild::run_pipeline_stageblocksChildHandle::wait_tracking_stops(WUNTRACED | WCONTINUED, retried acrossEINTR— the blocking peer of phase 1’s non-blocking version, which it replaces) in a loop, reporting eachStopped/Continuededge through a callback and returning the terminal outcome. It tracks its own last-seen stop locally: a signal death immediately following a reported stop, with noContinuedbetween, is reclassified asStoppedThenKilled— the collector’s oldend_stopped, a synchronous kill-and-reap racing nothing because it ran before the poll that would have seen the stop again, is gone; the identical verdict now falls out of the waiter’s own local bookkeeping, since a stopped child SIGKILLed by anyone is observably “stopped, then killed” whoever sent the signal. Ending attribution reads akill_cause: CancelScopefresh per stage — notRunningChild’s owncancelfield, which is the mooring’s scope, shared with every sibling stage and beyond it; an early draft attributed ending through that shared scope and one stage’s reader-gone kill cancelled its unrelated siblings, surfacing as spurious “its reader ended” failures two stages away.StageHandle’s external case shrinks toExternalWaiter { join, kill_cause, pid }: everything else the collector once read off the child directly (try_settle, a stop level) is the waiter’s own business now. - The anchor’s witness is two threads, not one, and not the plan’s literal
wording — and the anchor’s own stop is a level from two edges, exactly
like a member’s. The plan describes “one reader thread… (blocking
read… plus a waitpid on the anchor for its stop)”; no single OS thread can
block on a pipe read and a
waitpidat once without a signal-multiplexing mechanism (a self-pipe,ppollwith an unblockedSIGCHLD) the plan never asks for and this phase does not add. Two dedicated threads — a report reader blocking to EOF on the anchor’s stdout, an anchor waiter blockingwaitpid(WUNTRACED | WCONTINUED)as the sole reaper of its pid — both reportWitnessedand both terminate structurally: the report reader on EOF (the anchor’s own exit closing its write end), the anchor waiter on any terminal outcome. The anchor waiter trackingWCONTINUEDtoo, and reporting it asWitnessed::Continued, is not optional symmetry: the anchor is a member of the group like any other, and an early draft that tracked only the members’ stop as a two-edge level while leaving the anchor’s a one-shot signal could never clear the group’sparkedflag if the anchor’s own stop was what last set it — a second, genuine Ctrl-Z would then never park.AnchorProcess::finishcloses the release pipe,CONTs the anchor’s pid alone, and joins both threads instead of reaping directly — reaping fromfinishand from the anchor waiter at once would be the very double-waitpidthis whole plan exists to close out. The report reader’s read end, living inside its own thread’s stack, closes only once it observes EOF, so the old ordering hazard (“closing the parent’s read end before the anchor is confirmed dead risks aSIGPIPEon a stray write”) cannot arise by construction, not by a manual sequence. - A thread stage’s interior stop gets a fourth kind of producer: a
condvar, not a poll.
StageStop— a stage thread’s own spawned child stopping, written by phase 4’s own (untouched)RunningChild::waitpoll loop — stays a cell for that writer, since touching that loop’s shape is phase 4’s job, not this one’s. But the reader no longer polls it:StageStopgains aCondvar,setnotifies it, and a per-thread-stage watcher thread blocks onwait_for_change, reporting each transition asEvent::Stopped/Continuedexactly as an external’s waiter does.StageStop::close— the stage’s own last act, before itsSettledsend — wakes the watcher a final time so it never outlives the stage blocked on an edge that will not come. stepis pure: filing an observation and updating this collector’s own tracked levels are notEffects; writing into another party’s shared cell is. The plan’sEffectenum —PauseGate,SigstopGroup,SigcontMember,CancelAll,Park,Done— grows two members beyond the plan’s literal list,KillStoppedStageandForwardStop(both below), each earning its keep against this same line: a signal, a gate, a wake, or a write into state this collector does not itself own, crosses as anEffect;self.observed,self.stopped,self.parked— this collector’s own bookkeeping — do not. An early draft got exactly this line wrong twice, both caught only by trying to write the transition-table tests the plan calls for:on_stopped’s joining-with-a-park arm calledpark.stop.set(Some(sig))— the owning collector’s ownStagePark, one level up — directly inside the fold, which a pure-steptest cannot even observe (nothing aboutstep’s return value shows it happened) and so cannot pin; forwarding the stop is nowEffect::ForwardStop(Option<Signal>)(Somefor the stop,Nonefor its resume), and a test assertsstepalone leaves the owner’s park untouched. Separately,kill_nowunconditionally raised the one forgiven ending for anyKillStage-shaped kill, so the background-stop answer’s own kill (below) silently forgave a verdict that should have surfaced asStoppedByJobControl.KillStageis the reader-gone cascade’s alone;KillStoppedStageis a second, distinct kill. Both reduce to “kill this stage’s pid, or cancel-and-wake this thread, right now,” and an early draft reusedKillStage/kill_nowfor both, reasoning thatCommandFailure::from_outcome’s reader-gone forgiveness never applies toWaitOutcome::StoppedThenKilledregardless of whichEndingis stamped — true, but beside the point:kill_nowis also the only place permitted to raiseEnding::RalEnded(ReaderGone), the one forgiven ending, and a background group’s stop-then-kill is not a reader-gone death — reusing the same method broke that invariant’s literal truth even though the specific verdict it protects happened to survive by another route.KillStoppedStage/StageHandle::kill_stoppedraises no ending at all, leaving the followingCancelAll(Terminate)’s owncancel_stagesto stamp one; a transition-table test asserts the twoEndings differ. The ownerless-stop rule (SigcontMember) is unaffected: reviving a detached member is not a kill, forgiven or otherwise.- A
Foregroundpark needs one level,parked, guarding it — the load a polledresume_allused to carry for free.sleep 10 | cat’s one Ctrl-Z is three edges: the anchor’s ownWitnessed::Stopped, and each external’s own waiter’sEvent::Stopped. The polling collector could not have this bug —resume_allcleared every level synchronously onfg, before the next probe could read a stale one — but the event-driven one, with noresume_allleft to call (each producer now reports its ownContinuedstructurally), can:drivefolds the first edge, returnsDrive::Parked, and the other two sit queued in the channel;fg’sSIGCONT -pgidclears nothing on this side, anddrive’s nextrecvis the stale second edge, parking the group right back — onefgper stopped member instead of one.parked: bool, set on the first edge aForegroundgroup’son_stopped/on_witnessedanswers withPark, and cleared only once every tracked level —stopped[ix]for every member,anchor_stoppedfor the anchor — reads clear again, is what makes a second edge of the same stop answer with nothing while a genuinely later one still parks. - The cancellation timer is the one left, on every platform, and does
only cancellation. A low-frequency (200 ms) thread holds a clone of the
mooring’s own
CancelScopeand aWeaktoken to the collector; it exits once the collector is gone (theWeakfails to upgrade) or once it finds a cause to report, whichever comes first. It does not replicatecheck’s park-gate wait — that cooperative block belongs to whichever thread is actually evaluating a joining collector’s own nesteddrive, and the event-driven model needs no proxy for it: the shared pgid’s ownSIGSTOPalready freezes every process transitively, and a joining collector’sdriveloop simply goes idle, blocked inrecv, until a real edge — its own member’sContinued— arrives. - Cancellation reaches the pids this collector launched, not just the pgid
it may own. A joining collector’s own group can neither
signalnorkill— there is no pgid of its own to send either to — so a direct external stage nested under atimeoutused to hang incancel_all’s final drain until it exited on its own:StageHandle::cancelset only the waiter’skill_cause, an attribution nothing delivers.cancelnow also signals the stage’s own pid directly (the cause’s signal, thenSIGCONT, the same pairPipelineGroup::signalsends a whole pgid — factored out ascause_signalso the two stay one choice), andcancel_all’s kill step falls back to a pid-wisekill_live_externalswherevergroup.kill()would itself be a no-op;CollectState::droptakes the same fork for an unwinding joining collector. A nested pipeline’stimeoutnow behaves exactly as a top-level one’s does. Descendants of a killed external remain the group owner’s to end, as for any stage’s own kill — the same edge every POSIX shell has, and moot on Linux under confinement, where the stage’s own jail cgroup kill already takes its descendants with it.
Superseded and unamended
Supersedes the polling half of
stages-are-threads:
PipelineGroup::witness, StageHandle::probe/Probe, RunningChild:: try_settle/stopped_level, CollectState::pass’s Pass::{Advanced,Idle}
and its backoff arithmetic, and the pipeline arm of RunningChild::wait’s
own poll loop are all deleted; the thread/process shape, the anchor’s
existence, GroupRole, StopPolicy, and ParkedPipeline are unchanged.
Leaves phase 4 of dev/docs/plans/260902_event-driven-pipeline-collector.md
open, each small and independently addressable: standalone (non-pipeline)
RunningChild::wait still polls for its own cancel scope, since a blocking
wait there could not otherwise be preempted by a deadline or an upstream
cancel; the Windows stage-thread interrupt loop still retries
CancelSynchronousIo on its own 5 ms sleep; a killed thread’s forgiven
Break still carries no mark of whether the kill or its own code produced
it, indistinguishable in StageObservation from an ordinary one.
Risks accepted
- The anchor’s report-outranks-stop precedence is no longer enforced by a
single poll’s read order — it is now a genuine race between two
threads. A signal reported in the same instant as the anchor stopping
can, in principle, be answered as a stop first. Accepted: the two are
already rare and nearly disjoint in practice (a signal reaching a
process about to be
SIGTSTP’d by the same delivery), and getting this fully ordered again needs the anchor to fold both into one report itself — a change to the anchor’s own protocol, out of scope here. - A leaked producer thread outliving its pipeline would be a defect. The
cancel timer waits on a
Receiverinstead of sleeping blind: the collector’sDropdrops the pairedSender, which wakes the timer’srecv_timeoutimmediately instead of on its next tick, andDropjoins the thread before returning. No straggler outlives the collector. - Thread count grows by one waiter per direct external stage, one watcher per thread stage, and two per owned group’s anchor, on top of the stage threads phase 1 already counts. Bounded by stage count, the same order the pumps already cost; no pipeline this shape supports has enough stages to make this material.
- Kill by pid is no longer structurally immune to pid reuse. The old
Child::killrefused to signal a handle whose status was already recorded;kill_stage_by_pidsignals a bare pid from a thread other than the one reaping it. The window is between the waiter’swaitpidreturning and itsSettledbeing processed by the collector, whilestages[ix]is stillSome. Accepted as negligible on sequential-pid kernels; the exact fix on Linux ispidfd_openat spawn +pidfd_send_signal, macOS has no equivalent short of a WNOWAIT reaping protocol; to be taken up when phase 4 touches the waiter. - The cancel timer watches the launch-time mooring’s scope, where the
polling
drivechecked the scope of whichever mooring the caller passed (forfg, the REPL’s current one). In practice the same root scope; noted as a semantic change. - A panic in the interior-stop watcher or an anchor witness thread loses
edges, never a
Settled. Neither owns an index — only a stage’s own closure or an external’s waiter does, each behind its ownSettleOnDrop— so the pipeline can go deaf to Ctrl-Z/Ctrl-C (a stop or a signal simply never arrives) butdrivestill reaches a stage’s own end and cannot hang. Out of scope: a stack overflow or a double panic aborts the process regardless of any of this. - Edges from different producers are ordered only per producer. After
fg, one member’sContinuedcan in principle overtake another member’sStoppedfrom the same Ctrl-Z and re-park the group; theparkedlevel’s soundness assumes everyStoppededge of one stop arrives before anyContinuedof the resume, which holds unless a waiter thread is starved for the whole human-scale interval between Ctrl-Z andfg.
Measured
Verified by hand alongside the suite: !{ echo a; exit 3 } | head -1 exits
0, its writer’s already-observed outcome untouched by the reader-gone
cascade that arrives one event later; let n = !{ sh -c 'while :; do :; done' | !{ return 5 } } binds 5, the killed producer’s SIGKILL reclassified
and forgiven entirely inside run_pipeline_stage’s own local bookkeeping,
with no probe anywhere in the path; yes | cat | head -1 exits 0, both
interior stages killed and forgiven independently over the same channel;
Ctrl-Z on an all-ral foreground pipeline (!{ a } | !{ b }) still parks
through the anchor’s own waiter thread, fg still resumes it to completion.
just ci is green on this host; the Linux container was not run.
See also stages-are-threads,
pipeline-execution,
a-stage-ral-stopped-has-no-failure
(unchanged: the forgiven-death rule is stated once, above the transport that
realises a stage — only its trigger moved from a probe to an event).
docs/SPEC.md §7, §7.6, §11.6.