- stage 1 crypto (ids 85-87, RFC vectors), stage 2 lifecycle (cap + WO_T_ACTOR, call kinds 5/6, monitor, time.after — ids 88-90), stage 3 framework WS (ws_accept + hijack, wsframe codec), stage 4 chat sample + 5-check gate, stage 5 closeout - two spec deviations pre-disclosed: reply-type agreement rule (WO-E226 through actor-M erasure), monitor three-argument form - battery-with-builds-first constraint baked in (stale-binary lesson) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
Chat + actor lifecycle — iteration 24 (absorbing 31) design
Status: APPROVED 2026-08-23; plan ready (
../plans/2026-08-23-chat-ws-lifecycle.md). Iterations 24 (chat: WebSocket pub/sub workload) and 31 (actor lifecycle) ship as ONE iteration by developer directive 2026-08-23 — chat builds request/response, backpressure, death notices, and timers as it needs them; the recorded chain "31 → 24" collapses into "24". Story 34's fork is resolved here too (C builtins, full set). Stories: 24 · 31 · 34. Substrate: the landed 8+11 arc (spec); every decision below reuses its machinery rather than growing parallel machinery.
The decisions (developer, 2026-08-23)
- 24 absorbs 31. One iteration, one plan. The four lifecycle mechanisms are built to chat's need, not speculatively.
- Crypto (34): C builtins now, full set — SHA-1, SHA-256, HMAC-SHA256. Hand-rolled, libc-only, whole-value.
- Request/response:
call(addr, msg)parks, reply is typed by the receive method's return type. - Backpressure: fail the send — a full mailbox traps the sender, catchably. One policy, not configurable.
- Death: monitor only — one-way notice, delivered as an ordinary typed message the observer chose.
- Timers:
time.afterone-shot, no cancel — stale-timer handling is the generation-counter idiom in.wo, documented in the sample. - Serving model: WS-only actors. The blocking HTTP serve loop is untouched; only upgraded connections leave it. Keep-alive retirement for plain HTTP stays a future slice.
Part A — runtime: actor lifecycle
call / reply
call(addr, msg)issendthat waits: the message moves to the callee exactly assendmoves it, the caller's fiber parks onWO_PARK_INBOX(the DB-RPC park, unchanged), and the callee's reply value moves back and becomescall's result.- Two new envelope kinds (5 = call request, 6 = call reply) generalize the DB pair (3/4). The request envelope carries the caller's shard + fiber so the reply routes home; delivery, adoption, and the once-per-slice inbox drain are the arc's existing code paths.
- Typing: the reply type is the receive method's declared return
type.
receive(msg: M) -> Rmakescallon that actor produceR; areceivewith no return type makescalla compile error (WO-E226).sendto a returning receive stays legal and discards the result. Ownership: the request moves (existing transfer machinery), the reply moves back — a reply that is a traced-containing type is rejected at compile time exactly as WO-E222 rejects such sends. - Death integration (no hangs, ever):
callto an already-dead actor traps immediately; a callee dying mid-call (trap while the caller is parked) unparks the caller into the same trap. Both are WO_T_ACTOR (below), catchable.
Bounded mailboxes
- Every actor mailbox has one fixed cap: 1024 messages,
WO_MAILBOXenv override (soak tests shrink it to force the policy). The arc's stage-2 deviation 4 (mutex-guarded unbounded list) gains a length check — no ring rewrite in this iteration. - A
sendorcallarriving at a full mailbox traps the SENDER with WO_T_ACTOR (trap kind 13), catchable via the existing try/catch. The message is not enqueued; the sender's value is not consumed (the trap unwinds before the move completes, same as any trapping builtin). Nothing is silently dropped: the sender always learns.
Monitor
monitor(addr, msg)— the observer supplies an M-typed message (its OWN mailbox type); when the watched actor dies, the runtime delivers that message to the observer like any send. No new message types, no untyped mailbox hole.- Death v1 = trap-death only (a fiber trap unwinding out of an actor's receive). Normal program teardown reaps actors without firing monitors — shutdown is not death. Multiple monitors on one actor all fire; monitoring an already-dead actor fires immediately; the monitor message is subject to the mailbox cap like any send — but the "sender" here is the runtime mid-unwind, so a full observer's notice is DROPPED with a stderr diagnostic naming both actors (disclosed, not hidden; there is no sensible fiber to trap).
- Supervision (respawn) is
.wocode the sample demonstrates only if chat needs it; no runtime restart policy.
Timers
time.after(ms, addr, msg)— one-shot: aftermsmilliseconds the runtime deliversmsgtoaddras an ordinary send, riding the shard I/O plane's existing timeout arm (arc T4). No cancel builtin; the documented idiom is a generation counter in the actor's state — a stale timer message that arrives after its purpose passed is recognized and ignored by the receive code.- The armed timer holds the moved message; if the target dies before expiry the delivery is a send-to-dead (existing silent-drop rule).
Lowering + format
- All four surfaces lower to BUILTIN ids (like spawn/send): no new
opcodes. New ids from 85 up: call, monitor, time.after (and Part B's
three digests).
WO_B_MAXmoves;.wobversion stays (the ticks-84 precedent: pure id additions do not bump — an old runtime rejects a new image on the id range check, which is the honest failure).
Part B — runtime: crypto builtins (story 34 resolved)
- Three builtins over Bytes:
crypto.sha1(bytes),crypto.sha256(bytes),crypto.hmac_sha256(key, msg), each returning fresh Bytes. Hand-rolled C in the runtime (libc-only doctrine permits it; the code is bounded and well-specified), whole-value like every existing builtin — no streaming interface. - Unit gate carries the RFC test vectors (SHA-1: RFC 3174; SHA-256: FIPS 180-4 vectors; HMAC: RFC 4231) plus empty-input and block-boundary lengths.
- SHA-1 exists for the WebSocket handshake (its hard requirement); SHA-256/HMAC land in the same slice because the implementation shares its skeleton and the ledger's ETag/signed-token rows are waiting consumers. No other primitives (no MD5, no SHA-512, no AES) — YAGNI until a consumer names them.
Part C — framework: WebSocket in pure .wo
Upgrade (handler-owned — no generic spawn seam)
spawntakes a class literal, so the framework cannot spawn an app-defined connection actor. Inversion: the APP's route handler owns the upgrade. The framework provides, inhttp/ws.wo:- an upgrade validator (Upgrade/Connection/Sec-WebSocket-Key/version headers, RFC 6455 §4.2.1),
- the accept-key computation — base64 (builtin 80) of SHA-1 (Part B) of key + the RFC GUID,
- a
ws_accept-shaped function that writes the 101 response on the connection and returns the connection fd (Int) for the handler to move into ITS actors, - and a hijack sentinel: the serve loop, seeing it, neither serializes a response nor closes the fd — it forgets the connection and returns to accept. Non-upgrade requests are byte-identical to today (web-app gates prove it).
- The Req type grows the internal connection handle to make this
possible; it is not part of the public surface beyond
ws_accept.
Frame codec (pure .wo, Bytes, iteration-36 bitwise)
- Parse and serialize: text, close, ping, pong; binary accepted and echoed only. Client-to-server masking (XOR over the payload with the 32-bit key) uses the landed bitwise operators. Fragmentation: v1 rejects fragmented messages with a close frame (documented limit); control frames interleaved between data frames are handled per RFC. Payload lengths: 7-bit and 16-bit accepted; 64-bit lengths answered with close (BODY_MAX-scale bound, same doctrine as HTTP).
- The codec is a pure function library over Bytes — no fd, no actor — so its tests are plain corpus-style probes.
Two actors per connection
- One actor cannot both block in
net.readand hear room broadcasts (one message at a time is the actor contract). So: a READER actor — the fd's only reader; parses frames, forwards inbound text to the room, answers ping with pong via the writer, sees close/EOF and tells room (leave) and writer (close) — and a WRITER actor — the fd's only writer; receives broadcast/system/close messages and serializes frames. Single-writer discipline means no interleaved partial frames; single-reader means no torn parses. Both are app-side classes (the chat sample's), placed round-robin across shards by the existing spawn — connection load spreads without any placement surface.
Part D — the chat sample (docs/examples/chat)
- Actors: a REGISTRY (name → room address; conn actors
callit — the first realcallconsumer) and a ROOM per name (member list = writer-actor addresses — plain scalars; join/leave/broadcast; presence lines on join/leave). Message history: none (out of scope). - HTTP surface:
/answers JSON usage (JSON-first doctrine, no templates);/wsupgrades; everything served by the framework through[deps]exactly as web-app. - Shutdown: SIGTERM → serve loop stops accepting; rooms broadcast a close; writers send close frames; readers see EOF; every fiber unwinds (ASan zero leaks) and the process exits 0.
- The gate (
just chat, scripts/chat-accept.sh + a python client speaking raw RFC 6455 over stdlib sockets — no dependencies):- functional: two clients, different shards (TID-asserted), one room — a send reaches the other; a third client in another room receives nothing.
- handshake vectors: the RFC 6455 example key round-trips.
- soak: 1k concurrent clients, one hot room, every room progresses (reduction budget proof), RSS bounded (mailbox cap engaged, the WO_MAILBOX override shrinks it to force the trap path).
- drain: SIGTERM with clients connected — close frames observed, exit 0, ASan-clean run repeated under both WO_IO backends.
- the standing battery stays green (HTTP and WS share serve.wo honestly).
Diagnostics (new)
- WO-E226 —
callon an actor whose receive declares no return type (orcall's result used as the wrong type — existing param machinery). - WO_T_ACTOR (trap 13) — mailbox full (sender-side), call-to-dead, callee-died-mid-call. One trap kind, message names which.
- Monitor/after argument shapes ride existing arity/type checking.
Out of scope (restated from the stories)
Message persistence/history; auth beyond bearer; permessage-deflate; fragmented-message assembly; wss (TLS at the proxy — pass-through documented in the sample README); supervision trees / restart policy; priorities; timer cancel; cross-process anything; retiring the HTTP close-when-idle policy (its own slice); mailbox ring rewrite (22's mutex number stands until 23-scale work).
Success criteria
- The chat gate's five checks green at default cores AND
WO_SHARDS=1. - Crypto vectors green; handshake interoperable with a stock client (the python client IS one — it computes the accept key independently).
- Lifecycle proofs: a
callround-trip parks (TID same-shard check), a full mailbox traps the sender catchably, a monitored actor's death delivers the chosen message, a timer fires as a message — each pinned by a corpus fixture or the sample gate. - The full standing battery green; the language grew NOTHING —
call/monitor/time.afterare builtins, not keywords.