Iteration 24 closes, absorbing 31 and 34. No code in this commit. - stories 24, 31, 34 -> `status: done`, each with a landing banner. 24's records the gate numbers and BOTH disclosed deviations: monitor takes three arguments (the caller may be `main`, which has no mailbox) and a v1 `call` reply is a typed scalar (which is what let the agreement be checked at compile time, WO-E226). 31's notes it landed INSIDE 24 and that a fifth mechanism it never anticipated came out of proving the gate — the drain guarantee (40). 34's names the gap it did NOT close: still no RNG, so CSRF/sessions stay blocked - board: in-progress row cleared, marker doc deleted (convention), the standup entry in the six-question shape, chain note — next link is databasev2 4 (io_uring group-commit, chain 5) - graph: PUBSUB2 (pub/sub + WebSockets, "rejected until here") -> done - porch ledger: a WebSocket/pub-sub row added; the cancellation row now says what it actually waits on rather than repeating "the arc"; the README's "no WebSockets/SSE" limitation was stale — WebSockets are supported, SSE and chunked encoding are not - CODE-LOGIC: runtime/src gains the actor-lifecycle section (call, death, the cap counter's sender/home-thread split, the monitor walk, the timer list), the drain guarantee, and the digest section; docs/examples/chat gains its own — actor topology, WHY two actors per connection, fd ownership, and the shutdown choreography Battery after the doc edits: wovm-test 36 suites 0 fail, woc-test exit 0, oop-e2e 119/0, chat 11/0, web-app 46/0, linkcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4.3 KiB
docs/examples/chat — how the sample is put together
Iteration 24's acceptance workload: rooms, presence and broadcast over WebSocket, actors on fibers across shards, one binary, no broker. It exists to drive the actor work, so nearly every shape here is chosen to exercise something the runtime claims.
Gate: just chat (scripts/chat-accept.sh), which logs to /tmp/chat.log —
tail -F it while the gate runs.
The actors
| Actor | Owns | Answers |
|---|---|---|
Registry |
name → room map, a fallback room | a call returning the room's address; spawns rooms on demand |
Room |
its member list (writer address + name) | join, leave, a text line, shutdown |
Reader |
the read half of one connection | nothing — it loops on the fd and sends onward |
Writer |
the fd, and the write half | text, pong, close |
ConnWorker |
one accepted connection | runs the HTTP layer over that fd |
Registry is the first honest consumer of call: the handler runs on the
connection worker's shard, the registry lives wherever placement put it, and
the reply is a scalar — the room's address. That is the cross-shard call
proof the gate asserts, not a contrivance added for it.
Two actors per connection, not one
One fd, two directions, and they block independently. A single actor would have
to be inside read to notice the client, and inside write to deliver a
broadcast — it cannot be in both, so a broadcast would stall behind a quiet
client's read. Splitting them buys three things:
- The
Writeris the sole writer of that fd. Frames can never interleave, which for a framed protocol is a correctness property and not a nicety. - The
Readermay block as long as it likes. It sits inread_dlwith a 30 s idle deadline and nothing else is waiting on it. - The
Writer's mailbox becomes the backpressure point. A slow client stops draining its socket, itsWriterblocks inwrite_dl, its mailbox fills, and the room's next broadcast to it raises a catchableWO_T_ACTOR. The room catches that and drops the member. This is the whole reason the mailbox cap is fail-fast — the room survives its slowest member, and the gate'sWO_MAILBOX=8leg proves the path fires rather than assuming it.
Room.say is written around that: it shifts every member, tries the send, and
keeps only the members whose send succeeded — a failed one is sent a close and
dropped. So fan-out and eviction are the same pass.
Who owns the fd
The Writer. It closes it, in every branch: a failed write sets dead and
closes; a close message writes the close frame and closes. The Reader closes
the fd itself in exactly one case — when its send_close to the writer traps,
meaning the writer is unreachable and nobody else will. Without that the fd
would leak on a dead-writer path.
Writer.dead guards against a second close, which matters because two
independent paths can decide a connection is finished (the reader seeing EOF,
and the room broadcasting shutdown).
Shutdown choreography
On env.stopping() the accept loop stops and main sends one message to the
Registry, which fans out to every room; each room shifts its members and
sends each Writer a close; each writer writes the close frame and closes the
fd. main then spins — it may not park, because a park after the stop flag
unwinds — and returns, which is what stops the engine.
Independently, every Reader notices env.stopping() at its loop head and
runs its tail: leave the room, close the writer.
Both paths exist and that is deliberate: the reader path covers a connection whose room is already gone, the room path covers a reader parked in a read that has not come back yet.
This is where iteration 40 came from. The room path used to be unreliable:
a Room whose shard was idle at SIGTERM never adopted the shutdown message,
because an idle worker abandoned its inbox on stop. Clients that still got a
close frame were being saved by the reader path alone — which is why the
failure looked random and why a warmed-up server hid it. The engine now
guarantees that a send issued before the stop flag is delivered, so both paths
work as written. Nothing in this file changed to fix it, and that is the point:
the sample was right and the runtime was not.