writeonce/docs/stories/language-runtime-database/42-bounded-subprocess.md
shoney.arickathil b17b848403 docs(lang42): close out iteration 42
- 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>
2026-09-01 22:19:25 +02:00

7.6 KiB
Raw Permalink Blame History

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.run already 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 its waitpid loop 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)

  1. Retrofit, one-shot only. The existing proc.run gains 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.
  2. Transport — died with fork 1: a one-shot form streams nothing. Settled by the follow-up when it arrives.
  3. 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 _dl mould. The general signals-as-events seam stays deferred by name to its real consumer (the multiplexer study's stage C).
  4. Argv only; a shell form is refused permanently. A caller wanting a shell types sh -c into their own argv and owns the quoting risk; consumer #1 is a confined script runner and must never get a shell-shaped API.
  5. 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 execvp path search.
  6. Ids. WO_B_PROC_RUN keeps its id and gains parking + defaults; the extended bounded form takes the next free id from 96+ (89/90 remain 31's holes); no .wob bump (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 a just gate, 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.