writeonce/docs/examples/chat/CODE-LOGIC.md
shoney.arickathil 62d29d6a77 docs(24): T10 closeout — stories done, board, graph, ledger, CODE-LOGIC
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>
2026-08-28 00:06:31 +02:00

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.