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

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:

  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.