- 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>
16 KiB
Bounded Subprocess (iteration 42) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.Project rule (overrides the plan-skill template): plan docs carry concept, reason and actions in words — no implementation or test code blocks. Each step names the exact functions, fields, ids and expected outcomes; the implementer writes the code at the keyboard, matching the anchors cited here.
Goal: proc.run becomes bounded (deadline, output caps, per-shard
ceiling, owner-bound reaping) and parked (fiber waits, shard never stalls),
plus an extended proc.run_dl stating bounds per call.
Architecture: rework WO_B_PROC_RUN in runtime/src/sysio.c from
blocking drain + waitpid into the iteration 35 _dl parking mould: after
the fork, the two pipe read ends and a pidfd for the child are bundled
behind one epoll fd; the fiber parks on that bundle fd with the existing
fb->dl_active/fb->dl_at deadline sweep armed; re-entry after each wake
drains whatever is ready into growable capped buffers, and the pidfd firing
means reap-and-return. A per-shard child registry in wo_vm carries the
cross-park state and serves the ceiling, engine-stop kill, and unwind
cleanup.
Tech Stack: C (runtime), OCaml (compiler stdlib table), .wo (example
app), bash (gate script), just (recipes).
Spec: docs/superpowers/specs/2026-09-01-bounded-subprocess-design.md
— the plan argues from it; read both.
Global Constraints
- Linux only; pidfd needs runtime kernel ≥ 5.3 (Ubuntu 22.04 ships 5.15).
- glibc 2.35 (the release build floor) has NO
pidfd_open/pidfd_send_signalwrappers (they arrived in 2.36) — call both via rawsyscall(SYS_pidfd_open, …)/syscall(SYS_pidfd_send_signal, …). - Defaults, verbatim from the spec: deadline 30 000 ms, stdout cap 1 048 576 bytes, stderr cap 65 536 bytes, ceiling 32 children per shard.
- Bound violations kill the child and trap as
WO_T_IOwith a message naming the bound and its value; silent truncation is removed. - New builtin id:
WO_B_PROC_RUN_DL = 96(89/90 stay iteration 31's reserved holes). No.wobversion bump —WOB_VERSIONdoes not move. - No new dependencies, no new threads, no signal handlers.
- All work on
dev; every commit title prefixedfeat(lang42):/fix(lang42):/docs(lang42):; bullet-point commit bodies, ≤25 lines. - Builds and tests only through just recipes:
just wovm-build,just wovm-test,just woc-build,just woc-test. - Runtime test binaries build with ASan+UBSan (runtime/Makefile does this
for every
test/test_*.cautomatically) — a leak or race is a failure.
Task 1: claim the prefix, commit the standing docs
Files:
- Modify:
docs/00-git-commit-history.md(prefix registry table) - Already-edited, to commit: story 42, spec,
docs/stories/00-status.mdrow,docs/00-dependency-graph.mdnode, the three exploration studies underdocs/plan/exploration/{alacritty,tmux,zen-browser}/, this plan.
Interfaces: none — bookkeeping.
- Step 1: Add a
lang42row to the prefix registry table indocs/00-git-commit-history.md: feature "iteration 42 — bounded subprocess (proc.run bounded + parked, proc.run_dl)", status "ondev". - Step 2: Commit all listed docs as one
docs(lang42): story, spec and plan for bounded subprocesscommit (bullets: story+spec+plan added; board row; graph node; the three parity studies that motivated it).
Task 2: baseline suite — pin what proc.run already does
Files:
- Create:
runtime/test/test_proc.c(auto-picked by the Makefile's$(wildcard test/test_*.c)— no build wiring needed)
Interfaces:
-
Consumes: the test harness macros in
runtime/test/t.h(T_EQ,T_CHECK),wo_db_init-style setup patterns fromtest_builtin.c, and direct builtin dispatch the waytest_builtin.cinvokes cases — operands in registers,WO_B_PROC_RUN(id 56) takes cmd Text,multi Textargs, and the Proc class id, returns the record {code, out, err}. -
Produces:
test_proc.cas the home for every later runtime leg. -
Step 1: Write three green legs against CURRENT behavior, copying
test_builtin.c's VM/fiber setup: (a) a child that exits 0 with known stdout — assert code 0 and the exact bytes; (b) a child that exits with a known nonzero code — assert the code; (c) a nonexistent command — assert code 127 (the execvp-failed convention already in the code). -
Step 2:
just wovm-test— all three legs pass, whole suite stays green, ASan clean. -
Step 3: Commit
feat(lang42): pin proc.run's current contract in test_proc.
Task 3: the parked rework — pidfd + epoll bundle + registry
The core task. The suspected sequential-drain deadlock is proven first, then dissolved by the rework.
Files:
- Modify:
runtime/src/sysio.c(theWO_B_PROC_RUNcase, currently ~lines 723–815) - Modify:
runtime/src/vm.h(the per-shard registry inwo_vm, one pointer field onwo_fiberfor the in-flight entry) - Modify:
runtime/src/vm.c(engine-stop sweep over the registry) - Test:
runtime/test/test_proc.c
Interfaces:
-
Consumes:
WO_SYS_PARKEDre-entry convention (sysio.c:510READ_DL is the model: fillfb->park_fd, armfb->dl_active/fb->dl_atonce — guarded so re-entry does not re-arm — returnWO_SYS_PARKED; the plane re-runs the builtin on wake);stop_pending();wo_str_new. -
Produces: a registry entry type (name it
wo_child) holding pid, pidfd, the bundle epoll fd, both pipe fds, two growable buffers with their caps, the deadline, and the owning fiber pointer;wo_vmgains a fixed array of 32wo_childslots plus a live count;wo_fibergains a pointer to its in-flight entry (NULL when none). Task 4–8 legs and Task 9'sWO_B_PROC_RUN_DLall reuse exactly this machinery. -
Step 1 (the red test): In
test_proc.c, add the deadlock leg: a child (usesh -cin the test only) that writes ~200 KiB to stdout and one line to stderr, stderr kept open until stdout completes. Guard the leg with a wall-clock check: it must complete within 5 s. Under the current sequential drain the parent stops reading stdout at 8,192 bytes, the child blocks on a full pipe, and stderr never reaches EOF. -
Step 2:
just wovm-test— the new leg FAILS (hangs into the guard) while everything else stays green. This is the bug proven. -
Step 3 (the rework): Rewrite the
WO_B_PROC_RUNcase: pipes openedO_NONBLOCKon the parent side; after the fork,syscall(SYS_pidfd_open, pid, 0); create one epoll fd and register both pipe read ends and the pidfd; claim a registry slot (fail closed withWO_T_IOnaming the 32-per-shard ceiling if none — the message text the Task 6 leg asserts); stash caps and buffers in the slot, point the fiber at it. First entry and every re-entry then run the same drain: read each ready pipe into its growable buffer; a buffer passing its cap means kill (syscall(SYS_pidfd_send_signal, pidfd, SIGKILL, 0, 0)), reap, release the slot, trapWO_T_IOnaming the cap and value. Pidfd readable means exited: reap viawaitpid(now non-blocking — the pidfd said so), do a final drain of both pipes to EOF (bounded by the caps), release, build the Proc record exactly as today. Nothing ready and child alive: park on the bundle fd with the deadline armed (30 000 ms default), returnWO_SYS_PARKED. Deadline re-entry withdl_atpassed: kill, reap, release, trapWO_T_IOnaming the deadline.stop_pending()on any entry: kill, reap, release, returnWO_SYS_STOPPED. -
Step 4: Engine-stop sweep: where
vm.c's worker loop observes the stop flag, kill + reap every live registry entry on that shard — covers fibers that never get rescheduled. -
Step 5:
just wovm-test— deadlock leg green, Task 2 baseline legs still green (same results from the parked path), suite ASan clean. -
Step 6: Commit
feat(lang42): proc.run parks — pidfd + epoll bundle + child registry(bullets: the deadlock repro and its dissolve; raw syscalls because glibc 2.35).
Task 4: the deadline leg
Files: Test: runtime/test/test_proc.c; fix (if red exposes drift):
runtime/src/sysio.c.
Interfaces: consumes Task 3's machinery unchanged; the leg drives the default arming path.
- Step 1 (red first if Task 3 left a gap): a child sleeping 10 s,
run with the deadline forced low for the test (drive the builtin with a
small
dlthe way the harness passes operands — until Task 9 the extended operands are reachable only from C, which is fine here). Assert: the trap message names the deadline and its value; then assert the pid is GONE —kill(pid, 0)returns ESRCH — measured, not assumed. - Step 2:
just wovm-testgreen (implement/adjust the kill path if Step 1 caught drift). Also add the fiber-progress assertion: while the sleeping child runs, a second fiber on the same VM increments a counter — assert it advanced before the deadline fired (the shard was never blocked). - Step 3: Commit
feat(lang42): deadline kills, parked shard keeps scheduling.
Task 5: the output-cap leg
Files: Test: runtime/test/test_proc.c; fix: runtime/src/sysio.c.
- Step 1: a child writing unbounded output against a small stdout
cap passed from the harness. Assert:
WO_T_IOwhose message names the cap and its value; pid gone (ESRCH); and — fd hygiene — the shard's open fd count returns to its pre-call value (count/proc/self/fdentries before and after). - Step 2: Same for the stderr cap.
- Step 3:
just wovm-testgreen. Commitfeat(lang42): output caps refuse by name, no truncation.
Task 6: the ceiling leg
Files: Test: runtime/test/test_proc.c.
- Step 1: 32 fibers each spawn a child sleeping 2 s; a 33rd spawn
must trap
WO_T_IOnaming the ceiling while the 32 keep running to completion unharmed. Then all 32 complete with code 0. - Step 2:
just wovm-testgreen (the slot-claim refusal exists since Task 3; this pins it). Commitfeat(lang42): per-shard ceiling fails closed at 32.
Task 7: the churn leg — fds flat over a thousand runs
Files: Test: runtime/test/test_proc.c.
- Step 1: loop one thousand sequential short-lived children
(the iteration 24 measurement style): record the
/proc/self/fdentry count before, assert the count after equals it, and assert no registry slot remains claimed. - Step 2:
just wovm-testgreen. Commitfeat(lang42): a thousand spawns leave the fd table flat.
Task 8: stop and unwind reap their children
Files: Test: runtime/test/test_proc.c; fix: runtime/src/sysio.c,
runtime/src/vm.c.
- Step 1: park a fiber on a child sleeping 10 s, set the stop flag
the way
test_fiber.cdoes, drive the loop; assertWO_SYS_STOPPEDsurfaced AND the child pid is gone. Second leg: a child owned by a fiber that is unwound (thetest_unwind.cpattern) is also gone. - Step 2:
just wovm-testgreen. Commitfeat(lang42): stop and unwind kill the children they own.
Task 9: proc.run_dl — the per-call bounds surface
Files:
- Modify:
runtime/src/wob.h(enum entryWO_B_PROC_RUN_DL = 96, comment stating the operand order and the nil-never contract: bounds violations trap, they do not nil) - Modify:
runtime/src/sysio.c(a thin case: parse deadline_ms, out_cap, err_cap operands, then fall into Task 3's core with those instead of the defaults) - Modify:
compiler/src/types.ml:315region (one row besideproc.run: moduleproc, memberrun_dl, arity 5, id 96, same nullable-Proc return and Proc record class as the existing row) - Test:
runtime/test/test_proc.c(drive id 96 with explicit bounds); compiler:just woc-testmust stay green — the generic builtin path needs noemit.mlchange (the net_dlfamily at ids 91–93 landed with table rows alone; verify by reading their absence fromemit.mlbefore assuming).
Interfaces:
-
Produces:
proc.run_dl(cmd, args, deadline_ms, out_cap, err_cap)in the language, the name the example app and every future consumer calls. -
Step 1 (red): runtime leg driving id 96 with a tight explicit deadline — fails while the case is absent.
-
Step 2: add the wob.h entry and the sysio.c case; green.
-
Step 3: add the types.ml row;
just woc-build && just woc-testgreen. -
Step 4: Commit
feat(lang42): proc.run_dl — deadline and caps at the call site.
Task 10: the example app and its gate
Files:
- Create:
docs/examples/subprocess/(wo.toml + one.wosource: an actor that servesproc.run/proc.run_dlresults — a fast command, a deliberate deadline hit caught with try/catch, a deliberate cap hit caught, and a long-running child for the drain leg; logs to/tmp/subprocess.logper the examples convention, announced and banner-separated) - Create:
scripts/subprocess-accept.sh(the gate) - Modify:
justfile(recipesubprocessrunning the gate, in theresidency/siterecipe mould)
Interfaces:
-
Consumes:
proc.runandproc.run_dlexactly as Task 9 shipped them. -
Step 1: write the app and gate with these legs: fast command returns its output through the actor; deadline violation is caught in
.woand reported (proves catchability from the language, not just from C); cap violation likewise; concurrency — a slow child runs while a second request is answered (wall-clock assertion that the second answer did not wait for the child); SIGTERM while asleep 30child lives — the gate records the child pid, stops the app, asserts the app exited cleanly AND the child pid is gone (iteration 40's battery gains its subprocess leg here). -
Step 2:
just subprocess— all legs green, printed assubprocess-accept: N checks, 0 failures. -
Step 3: Commit
feat(lang42): subprocess example + gate(app, script, recipe).
Task 11: close-out
Files:
-
Modify:
runtime/src/CODE-LOGIC.md(the proc.run section: parked lifecycle, the registry, the raw-syscall note, the deadlock that was) -
Modify:
docs/stories/language-runtime-database/42-bounded-subprocess.md(frontmatterstatus: done; Progress note of what landed vs the spec) -
Modify:
docs/stories/00-status.md(NEXT PLAN entry answering the six standup questions — implemented, key findings measured, learned, unblocked (the streaming form, tmux/alacritty/zen stages), next steps,.dev/referenceused: alacritty/tmux/zen-browser; flip the pending row) -
Modify:
docs/00-dependency-graph.md(node 42 class → done, same change as the board row per the maintenance rule) -
Step 1: run the full belt:
just wovm-test,just woc-test,just subprocess, plusjust siteuntouched-but-verified. All green, outputs quoted in the board entry, not asserted. -
Step 2: write CODE-LOGIC.md beside the code (project rule) and the three doc updates.
-
Step 3: Commit
docs(lang42): close out iteration 42.
Self-review (done at write time)
- Spec coverage: every acceptance criterion has a task — normal exit (2), deadlock repro-then-fix (3), deadline + progress (4), caps (5), ceiling (6), fd-flat thousand (7), stop/unwind (8), per-call bounds (9), drain leg + example gate (10). Out-of-scope items appear in no task.
- Placeholders: none; every step names its files, ids, messages and expected outcomes.
- Consistency: registry type
wo_child, bundle-epoll park, id 96, caps 30 000 ms / 1 MiB / 64 KiB / 32 used identically in tasks 3–10. - One verify-before-assuming flag left deliberately in Task 9: confirm the
net
_dlrows needed noemit.mlchange before mirroring them.