writeonce/docs/superpowers/specs/2026-09-01-runtime-v2-design.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

7.3 KiB
Raw Permalink Blame History

runtime-v2 — the runtime beyond sockets, all five iterations settled

Brainstormed and approved 2026-09-01, one pass across the whole track: runtime-v2 iterations 1–5. Scope settled: PULL transport (a child is fds; the net verbs already drive them), wait_dl over death notices, PTY as a spawn variant, signals via signalfd delivering the signal number, termios as two fd-taking verbs with runtime-guaranteed restore, fd passing as two verbs plus net.connect_unix. This spec is track-wide; each iteration gets its own implementation plan when it starts.

The one principle

A child is fds plus a registry slot — runtime-v2 adds ACQUISITION verbs, never new transport. net.read_dl, net.write_dl and net.close already park on any fd through the shard plane (POLL_ADD is fd-agnostic; pipes and PTY masters qualify today). So:

  • Backpressure is the kernel pipe. An owner that stops reading makes the child block on write — exactly tmux under a slow client. The mailbox-cap collision never starts because nothing is pushed.
  • The idle deadline is read_dl's own per-call ms. Total-output caps disappear because no runtime buffer exists — the caller's read sizes ARE the bound.
  • A wmux pane actor is chat's Reader actor with a different fd.

The rejected alternative, recorded: PUSH (child output as mailbox messages) required new delivery machinery in vm.c, a policy for the wo_mailbox_cap collision (drop, kill, or park — each wrong somewhere), ordering rules across two streams, and per-chunk allocation. Everything pull gets from three landed iterations, push would rebuild.

The surface

New builtin ids from 97 up. Every id is FOUR registrations — wob.h enum, loader.c arity table, builtin.c dispatch range, types.ml stdlib row — the iteration 42 lesson; missing one reads as "unknown stdlib builtin" from a valid image. No .wob version bump (the 38/42 precedent). Child joins the predeclared records (types.ml stdlib_records): id, in, out, err — all Int-shaped; the fds are ordinary conn-like values every existing fd verb accepts.

Verb Contract
proc.spawn(cmd, args) -> ?Child iteration 1. Pipes on all three stdio fds, parent ends O_NONBLOCK; the registry slot is claimed (ceiling 32/shard, fails closed by name) and OWNED (below)
proc.wait_dl(id, ms) -> ?Int iteration 1. Parks on the slot's pidfd (the 42 machinery); the exit code, or nil at the deadline. ONE waiter per id — a second concurrent wait refuses by name
proc.signal(id, sig) iteration 1. pidfd_send_signal through the slot — no pid-reuse race
proc.spawn_pty(cmd, args, cols, rows) -> ?Child iteration 2. openpty; child on the slave as controlling terminal; in and out are both the master, err is nil; master raw by default, no knobs
proc.resize(id, cols, rows) iteration 2. TIOCSWINSZ on the slot's master; refusal by name on a pipe child
signal.on(sig, addr) iteration 3. Standing subscription; delivery is an ordinary send of the SIGNAL NUMBER as a scalar (scalars copy — no moved-message ownership rules). Offerable: SIGWINCH, SIGCHLD, SIGHUP, SIGUSR1, SIGUSR2. SIGTERM/SIGINT refused by name — the stop latch stays the engine's (iteration 40 is load-bearing). Kernel coalescing disclosed, not sequenced
term.raw(fd) / term.restore(fd) iteration 4. Save termios into the shard table, cfmakeraw, restore from the table. Restore is a RUNTIME OBLIGATION: fiber unwind, engine stop and wo_vm_destroy restore every saved tty, newest first. fd-taking from day one — a tty received via iteration 5 works
net.send_fd(conn, fd) -> Bool iteration 5. sendmsg + SCM_RIGHTS, exactly one fd, fixed ancillary buffer; refusal by name on a non-unix socket or a second fd
net.recv_fd(conn) -> ?Int iteration 5. The received fd as a plain Int; nil when the peer sent none
net.connect_unix(path) -> Int iteration 5 carries it — iteration 38 (net.connect) has not landed; verified pending, not assumed

Lifecycle — 42's doctrine, streaming edition

The wo_child slot grows: pid, pidfd, owner, and the fds the runtime must close if the owner never does. Owner is the spawning ACTOR when one exists, else the program. Actor death (iteration 24's dead mark), engine stop, and wo_vm_destroy each sweep: kill via pidfd, reap, close the slot's fds, restore any termios the shard saved. The per-shard ceiling stays 32; a zombie, an orphan, or a wrecked tty is a bug by definition. wait_dl parking reuses fb->dl_active/dl_at and parks on the pidfd directly — no epoll bundle needed for a single fd.

Mechanics notes (for the plans)

  • signalfd joins shard 0's plane as one more registered fd with a sentinel user_data, the wake-eventfd precedent in park.c; reads drain signalfd_siginfo records and fan out sends.
  • The termios table is per-shard (one thread — no locks), keyed by fd; double-raw on one fd refuses by name.
  • Raw syscalls wherever glibc 2.35 lacks wrappers (the 42 rule); signalfd and openpty are fine (openpty needs -lutil — check the Makefile link line before assuming).
  • Each iteration's gate legs: the 42 batteries repeat — fd-flat churn, stop/unwind reaping, refusals asserted verbatim — plus the iteration's own proof (PTY prompt bytes, SIGWINCH round trip, termios restore-after-trap, fd-across-socket byte echo).

Sequencing (the graph remap this settles)

Pull transport cuts the old 1→2→3 chain: signals never needed PTY, only the resize PAIRING. Edges now: 1 → 2 (spawn_pty extends spawn's plumbing); 3, 4, 5 startable alone, today; the VTE grid is wmux's own .wo work, also standalone (a replay corpus needs no subprocess). wmux 1 consumes all five plus the grid.

History — three amendments found at implementation (2026-09-02)

  1. Signal delivery is a record, not a scalar. Message payloads are unconditionally wo_drop_obj'd (vm.c — delivery, actor death, fiber reap), so a scalar payload is a crash by construction. signal.on(sig, addr) delivers a fresh predeclared Signal {sig} record per arrival; the class id rides the call as the appended record operand.
  2. A streaming slot does not own the caller's stdio fds — fd numbers get recycled, so a sweep closing them could close a stranger. The caller owns Child.stdin/stdout/stderr (released with net.close); the slot owns pid + pidfd and, for a PTY child, a private dup of the master so resize survives the caller closing its copy.
  3. No signalfd. The stop-latch pattern generalized instead: an async-signal-safe handler latches the number, bumps a sequence and pokes shard 0's wake eventfd; wo_io_wait's loop head drains latches into deliveries. Same observable contract, no mask plumbing, no fork-child mask restoration, EINTR itself is the wake.

Out of scope, by name

  • PUSH delivery of child output — rejected above, revisit only with a consumer pull cannot serve.
  • Death notices (proc.watch) — a two-line fiber composes wait_dl into an event; build it in .wo, not in C.
  • termios knobs beyond raw/restore; multi-fd SCM_RIGHTS arrays; sequence-numbered signals; PTY termios shaping — each waits for a named consumer.
  • SIGTERM/SIGINT user handling — permanently the engine's.