writeonce/docs/superpowers/plans/2026-09-01-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

16 KiB
Raw Permalink Blame History

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_signal wrappers (they arrived in 2.36) — call both via raw syscall(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_IO with 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 .wob version bump — WOB_VERSION does not move.
  • No new dependencies, no new threads, no signal handlers.
  • All work on dev; every commit title prefixed feat(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_*.c automatically) — 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.md row, docs/00-dependency-graph.md node, the three exploration studies under docs/plan/exploration/{alacritty,tmux,zen-browser}/, this plan.

Interfaces: none — bookkeeping.

  • Step 1: Add a lang42 row to the prefix registry table in docs/00-git-commit-history.md: feature "iteration 42 — bounded subprocess (proc.run bounded + parked, proc.run_dl)", status "on dev".
  • Step 2: Commit all listed docs as one docs(lang42): story, spec and plan for bounded subprocess commit (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 from test_builtin.c, and direct builtin dispatch the way test_builtin.c invokes cases — operands in registers, WO_B_PROC_RUN (id 56) takes cmd Text, multi Text args, and the Proc class id, returns the record {code, out, err}.

  • Produces: test_proc.c as 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 (the WO_B_PROC_RUN case, currently ~lines 723–815)
  • Modify: runtime/src/vm.h (the per-shard registry in wo_vm, one pointer field on wo_fiber for 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_PARKED re-entry convention (sysio.c:510 READ_DL is the model: fill fb->park_fd, arm fb->dl_active/fb->dl_at once — guarded so re-entry does not re-arm — return WO_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_vm gains a fixed array of 32 wo_child slots plus a live count; wo_fiber gains a pointer to its in-flight entry (NULL when none). Task 4–8 legs and Task 9's WO_B_PROC_RUN_DL all reuse exactly this machinery.

  • Step 1 (the red test): In test_proc.c, add the deadlock leg: a child (use sh -c in 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_RUN case: pipes opened O_NONBLOCK on 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 with WO_T_IO naming 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, trap WO_T_IO naming the cap and value. Pidfd readable means exited: reap via waitpid (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), return WO_SYS_PARKED. Deadline re-entry with dl_at passed: kill, reap, release, trap WO_T_IO naming the deadline. stop_pending() on any entry: kill, reap, release, return WO_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 dl the 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-test green (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_IO whose 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/fd entries before and after).
  • Step 2: Same for the stderr cap.
  • Step 3: just wovm-test green. Commit feat(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_IO naming the ceiling while the 32 keep running to completion unharmed. Then all 32 complete with code 0.
  • Step 2: just wovm-test green (the slot-claim refusal exists since Task 3; this pins it). Commit feat(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/fd entry count before, assert the count after equals it, and assert no registry slot remains claimed.
  • Step 2: just wovm-test green. Commit feat(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.c does, drive the loop; assert WO_SYS_STOPPED surfaced AND the child pid is gone. Second leg: a child owned by a fiber that is unwound (the test_unwind.c pattern) is also gone.
  • Step 2: just wovm-test green. Commit feat(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 entry WO_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:315 region (one row beside proc.run: module proc, member run_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-test must stay green — the generic builtin path needs no emit.ml change (the net _dl family at ids 91–93 landed with table rows alone; verify by reading their absence from emit.ml before 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-test green.

  • 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 .wo source: an actor that serves proc.run/proc.run_dl results — 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.log per the examples convention, announced and banner-separated)
  • Create: scripts/subprocess-accept.sh (the gate)
  • Modify: justfile (recipe subprocess running the gate, in the residency/site recipe mould)

Interfaces:

  • Consumes: proc.run and proc.run_dl exactly 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 .wo and 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 a sleep 30 child 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 as subprocess-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 (frontmatter status: 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/reference used: 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, plus just site untouched-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 _dl rows needed no emit.ml change before mirroring them.