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