writeonce/docs/superpowers/specs/2026-08-23-chat-websocket-actor-lifecycle-design.md
shoney.arickathil c0b0dbb846 docs: audit all markdown against the code, fix findings, flatten status folders
- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet
  available"; "no package manager" contradicted [deps]; the deps example
  would not have compiled (the key IS the module name)
- runtime/README: leads with wovm, wo-rt.c demoted to a historical section;
  dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18
- employee + log-watcher READMEs claimed "does not compile"; both are gates
- error catalog: +10 emitted codes incl WO-E250, the only diagnostic the
  shipped query surface raises; recorded why the sweep rotted
- language-surface: group-by parses, then the typechecker refuses it
- 00-code-review + 00-link-audit re-run; history kept, not rewritten
- 48 dead Rust-era exploration links de-linked rather than re-pointed (their
  prose names the retired plan by number); successor map -> discarded.md
- 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty
- releasing.md: dropped a --draft step the workflow never had
- new docs/00-doc-audit.md: findings + disposition, incl one row where the
  audit was wrong and the doc it accused was right
- status folders removed: 34 stories flat, status only in frontmatter; 252
  links recomputed from resolved paths; board/board-views/structure retaught
- story 24 -> in-progress, since frontmatter is now the only truth
- new iteration 38: fs mutation verbs + net.connect, the two capability
  families no iteration owned
- new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called
  CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no
  source of randomness at all
- linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:20:22 +02:00

226 lines
12 KiB
Markdown

# Chat + actor lifecycle — iteration 24 (absorbing 31) design
> **Status: APPROVED 2026-08-23; plan ready**
> ([`../plans/2026-08-23-chat-ws-lifecycle.md`](../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](../../stories/language-runtime-database/24-chat-websocket-workload.md) ·
> [31](../../stories/language-runtime-database/31-actor-lifecycle.md) ·
> [34](../../stories/language-runtime-database/34-crypto-builtins.md).
> Substrate: the landed 8+11 arc
> ([spec](2026-08-20-shard-fiber-arc-design.md)); every decision below
> reuses its machinery rather than growing parallel machinery.
## The decisions (developer, 2026-08-23)
1. **24 absorbs 31.** One iteration, one plan. The four lifecycle
mechanisms are built to chat's need, not speculatively.
2. **Crypto (34): C builtins now, full set** — SHA-1, SHA-256,
HMAC-SHA256. Hand-rolled, libc-only, whole-value.
3. **Request/response: `call(addr, msg)` parks, reply is typed** by the
receive method's return type.
4. **Backpressure: fail the send** — a full mailbox traps the sender,
catchably. One policy, not configurable.
5. **Death: monitor only** — one-way notice, delivered as an ordinary
typed message the observer chose.
6. **Timers: `time.after` one-shot, no cancel** — stale-timer handling
is the generation-counter idiom in `.wo`, documented in the sample.
7. **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)` is `send` that waits: the message moves to the
callee exactly as `send` moves it, the caller's fiber parks on
`WO_PARK_INBOX` (the DB-RPC park, unchanged), and the callee's reply
value moves back and becomes `call`'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) -> R` makes `call` on that actor produce `R`;
a `receive` with no return type makes `call` a compile error
(WO-E226). `send` to 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):** `call` to 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_MAILBOX`
env 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 `send` or `call` arriving 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 `.wo` code the sample demonstrates only if
chat needs it; no runtime restart policy.
### Timers
- `time.after(ms, addr, msg)` — one-shot: after `ms` milliseconds the
runtime delivers `msg` to `addr` as 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_MAX` moves; `.wob` version 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)
- `spawn` takes 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, in `http/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.read` and 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 `call` it — the
first real `call` consumer) 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); `/ws` upgrades; 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):
1. functional: two clients, different shards (TID-asserted), one
room — a send reaches the other; a third client in another room
receives nothing.
2. handshake vectors: the RFC 6455 example key round-trips.
3. 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).
4. drain: SIGTERM with clients connected — close frames observed,
exit 0, ASan-clean run repeated under both WO_IO backends.
5. the standing battery stays green (HTTP and WS share serve.wo
honestly).
## Diagnostics (new)
- **WO-E226** — `call` on an actor whose receive declares no return
type (or `call`'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
1. The chat gate's five checks green at default cores AND `WO_SHARDS=1`.
2. Crypto vectors green; handshake interoperable with a stock client
(the python client IS one — it computes the accept key
independently).
3. Lifecycle proofs: a `call` round-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.
4. The full standing battery green; the language grew NOTHING —
`call`/`monitor`/`time.after` are builtins, not keywords.