- story frontmatter status: done, Progress section records what landed vs the spec (everything, same day as the brainstorm) - board: NEXT PLAN entry with the six standup answers (deadlock proven real: 5 s hang, 8192-byte truncation; 15 ms after; ping 2 ms during a parked child; 1000 spawns fd-flat; SIGTERM leaves no child); pending row flipped to DONE - graph: node 42 class done, same change as the board row - runtime/src/CODE-LOGIC.md: the bounded-subprocess section (bundle park, slot registry, ownership sweeps, raw pidfd syscalls) - full belt at close: 19 runtime suites 0 fail (test_proc 128/0), woc-test 557/0, subprocess-accept 12/0, site-accept 23/0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.6 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| language-runtime-database | 42 | done | ready |
42 — bounded subprocess: spawn, supervise, and reap a child process
Part of Story — one language, one runtime, one database. Spec:
2026-09-01-bounded-subprocess-design.md(brainstormed and approved 2026-09-01; the six forks below are settled). First named as iteration 28's leading gap ("bounded subprocess, stdin/stdout transport"), promoted to its own iteration on 2026-09-01 when three further consumers arrived at once — the alacritty, tmux and zen-browser studies all bottom out on it as their first domino.The problem. A one-shot
proc.runalready exists (runtime/src/sysio.c,WO_B_PROC_RUN: fork/execvp on an argv vector, stdout/stderr captured through pipes, exit code returned) — but it is bounded in nothing that matters and blocking in the one place it must not be. It runs on the shard thread, not parked in a fiber, so one slow child stalls every actor on that shard; it has no deadline, and itswaitpidloop notices a runtime stop only if a signal happens to interrupt it; its output caps (8,192 / 4,096 bytes, fixed) truncate silently rather than refuse; there is no stdin, no streaming, no concurrency ceiling, and no story for what happens to a child when its owner dies. Suspected but not yet reproduced: a child that fills the stdout pipe past the cap while the parent is waiting for stderr EOF deadlocks both — needs a test before it is claimed. Every workload class beyond "server that owns all its state" — a script runner (28), a terminal (alacritty stage A), a multiplexer (tmux), a browser driver (zen C′) — begins by starting a process and ends by being responsible for it.
Why bounded — the doctrine half
The feature is deliberately not "exec". Every resource a child can consume carries a declared ceiling, and exceeding it is a refusal by name, never a hang or an OOM — the same fail-closed posture porch 1 proved for pool saturation and iteration 31 for mailbox caps:
- Time — a deadline after which the child is killed and the caller resumes with a named timeout error.
- Output bytes — a cap on captured stdout/stderr; past it the child is killed and the error names the cap, because an unbounded pipe buffer is an unbounded allocation.
- Concurrency — a ceiling on live children per program; at the ceiling, spawning fails closed rather than queueing invisibly.
- Lifetime — a child is owned by the actor that spawned it. Owner dies, child is killed and reaped. Program stops, children are terminated before exit (iteration 40's drain guarantee extends to them). A zombie or an orphan is a bug by definition, not a caveat.
Info — the forks, settled (brainstorm 2026-09-01)
- Retrofit, one-shot only. The existing
proc.rungains the bounds in place (deadline, declared output caps, fiber parking, owner-bound reaping, per-shard ceiling). The streaming form — long-lived child, stdout as mailbox messages, what tmux/alacritty/zen need — is the named follow-up, building on this slice's registry and pidfd machinery. - Transport — died with fork 1: a one-shot form streams nothing. Settled by the follow-up when it arrives.
- pidfd, no signal seam. Child exit is a pollable fd (
pidfd_open, Linux 5.3+) registered in the shard's io_uring beside the pipes and the deadline timer; the fiber parks in the iteration 35_dlmould. The general signals-as-events seam stays deferred by name to its real consumer (the multiplexer study's stage C). - Argv only; a shell form is refused permanently. A caller wanting a
shell types
sh -cinto their own argv and owns the quoting risk; consumer #1 is a confined script runner and must never get a shell-shaped API. - Env and cwd stay inherited, zero knobs. No consumer demands
control; skillhost confinement owns env scrubbing when it arrives, and
a clean-env knob breaks
execvppath search. - Ids.
WO_B_PROC_RUNkeeps its id and gains parking + defaults; the extended bounded form takes the next free id from 96+ (89/90 remain 31's holes); no.wobbump (38's precedent).
Bounds surface: bare proc.run(cmd, args) gets named defaults (30 s,
1 MiB stdout, 64 KiB stderr); an extended form states them per call;
exceeding any bound kills the child and raises a catchable error naming
the bound — silent truncation is removed.
Progress
DONE 2026-09-01, same day as the brainstorm. Everything the spec
names landed: the parked rework (pidfd + epoll bundle + wo_child
registry in sysio.c/vm.h/vm.c), proc.run_dl end to end (wob.h id
96, loader arity row, builtin dispatch range, types.ml row — no
emit.ml change, as the net _dl precedent predicted), and the
suspected drain deadlock proven red against the old code (5.0 s hang to
the alarm, stdout truncated at 8192) before the rework dissolved it
(15 ms). Gates: test_proc 128/0 inside a fully green 19-suite ASan run,
woc-test 557/0, just subprocess 12/0 first run (ping answered in 2 ms
while a sleep-2 child was parked; SIGTERM left no child), just site
23/0 untouched. Mechanics written up in runtime/src/CODE-LOGIC.md.
Acceptance criteria — firmed in the spec, normative form there
- Given a child that exits normally, when it is run, then its exit code is an ordinary value in the language and both output streams are readable.
- Given a child that outruns its deadline, when the deadline passes, then the child is dead (verified by pid absence, not assumed), the caller has a named timeout error, and no fd has leaked.
- Given a child whose output exceeds the byte cap, then the refusal names the cap and the child is dead — measured with a deliberately chatty child.
- Given an owning actor that dies while its child lives, then the child is reaped — verified from the outside via the process table.
- Given SIGTERM to the program while children live, then every child is terminated before the program exits — the drain gate (iteration 40's battery) gains a subprocess leg.
- Given a spawn loop of one thousand sequential children, then the program's fd count is flat (the iteration 24 measurement style) and rss does not grow with the loop.
- A runnable example under
docs/examples/with ajustgate, in the residency/chat mould — the gate is the acceptance, not the unit tests alone.
Out of scope, by name
- PTY allocation — pipes only. Giving the child a pseudo-terminal is the alacritty/tmux stage-A follow-up and has its own ioctl surface.
- fd passing (SCM_RIGHTS), termios, terminfo — the tmux study's stage-C bill, separate iterations.
- WebSocket client — zen C′'s remaining item, not process work.
- A general signal API for user code — only if fork 3 resolves toward the private reaping path; otherwise this iteration carries the seam but not the surface.
- Pipelines between children — composition can wait for a consumer.
Consumers, for the record
Iteration 28 (script runner, "bounded subprocess first" was already its stated order); alacritty study stage A (headless PTY runner — needs this plus PTY); tmux study (spawn half of its kernel); zen study stage C′ (spawn the browser, then drive it). Four consumers is the most any single named gap has accumulated.