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>
82 lines
4.3 KiB
Markdown
82 lines
4.3 KiB
Markdown
# `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:
|
|
|
|
1. **The `Writer` is the sole writer of that fd.** Frames can never interleave,
|
|
which for a framed protocol is a correctness property and not a nicety.
|
|
2. **The `Reader` may block as long as it likes.** It sits in `read_dl` with a
|
|
30 s idle deadline and nothing else is waiting on it.
|
|
3. **The `Writer`'s mailbox becomes the backpressure point.** A slow client
|
|
stops draining its socket, its `Writer` blocks in `write_dl`, its mailbox
|
|
fills, and the room's next broadcast to it raises a catchable `WO_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's `WO_MAILBOX=8` leg 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.
|