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

140 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: language-runtime-database
iteration: "42"
status: done
readiness: ready
---
# 42 — bounded subprocess: spawn, supervise, and reap a child process
> Part of [Story — one language, one runtime, one database](00-story.md).
> Spec: [`2026-09-01-bounded-subprocess-design.md`](../../superpowers/specs/2026-09-01-bounded-subprocess-design.md)
> (brainstormed and approved 2026-09-01; the six forks below are settled).
> First named as iteration [28](28-skillhost-host-workload.md)'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](../../plan/exploration/alacritty/00-alacritty-parity.md),
> [tmux](../../plan/exploration/tmux/00-tmux-parity.md) and
> [zen-browser](../../plan/exploration/zen-browser/00-zen-browser-parity.md)
> 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](28-skillhost-host-workload.md) (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.