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

119 lines
7.3 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 — the runtime beyond sockets, all five iterations settled
> Brainstormed and approved 2026-09-01, one pass across the whole track:
> [runtime-v2](../../stories/runtime-v2/00-story.md) 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.