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

320 lines
16 KiB
Markdown
Raw Permalink 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.

# 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.