Pipeline execution: byte edges, process groups, and helper final values

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 (collect then finish) 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 the wire — only ⟨comp, scrubbed E⟩ and the wire context ride along, E being the pipeline node’s own lexical environment rather than shell.env. 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) reads redirects, the terminal plan, and whether a !{…} audit captures bytes, and turns each stage’s head resolution into one launch decision:

  • Direct — an external command or bundled tool launched directly;
  • HelperEval — the stage’s ral computation evaluated in a helper.

No route is consulted — nor could one be, since the checked IR carries none: a stage’s classification cannot depend on where its payload lives, because the choice must be observationally transparent. The one fact resolve carries through is the pipeline node’s own PipeYield, the syntax the checker wrote in place of the last stage’s route, frozen onto the PipelinePlan. Launch consumes these 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 walks the stages once: stage i takes its stdin from the previous edge when there is one and from the parent otherwise, and writes to a fresh os_pipe when i + 1 < n and to the parent otherwise. Nothing in that loop reads a type. A non-final stage’s returned value is simply discarded (wants_value = false), and no returned value is ever serialised onto an edge — the value report travels its own channel, a socketpair on Unix and an anonymous pipe pair on Windows, never aliased with the interior pipes (pipes-are-positional-byte-wires).

The final-value bit is derived once, from two facts in one place: FinalValue::Report iff i + 1 == n and plan.yields is PipeYield::Last. Everything else is FinalValue::Ignore.

A non-final stage cannot observe its reader’s death by EPIPE. The parent holds a duplicate of each interior edge’s read end until that edge’s writer stage is reaped, so no interior edge ever delivers a broken-pipe signal or a write error to the stage that writes it. Instead the collector kills a producer once its reader stage is reaped: yes | !{ return 5 } terminates by that kill, on Unix and on the Windows-supported paths alike. Neither endpoint of a pipe promises traffic, so a producer with nothing left to write for is the ordinary case, not an error path — only the mechanism that ends it moved from the wire to the collector.

The final value report remains helper-staged for now. When the pipeline yields its last stage’s value, FinalValue::Report selects the helper’s value report. The parent sends one ChildEvalRequest with the stage body and WireShell snapshot; the helper evaluates it and returns the value in one ChildEvalResponse, alongside status and observations. The parent does not run a special in-process tail yet. Moving that tail into the parent is a separate future decision (compilation-ladder).

A helper serves one ChildEvalRequest / ChildEvalResponse frame pair. A HelperEval stage uses the shared run_child_eval runner (core/src/child_eval.rs). The parent packs the stage body plus a WireShell snapshot into one request frame and gates the helper on it; the helper reconstructs a child shell (Shell::child_of over the captured closure environment), evaluates the stage, and ships one response frame carrying the final value, status, and flat observations. It has no upstream typed-value input: an interior producer reaches it only through its byte pipe. The grant body evaluates locally and confines each external child per-command instead (capability enforcement; sandbox-external-children). A bundled stage takes the Direct arm as a ral --ral-bundled-tool child, and then never reaches this runner; when a redirect, a byte-capturing audit, or a foreground handoff rules that arm out, it comes through the helper like any other stage and spawns that child from inside it.

Pipelines run as one process group, held open by an anchor. Every multi-stage pipeline — including one whose stages are ral-implemented — executes in a subprocess sharing one pgid the parent ral process is not a member of. PipelineGroup (pipeline/group.rs) owns that pgid through a stable anchor process, spawned by prepare because a later stage’s setpgid join needs a target that cannot die first. The n ≥ 2 guard on that spawn is a formality: the machine’s Pipeline arm reduces a single-stage form to its inner closure, so PipeNode::launch never sees one. The SIGINT-forwarding relay is claimed on the first spawnafter spawn_with_pgid plus setpgid in pre_exec has put a real child in the group — so a signal is never forwarded to a child-less pgid; the module doc of group.rs states this SIGINT/relay invariant in full. Between prepare and the first spawn, a racing SIGINT only cancels the run’s foreground scope, which the launch loop’s per-stage process::check turns into a prompt abort.

A helper-evaluated stage can itself launch a pipeline. The machine’s Pipeline arm is PipeNode’s only caller and steps in the helper exactly as in the parent, so a stage whose body is itself a pipeline spawns its own nested helpers. Nested stages still receive only the byte routes and the helper control frames relevant to their own launch; no interior typed-value descriptor is an inheritance mechanism.

Out-of-process ral stages are subshells. A helper stage’s cd, env, or module changes do not flow back — only the byte pipe contents, final result, and observations cross the boundary. The final value report is enabled only for the last stage, which keeps job control coherent (isolation).

Windows has no foreground handoff, and its pipeline spawn boundary is creation-time. There is no tcsetpgrp to race; the terminal plan never selects ForegroundExternalGroup. The helper protocol still runs over anonymous OS pipe pairs for gate and final-report frames (pipeline/protocol/{unix,windows}.rs), but the parent side now writes numeric handle values into the helper environment and admits the raw handles to process::Launch. The Windows launch backend lowers that value through raw CreateProcessW with PROC_THREAD_ATTRIBUTE_HANDLE_LIST under a process-wide launch mutex, so report/gate handles cross only to the child named by that launch. Pipeline Job Object membership is likewise a launch fact: the group is prepared before spawn, the child is created suspended, assigned to the known job, then resumed; registration records a child already in the job. Collection therefore waits on the process tree ral actually launched, not on a post-spawn approximation (windows-spawn-boundary).

Collection is an event loop over a non-blocking probe. The collector polls every unsettled stage with try_settle — the same try_wait_handling_stop the single-child wait already polls — so stages settle in whatever order they actually end, and no stage’s blocking wait can starve another’s news. A stage whose reader has settled is killed (kill_for_dead_reader), so the cascade runs tail-ward; a stage that stops parks the group at once, wherever it sits — a self-stopping producer parks the pipeline rather than wedging a collector blocked on the final stage. Each interior edge’s held-open read end is dropped once that edge’s writer’s observation completes, 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. A parked pipeline 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 nothing here changes for it.

Abort is gate-first. A mid-launch failure SIGTERMs the pgid so whoever honours it can leave before the drop order reaches SIGKILL, and a PipelineBuild accumulator then releases every transient resource in one order: unreleased stage gates close first (a helper parked on its job read treats EOF as the parent’s stand-down and exits), then the unconsumed StageRoutes (every unspawned stage’s edge ends, allocated up front by open_stage_routes), then the running stage handles, then PipelineGroup. That order is the invariant — a helper holding an inherited copy of the anchor channel must be let go before the anchor is waited, or the wait deadlocks.

Stop and resume park the whole group, and the foreground handoff orders before the wake. A foreground command or pipeline that takes SIGTSTP becomes a parked job rather than dying. wait_handling_stop (core/src/process/signal/unix.rs) is entered with park_on_stop true only on the interactive foreground path — a standalone external sets it from fg.park_on_stop() (want_fg && interactive, runtime/command/foreground.rs), a pipeline stage from plan.terminal.owns_tty() (pipeline/launch.rs). A non-interactive script that foregrounds an interactive child still takes the terminal but has no job table to resume a parked job, so it kill-and-reaps on stop rather than parking (terminal-foreground-ownership). On WIFSTOPPED the parking path returns WaitOutcome::Stopped without killing or reaping (batch mode keeps park_on_stop false and runs the legacy kill-and-reap). RunningChild::wait (runtime/command/child.rs) turns that outcome into Err(Break::Escape(Escape::Stopped { pgid, signal, cmd })) and detaches its pump threads — they keep draining the stopped child’s pipes and finish on their own once a later fg runs it to completion. For a pipeline, the collect walk (pipeline/collect.rs) SIGSTOPs the whole -pgid the moment it observes a stop, so any still-running siblings park together, and stops probing; every stage it never observed is then abandon()ed so their Drop does not SIGKILL the parked group. As PipeNode::join returns, PipelineGroup’s drop has the ForegroundGuard restore the terminal to the shell and AnchorProcess::finish SIGCONT just the anchor’s own pid (not -pgid), so the anchor wakes, sees EOF on its release fd, and exits without disturbing the parked stages — POSIX keeps the pgid addressable while any member lives. That guard was acquired at launch by claim_foreground only when the run held a terminal lease: try_acquire(leader, lease) takes a &TerminalLease whose borrow is the proof ral owns the controlling terminal’s foreground, so the terminal plan and the guard ask the same authority (terminal-lease). Escape::Stopped rides out to the REPL, which records a Stopped job; `JobTable` keys it by pgid. try and audit deliberately let Escape::Stopped propagate unclassified — a parked job is not a recoverable error — ordinary application and bind never reach this flow, having no kernel stage to suspend.

Resuming is where ordering becomes load-bearing. wait_foreground (ral/src/jobs.rs) acquires a ForegroundGuard firsttcsetpgrp(-pgid) plus a termios snapshot, again gated on shell.terminal_lease() so a non-interactive resume that holds no lease skips the tty dance but still SIGCONTs and waits — and only then sends SIGCONT to -pgid, draining with waitpid(-pgid, WUNTRACED) (EINTR-retried) until the group exits or stops again. The tcsetpgrp-before-SIGCONT order is the invariant: a resumed member that reads the tty before the handoff lands would hit SIGTTIN — children carry the default disposition via reset_child_signals — and re-stop the whole group. On a stop during the wait, wait_foreground SIGSTOPs -pgid so a partial stop parks siblings together (a no-op for the Ctrl-Z case, where the kernel already stopped every member), then restores the tty pgid and termios on the way out.

See also pipelines, evaluator-machine, capability-enforcement; map runtime, io-process, jobs. docs/SPEC.md §7, §11.6; RATIONALE §“The pipe is the operating system’s”.