writeonce/docs/stories/language-runtime-database/42-bounded-subprocess.md
shoney.arickathil 8bcd24969e docs(lang42): story, spec and plan for bounded subprocess
- claim the lang42 prefix; iteration 42 story (readiness: ready), approved
  spec, and the 11-task implementation plan
- board: pending row for 42; graph: node 42 with green edges (11, 24)
- graph: porch track section added (same sweep)
- parity studies that motivated 42: alacritty, tmux, zen-browser under
  docs/plan/exploration/ — staged path, gap lists, refused routes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:19:25 +02:00

6.8 KiB
Raw Blame History

track iteration status readiness
language-runtime-database 42 pending 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.

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.