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

12 KiB
Raw Blame History

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.