writeonce/docs/superpowers/plans/2026-09-01-runtime-v2.md
shoney.arickathil 6e997759e5 docs(rt2): close out runtime-v2 1-5
- five stories status: done; 00-story records the one-run landing
- spec History: three implementation amendments (Signal record not
  scalar, caller-owned stdio fds, handler-latch instead of signalfd)
- board NEXT PLAN entry with measured findings (zero transport code
  added; the tty-across-the-socket handover proven; the double-raw
  refusal restoring the terminal — the "bug" that was the design
  working); section rows flipped; graph nodes green
- CODE-LOGIC.md: the runtime-v2 section
- full belt quoted on the board: suites 0 fail both flavors (test_proc
  193/0, test_term 60/0), woc 557/0, subprocess 12/0, site 23/0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit bc1b4f070693eb755ad6a9fd0c853fb3e2bda347)
2026-09-15 01:15:30 +02:00

235 lines
12 KiB
Markdown
Raw 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.

# runtime-v2 (iterations 1–5) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> 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 code blocks. Steps name exact
> functions, ids, fields and expected outcomes; the implementer writes
> the code at the keyboard against the cited anchors.
**Goal:** land all five runtime-v2 iterations — streaming subprocess,
PTY, signals-as-events, termios adoption, fd passing — as builtins on the
existing park plane, per the track spec.
**Architecture:** acquisition verbs only, never transport: children and
received fds are ordinary fds the existing `net.read_dl`/`write_dl`/
`close` drive. The `wo_child` registry grows a streaming variant; a
per-shard termios table and shard-0 signalfd are the only new state.
**Tech Stack:** C (runtime), OCaml table rows (compiler), `.wo` +
bash (gate legs).
**Spec:** `docs/superpowers/specs/2026-09-01-runtime-v2-design.md`.
## Global Constraints
- Ids 97–107, in this order: 97 `PROC_SPAWN`, 98 `PROC_WAIT_DL`,
99 `PROC_SIGNAL`, 100 `PROC_SPAWN_PTY`, 101 `PROC_RESIZE`,
102 `SIGNAL_ON`, 103 `TERM_RAW`, 104 `TERM_RESTORE`, 105 `NET_SEND_FD`,
106 `NET_RECV_FD`, 107 `NET_CONNECT_UNIX`. `WO_B_MAX` → 107. No `.wob`
bump.
- Every id = FOUR registrations: `wob.h` enum, `loader.c` `b_arity`,
`builtin.c` dispatch range (extend the `<= WO_B_PROC_RUN_DL` bound to
`<= WO_B_NET_CONNECT_UNIX`), `types.ml` row. The 42 lesson.
- **Spec amendment 1 (verified 2026-09-01):** message payloads are
unconditionally `wo_drop_obj`'d (`vm.c:887`, `actor_die`,
`actor_activate`) — a scalar payload is a crash. `signal.on` therefore
delivers a fresh predeclared `Signal {sig Int}` record per delivery,
class id appended by the compiler (loader arity 3). Record the
amendment in the spec's History when closing.
- **Spec amendment 2:** a streaming slot does NOT own the caller-visible
stdio fds (fd-reuse hazard: the sweep could close a recycled number).
The caller owns Child.in/out/err and closes them with `net.close`; the
slot owns pid + pidfd + (PTY only) a private `dup` of the master for
resize. Sweep = kill, reap, close pidfd (+ master dup).
- PTY via `posix_openpt`/`grantpt`/`unlockpt`/`ptsname_r` — plain libc,
NO `-lutil`, no Makefile change.
- Raw syscalls where glibc 2.35 lacks wrappers (pidfd already handled);
`signalfd` has a wrapper since 2.8 — use it.
- SIGTERM/SIGINT registration refused by name; the stop latch stays the
engine's.
- All work on `dev`, commits prefixed `feat(rt2):`/`docs(rt2):`; builds
and tests only via just recipes; ASan+UBSan clean is a gate.
- Suite home: `runtime/test/test_proc.c` grows spawn/wait/pty legs; new
`runtime/test/test_term.c` for termios + signals + fd-passing (auto-
globbed).
---
### Task 1: streaming spawn — `proc.spawn`, `proc.wait_dl`, `proc.signal` (rt2 iteration 1)
**Files:**
- Modify: `runtime/src/wob.h` (ids 97–99), `runtime/src/loader.c`
(arities: spawn 3 — cmd, argv, cls; wait_dl 2; signal 2),
`runtime/src/builtin.c` (dispatch bound), `runtime/src/sysio.c`
(three cases + slot changes), `runtime/src/vm.h` (`wo_child` gains
`streaming`, `waiter`, `master_dup` fields), `runtime/src/vm.c`
(`actor_die` sweeps children owned by the dying actor),
`compiler/src/types.ml` (`Child` record — id/in/out/err, all Int —
in `stdlib_records`; rows for the three verbs).
- Test: `runtime/test/test_proc.c`.
**Interfaces:**
- Produces: `proc.spawn(cmd, args) -> ?Child`, `proc.wait_dl(id, ms) ->
?Int`, `proc.signal(id, sig) -> 0`; slot ownership field
`owner_actor` (a `wo_actor*`, NULL = program) that Tasks 2–5 reuse.
- [ ] **Step 1 (red):** legs in `test_proc.c` driving the new ids from
bytecode: (a) spawn `cat`, `net.write_dl` a line to `Child.in`, read
it back from `Child.out` via `net.read_dl` — the echo round trip;
(b) `wait_dl` on a fast child answers its code, on a `sleep 10` with
ms=100 answers nil and the child is STILL alive (then `proc.signal`
SIGKILL, wait again, code observed, ECHILD after vm destroy);
(c) second concurrent `wait_dl` on one id refuses by name (two
fibers); (d) spawn-loop fd-flat leg with the caller closing all three
fds each round. Run `just wovm-test` — all four fail (unknown builtin).
- [ ] **Step 2 (green):** implement. Spawn: three pipes (stdin write end
stays parent-side as Child.in), parent ends `O_NONBLOCK`, fork/execvp
(argv rules identical to 42's), pidfd, claim slot (`streaming = 1`,
no epoll bundle, no buffers), owner = `vm->cur->actor` (NULL when
none), build the Child record via `record_of` (4 fields). `wait_dl`:
slot lookup by id (id = slot index + a generation counter to refuse a
stale id by name), waiter-claim refusal, park on the pidfd with
`dl_active`/`dl_at`, on exit reap + release slot + answer code, nil at
deadline (child untouched). `signal`: `pidfd_send_signal` through the
slot. `actor_die` calls a new `wo_proc_abandon_actor(vm, a)` killing
every slot whose owner is `a`. Sweeps close pidfd only (amendment 2).
- [ ] **Step 3:** `just wovm-test` green both flavors; commit
`feat(rt2): proc.spawn/wait_dl/signal — the streaming child` with the
compiler row in the same commit (`just woc-build && just woc-test`
first).
### Task 2: PTY — `proc.spawn_pty`, `proc.resize` (rt2 iteration 2)
**Files:** same four registration files (ids 100–101; spawn_pty arity 5
— cmd, argv, cols, rows, cls; resize 3), `runtime/src/sysio.c`,
`runtime/test/test_proc.c`.
**Interfaces:**
- Consumes: Task 1's slot, Child record, ownership.
- Produces: `proc.spawn_pty(cmd, args, cols, rows) -> ?Child` (in==out=
master, err nil), `proc.resize(id, cols, rows) -> 0`.
- [ ] **Step 1 (red):** legs: (a) spawn_pty `sh -c 'test -t 0 && echo
yes-tty'` — read "yes-tty" back (isatty proof); (b) spawn_pty with
24x80 then a child running `stty size` — read "24 80"; resize to
40x120, re-ask via a second child? No — one child that sleeps then
prints size after a marker write; simpler: child = `sh -c 'read x;
stty size'` — resize between spawn and the marker write, expect
"40 120"; (c) resize on a Task-1 pipe child refuses by name. Red run.
- [ ] **Step 2 (green):** `posix_openpt(O_RDWR|O_NOCTTY)`, `grantpt`,
`unlockpt`, `ptsname_r`; child: `setsid`, open slave (becomes
controlling tty), dup2 onto 0/1/2, `TIOCSWINSZ` initial size, exec.
Parent: master `O_NONBLOCK`, slot stores a private `dup` of the master
(`master_dup`) for resize; Child.in == Child.out == master, err = 0
(nil). Resize: `ioctl(master_dup, TIOCSWINSZ)` + refusal by name when
the slot is not a PTY child. Sweep closes `master_dup`.
- [ ] **Step 3:** suites green; commit `feat(rt2): spawn_pty + resize —
a child that believes it owns a terminal`.
### Task 3: signals as events — `signal.on` (rt2 iteration 3)
**Files:** registrations (id 102, arity 3 — sig, addr, cls),
`runtime/src/sysio.c` or `builtin.c` for the case, `runtime/src/park.c`
(signalfd on shard 0's plane beside the wake eventfd, sentinel
user_data), `runtime/src/vm.h` (per-vm subscription list {sig, actor}),
`compiler/src/types.ml` (`Signal {sig Int}` record + row),
`runtime/test/test_term.c` (new).
**Interfaces:**
- Consumes: `runtime_notify` (`vm.c:1326`, the timer delivery path) for
handing a fresh payload to an actor.
- Produces: `signal.on(sig, addr) -> 0`; delivery = fresh `Signal{sig}`
record per arrival (spec amendment 1).
- [ ] **Step 1 (red):** test_term.c: module with an actor whose receive
pushes `msg.sig` into a shared multi; main registers
`signal.on(SIGUSR1, addr)`, then `proc.run("sh", ["-c", "kill -USR1
$PPID"])`, then sleeps briefly; assert the multi holds SIGUSR1's
number. Second leg: `signal.on(SIGTERM, …)` refuses naming the stop
latch. Red.
- [ ] **Step 2 (green):** first registration on shard 0 creates the
signalfd (mask grows per registration; `pthread_sigmask` blocks the
sig process-wide first — document: registration must happen before
worker shards spawn or the mask is per-thread incomplete; v1 rule:
register from shard 0/main, refusal by name elsewhere). park.c: the
signalfd is registered like the wake eventfd (oneshot POLL_ADD under
uring, level under epoll) with its own sentinel; on readiness drain
`signalfd_siginfo` records, for each match allocate `Signal{sig}` via
`wo_obj_new` and `runtime_notify` the subscribed actor(s).
- [ ] **Step 3:** suites green (both WO_IO backends — the fibers gate
pattern proves uring AND epoll); commit `feat(rt2): signal.on —
signalfd delivers Signal records to actors`.
### Task 4: termios — `term.raw`, `term.restore` (rt2 iteration 4)
**Files:** registrations (ids 103–104, arity 1 each),
`runtime/src/sysio.c` (cases + the per-shard saved-termios table in
`wo_vm` — 8 entries {fd, termios, owner fiber}), `runtime/src/vm.c`
(restore sweep in `fib_reap` and `wo_vm_destroy` beside the proc
sweeps), `runtime/test/test_term.c`.
- [ ] **Step 1 (red):** legs using a PTY pair made in the TEST via
`posix_openpt` (C-side, no builtin): (a) `term.raw(slave_fd)` then
`tcgetattr` shows ECHO/ICANON cleared; `term.restore(slave_fd)`
brings the saved flags back bit-identically; (b) double-raw refuses by
name; (c) raw then DELIBERATE trap in the fiber — after the trap the
fd's termios are restored (the runtime obligation); (d) restore on an
fd never raw'd refuses by name. Red.
- [ ] **Step 2 (green):** table claim (full table refuses by name),
`tcgetattr` save, `cfmakeraw`, `tcsetattr`; restore verb frees the
entry; `fib_reap` and `wo_vm_destroy` restore entries owned by the
dying fiber / all, newest first.
- [ ] **Step 3:** suites green; commit `feat(rt2): term.raw/restore —
no wrecked tty, ever`.
### Task 5: fd passing — `net.send_fd`, `net.recv_fd`, `net.connect_unix` (rt2 iteration 5)
**Files:** registrations (ids 105–107; arities 2/1/1),
`runtime/src/sysio.c`, `runtime/test/test_term.c`.
- [ ] **Step 1 (red):** legs: (a) `net.listen_unix` + `net.connect_unix`
pair inside one vm (two fibers: acceptor and connector); (b) create a
pipe in C, `send_fd` its read end across the socket, `recv_fd` it,
write into the pipe's write end, `net.read_dl` from the RECEIVED fd
answers the bytes; (c) `send_fd` on a TCP socket refuses by name;
(d) `recv_fd` when the peer sent plain bytes answers nil; (e) a tty
fd (test PTY slave) crosses and `term.raw` works on it — the wmux
handover in miniature. Red.
- [ ] **Step 2 (green):** `connect_unix`: socket AF_UNIX, connect,
`O_NONBLOCK` after. `send_fd`: `SO_DOMAIN` check (refusal), `sendmsg`
with one `SCM_RIGHTS` fd in a fixed `CMSG_SPACE(sizeof(int))` buffer
and one sentinel data byte; EAGAIN parks (POLLOUT, the write mould).
`recv_fd`: `recvmsg` with the same buffer; EAGAIN parks (POLLIN);
a message without ancillary fd answers nil; received fd set
`O_NONBLOCK`.
- [ ] **Step 3:** suites green; commit `feat(rt2): send_fd/recv_fd/
connect_unix — an fd crosses the socket`.
### Task 6: close-out
**Files:** `runtime/src/CODE-LOGIC.md` (runtime-v2 section),
`docs/superpowers/specs/2026-09-01-runtime-v2-design.md` (History —
the two amendments), the five story files (status: done + Progress),
`docs/stories/00-status.md` (NEXT PLAN entry, section rows ✅),
`docs/00-dependency-graph.md` (nodes → done class).
- [ ] **Step 1:** full belt: `just wovm-test`, `just woc-test`,
`just subprocess`, `just site` — quote results, never assert.
- [ ] **Step 2:** write the docs; commit `docs(rt2): close out
runtime-v2 1–5`.
---
## Self-review (at write time)
- Spec coverage: every surface row has a task; both amendments carried
into Tasks 1 and 3 and recorded for the spec's History in Task 6.
Out-of-scope items appear in no task.
- Ids consistent 97–107 across tasks; `Child`/`Signal` records named
identically throughout.
- Deliberate verify-first flags: the shard-0-only registration rule for
signals (mask is per-thread — confirm where worker threads inherit
the mask), and `runtime_notify`'s exact signature before reuse.