Output capture and detachment
A run captures a child’s output by draining its stdout/stderr pipe to
end-of-file, so a process that never closes that pipe is foreground work the run
must wait on to its wall — and spawn is the construct that moves such work off
the run onto a root-parented, byte-bounded, lease-bound worker. A long-running
server is the canonical instance: run inline it stalls the call to the deadline
and is killed with its tree; spawned it returns instantly and survives, reaped
only for neglect — or never, if born a service.
The Capture node: a computation’s own byte payload
This section names a different mechanism from the drain-to-EOF story
below, though both swap in a Sink::Buffer. The evaluator’s Capture node
is inserted by annotate wherever a value
demand meets a byte payload route. A command substitution is
the canonical example: let v = echo hi. It does not inspect the operand’s
produced value. The operand’s type already routes its payload to stdout, so
there is nothing left to discriminate.
CompKind::CapturepushesFrame::Capture(core/src/evaluator/machine.rs, evaluator-machine) and swapsshell.io.stdoutto a freshSink::Bufferfor the run of its operand.- On success,
Frame::Capture’s return rule takes the buffer exactly, asValue::Bytes. The text a value boundary reads comes from theDecodenode the checker binds over it — no frame of its own, since the kernel’sdecodetakes a value andstep_evalreads it inline: one trailing terminator stripped, thendecode_utf8_strict, over the bytes closing the bound variable gives back. A decode failure names| from-bytesas the route for output that is not valid UTF-8. - On failure,
Frame::Capture’s halt rule releases whatever bytes the operand already wrote to the outer stream, before the error propagates. A failed operand’s partial output is therefore not silently lost. - An operand may contain an external child. That child’s stdout still drains
through the ordinary pump-to-EOF machinery below. It lands in the
Capturebuffer rather than in the terminal.
Flush-through routes a non-final write past the innermost buffer. A
block is a right-nested Bind, a; b being a to _. b
(evaluator-machine), and stepping a Bind
swaps shell.io.stdout to the ambient sink (swap_ambient_stdout,
core/src/evaluator/machine.rs) before evaluating its left-hand computation,
restoring the prior sink when the To frame it pushed returns. Every
non-final part therefore writes to the sink one level out; only the
sequence’s tail is its payload. This is what keeps a syntactic thunk and an
opaque one in agreement: !{M} and let b = {M}; !$b observe the same
bytes in a capturing context. They differ only in buffering latency.
Capture is a drain to EOF
A captured stream is a Sink::Buffer fed by a pump — see io-process.
Sink::pumpspawns a thread runningio::copy(child_pipe, sink)until the pipe reaches end-of-file (core/src/io/sink.rs).- The pump is joined after the child is waited:
WaitedChildjoins the pump handles, and the typestate makes draining-before-waiting unwritable (core/src/runtime/command/child.rs, runtime). A foreground command returns only once every byte the child wrote has been copied and the pipe has closed. - The release condition is EOF, not exit. A pipe closes when its last writer’s descriptor closes — so a child that has itself exited but left a grandchild holding the inherited write end keeps the pump blocked.
A never-closing pipe stalls the foreground to the wall
- A server holds its stdout open for its whole life, so the pump’s
io::copynever sees EOF and the foreground command blocks indefinitely. - The release is the foreground deadline. exarch arms a 30 s wall as a
disarmable entry on the shared
process::reaper(deadlines-as-data); on expiry the worker’s child-wait loop firesterminate_group. - A non-interactive exarch external leads its own process group
(
PgidPolicy::NewLeader,core/src/runtime/command/foreground.rs, gated by terminal-foreground-ownership), so the cancel SIGTERMs then unconditionally SIGKILLs the whole group — grandchildren included. Every copy of the write end closes, the pump sees EOF, the drain joins, and the call returns at the wall with exit 124. The server dies with it. - The child’s own report names the cause, not the signal: the cancel cause
travels with the status as
WaitOutcome::Cancelled, so the command readsstopped because the call's time limit expiredrather thankilled by signal 15. The status is unchanged — a signal death’s 128 + N. - This is correct, not a defect: an inline command that never closes its pipe is genuinely work the run cannot finish, so the run bounds it and tears down its tree. The cancel→drain→collect path is the same one pipelines reap by (pipeline-execution).
spawn moves the work off the run
The escape is detachment — the handle is its evidence (concurrency-detached-vs-structured).
spawn { … }reifies aValue::Handleand runs the body on a worker thread parented at the durable root, not the swappable foreground scope (unify-turn-evaluation). The 30 s wall never reaches it, so the worker survives the run.spawnreturns the handle the instant the thread starts; the run does not wait. A server spawned this way keeps running while the launching run returns in milliseconds.- The worker’s output goes to its own per-handle buffer —
spawn_childwires the child’sstdout/stderrto freshnew_buffer()sinks (core/src/builtins/concurrency.rs), drained into the handle’s cache only when it settles. There is no run-owned pipe for it to hold open, so the run cannot stall on it.
A chatty server is bounded, not unbounded
- Every
Sink::Bufferis capped at 16 MiB (SINK_BUFFER_CAP). Past the capwrite_cappedappends a one-line truncation marker and drops the rest — yet the write still returnsOk(core/src/io/sink.rs). - So the pump keeps reading and discarding after the cap. A server that spews to stdout never fills the kernel pipe — it never blocks on a full pipe — and the worker’s memory stays bounded at ~16 MiB. The detached path has no unbounded-growth failure mode, and no undrained-pipe stall.
- Truncation with a marker is this path’s contract, and only this one. A
worker’s bytes arrive with nobody to refuse them: the pump is a thread whose
failure has no one to raise it to, and the buffer has to stay bounded whether
or not the handle is ever awaited — so the report travels in band, and
awaithands on a prefix that says where it stopped. Where the bytes are the value —capture— the buffer is drained by the very step that would bind them, soFrame::Capture’s return rule readsbuffer_overflowedonce the writers have joined and fails, flushing the prefix visibly rather than binding it (capture). One flag on the buffer, two readings of it.
Detachment decays by neglect, not by age
- Every detached worker —
spawn,watch,service— files aWorkerEntryin its shell’sWorkerRegistrythe instant it starts (core/src/types/shell/workers.rs): a per-shell directory holding the handle itself, not a second by-id control plane. All three classes are meant to end with the host process, anddetachfiles nothing here because it is not a worker at all — it is meant to outlive the host (survives-exit-is-its-own-verb).poll,await,race, andcancelstay the only verbs that touch a worker, and there is no model-facing listing over the registry at all — theworkersbuiltin was retired, since a listing carrying liveValue::Handles can never cross the engine protocol. Rediscovery instead splits by class: an ordinaryspawn/watchworker (LeaseClass::Worker) is rediscovered through the binding lease, never by id; aservice-born worker (LeaseClass::Durable) is rediscovered by the id its birth trail card named (worker #id cmd durable) — the pin register once carried a host-ownedserviceslisting for this, deleted with the rest of the protected-pin mechanism (names-and-schedule-labels’s 2026-08-27 amendment) — andservice-handle <id>(exarch/src/shell_eval/builtins.rs) takes the handle back by that id to resume the ordinary eliminator idiom (builtins). - An ordinary
spawn/watchworker (LeaseClass::Worker) is governed by the frame’sWorkerLease: an idle bound on the observation clock, under an absolute backstop. It is reaped once unobserved — nopoll/await/racehas named its handle — foridle, or once older thanbackstopregardless of observation. exarch grants one hour idle / 24 hour backstop (DETACHED_WORKER_CEILING,DETACHED_WORKER_BACKSTOP,exarch/src/shell_eval.rs); the REPL grants none, so its spawns never reap. Age alone no longer kills a worker: a build babysat every run viapollrenews indefinitely, up to the backstop. - The mechanism is the reaper’s own re-arming
Rundeadline (process::arm_callback,lease_fireincore/src/builtins/concurrency.rs): each firing checks the backstop first, then the idle bound off the handle’s shared last-observed cell, and either reaps or re-arms itself for the sooner of the two remaining margins. A worker that has already settled (notRunning) ends the chain silently — it lingers in the registry as an unclaimed result under its own, separate retention lease (256 idle ral calls,SETTLED_WORKER_RETENTION), swept byWorkerRegistry::sweep_retentionon the host’s ral-call epoch. service <desc> { … }births a worker whose registry entry carries the durable class (LeaseClass::Durable): no idle bound, no backstop ever arms for it — legibility is the whole bound, structural now thatdescis a mandatory single-line description: the host’sservicespin lists it by id and description,service-handle <id>retakes its handle, and it dies by/clear, an explicitcancel, or — by intent rather than by construction, since teardown’s cancel flag races the exiting main thread — with the host process.- Every reap — idle, backstop, or settled-retention — is atomic with a
ReapNoticerecording what fell and why. The engine drains its ledger at each run’s own ready boundary (Shell::emit_ready_boundary_notices, called fromrun_framedjust before the frame tears down) and pushes it through that run’s surface sink as a`noticevalue, beside the binding-lease’s idle-prune notice; exarch decodes it back (card::value_to_notice) into aDisplay::Noticerecord, folded into a transcript/TUI row by whichever printer draws it. The model’s later “where did my job go?” always has an answer in the log.
Reading a spawned server’s output (the exarch caveat)
- A server never settles, so
await $hwould block to the wall and unwind — sparing the root-parented worker, via the cancel-awarewait_first_settled. Butpoll $his a pull-based read of a running worker: its`pendingarm carries a{stdout, stderr}snapshot of the bytes buffered so far, cloned non-destructively (peek_buffer, not the completiontake_buffer), so the buffer is left intact and a laterawait/`settledpollstill sees everything (partial-poll-pending-output). The snapshot is cumulative — each poll of a live worker reports monotonically more — and it is capped bySINK_BUFFER_CAPlike every capture buffer. watch— the one primitive that streams a running worker’s output live — is still REPL-only: exarch’s per-call capture sinks cannot host a root-surviving writer (watch-repl-builtin). Partialpollis the headless substitute: not a live stream, but a poll-driven read exarch can drive from its own runs.- So under exarch a server is fire-and-
poll-and-cancel:spawnit, read its accumulated output withpoll $hon later runs,cancel $hwhen done.pollis also what keeps a plainspawned server alive past an hour of inattention — it renews the idle-observation lease. A server known at birth to go long stretches unpolled wantsservice <desc> { … }instead: born with no idle bound and no backstop, it never reaps for inattention, only by/clear,cancel, or the host’s own exit — and a server that must still be answering after that exit is not a worker at all, but adetach(below). To keep a full, unbounded log past the 16 MiB cap, still redirect inside the block to a file —spawn { python3 -m http.server > srv.log 2>&1 }— and read the file on later runs.
detach leaves the machine, not just the run
Every mechanism above is in-process: the pump, the buffer cap, the lease, the
registry all presuppose a thread the session can still reach. detach gives
that up — the thing to escape is not the session but the parent’s observation
of the child’s pgid.
- Everything up to the birth is the ordinary external-command machinery —
identity,
vet,build_command(core/src/runtime/command/detach.rs), so the grant judges the call exactly as it judges any exec and a head a handler in scope intercepts runs that handler instead, birthing nothing. Only the last act differs:Launch::spawn_detacheddouble-forks. The intermediate exits at once and hands the grandchild’s pid back over a pipe, so noRunningChildever records the pgid both kill paths address. Reaching the same lifetime by hiding a worker outside the session’sDurableRootcancel scope was rejected: it defeats the signal but leaves the child holding a pipe that closes at exit, killing anything that logs. - The grandchild severs the rest itself:
setsid()in its own body, since a bare double fork leaves its pgid naming the intermediate’s recycled pid; and fd 0, fd 1, fd 2 all on/dev/null. There is no pump, so none of the drain-to-EOF story above applies, and noSINK_BUFFER_CAP— there is nothing to buffer. - What comes back is a receipt —
{ pid, desc }, a record rather than aValue::Handle— and nothing is written anywhere. So there is nothing for aLeaseClassto grade, nothing forcancel_allto reach, and no exit status: the session never waited and cannot. The survivor is mute: a program worth outliving a session keeps its own log, and the only way to learn whether it is still alive is to probe what it serves. - The verb is absent, not vetoed, wherever it has no meaning. A host arms a
DetachPolicy— a birth budget — in the same act that installs the builtin, and does so only off Windows, where the double fork exists. That is now the whole of the absence question: whether a given call may spend the verb is asked of the live grant stack (GrantStack::permits_detach) and answered as a refusal,detach: false(detach-under-a-grant). - A survivor born under a projection keeps it for life.
build_commandrenders the frame’s confinement into the launch exactly as for a child the session keeps; onlyOwnership::Surrendereddrops bwrap’s--die-with-parent, which against a double fork would kill the survivor moments after birth or never fire at all. Nothing later can widen what it may touch, because nothing later can name it. - A detached row could not implement
Resident(core/src/types/resident.rs) even if one wanted the ledger’s uniformity, because that trait demandscancel()and every answer is wrong — a no-op lies about the edge, akillre-asserts the ownership the verb exists to renounce (residency).
See also
survives-exit-is-its-own-verb
(the verb, the race it replaces, and why the type change earns the name),
concurrency-detached-vs-structured
(why a handle marks detachment, and the doctrine
leases-and-budgets retired: not
“detached workers are unmanaged by design” but unmanaged by default),
unify-turn-evaluation (the root/foreground
split and the reaper), shell-eval (the frame that arms the
wall and captures the bytes), binding-leases (the
lease idiom applied to scratch names, the same page’s sibling story),
io-process, and docs/SPEC.md §11.2, §11.5.