- 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)
119 lines
7.3 KiB
Markdown
119 lines
7.3 KiB
Markdown
# 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.
|