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