- 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>
126 lines
6.8 KiB
Markdown
126 lines
6.8 KiB
Markdown
---
|
||
track: language-runtime-database
|
||
iteration: "42"
|
||
status: pending
|
||
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.
|
||
|
||
## 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.
|