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