feat(ws+pubsub): on_open hook + ws_chat — v0.5 chunk 2

WsHandler::on_open(&mut self, sender) — defaulted, non-breaking,
added mid-chunk for veto-by-diff (the v0.3 "push" precedent for
defaults-level decisions): without it a listen-only ws client can
never be subscribed, since on_message never fires for a client that
doesn't send. Runs exactly once, first, in the conn actor, before any
buffered pipelined frame is decoded. Panic contract identical to
on_message: check_cancelled re-raise dance, else 1011 and no
on_close.

DISCOVERY 1 (documented, not fixed — inherent): the 101 reaches the
client BEFORE on_open runs, so "is my subscription live yet" is a
race client-side. App-level ack is the answer (any reply proves
on_open completed; callbacks are sequential). The integration tests
hit this immediately (first read of a join notice flaked into a 5s
read-timeout) and use a sync/synced ack; same reason Phoenix joins
reply.

DISCOVERY 2 (the structural one): PubSub::new() is in-runtime only,
but pipelines are built pre-runtime. crud's static-OnceLock bootstrap
DOES NOT COMPOSE with serve_with_shutdown here: a static pins the
table actor's last ServerRef forever, its inbox never closes, the
gen_server never exits, AllDone is unreachable — serve hangs (the
cross-thread-unpark limitation closes the workaround routes). The
pattern that works: NON-static Arc<OnceLock<PubSub<M>>> captured by
the route closure. Drain stops conns+listeners -> last Arc<Pipeline>
drops IN-runtime -> cell+handle drop -> inbox closes -> table exits.
Corollary: relays hold the Receiver only, NEVER a PubSub clone
(relay holds table's inbox open, table holds relay's receiver open:
mutual keepalive, shutdown hangs). Both rules in module docs, README,
and enforced by the new shutdown_with_open_chat_terminates test:
two open chat sockets + live relays + live table, shutdown must
return within 5s.

examples/ws_chat.rs: rooms as topics (room:{name}), on_open
subscribes (conn-actor pid: the monitor scopes cleanup to the
session) + spawns the relay (rx -> WsSender clone; exits on prune or
WsClosed), on_message broadcast_from(self_pid()) so the speaker is
never echoed, on_close broadcasts the leave notice and relies on the
monitor for unsubscribe.

Integration: ws_chat_broadcast_reaches_other_client_not_sender
(no-echo proven orderingly, no timeout reads: B speaks after A, A's
next frame must be B's) + shutdown_with_open_chat_terminates.

hammer.sh default subset now includes ws_ (v0.4 ran it ad hoc; ws IS
conn-lifecycle). Hammered: 35x subset (incl. both new tests) + 3x
full + 1x full under smarm-trace, all green. dbg!-grep clean. smarm
PRISTINE. README pub/sub + on_open sections; ROADMAP v0.5 as-landed.

Suite: 67 unit + 42 integration + 2 doc.
This commit is contained in:
Claude
2026-06-12 10:30:08 +00:00
parent 341d20ab45
commit ad19848db3
7 changed files with 386 additions and 14 deletions
+42 -13
View File
@@ -216,23 +216,52 @@ force-stopped at the drain deadline on shutdown; full suite green.
4. **Handler API** ✅ — folded into chunk 3 above (was: settle in design
pass; settled: gen_server-shaped trait + WsSender handle).
## v0.5 — PubSub
## v0.5 — PubSub ✅ (landed 2026-06, chunks 1-2)
`urus::pubsub`, phoenix_pubsub-shaped, local node only (no distribution —
smarm has none):
smarm has none). All six design questions ratified by the user pre-code
("push" on the stated leans); as-landed:
- `subscribe(topic)`, `unsubscribe(topic)`, `broadcast(topic, msg)`,
`broadcast_from(self, topic, msg)`.
- Topic table: gen_server owning `HashMap<Topic, Vec<(Pid, Sender<Arc<M>>)>>`;
shard N ways by topic hash if the single server measures hot (don't
pre-shard; bench first — smarm channel sends are cheap).
- Dead subscriber cleanup via `monitor` + `handle_down` (this is exactly
what 06-10's gen_server `handle_down` was built for).
- `Arc<M>` payloads: one allocation per broadcast, not per subscriber —
the shared-heap advantage over BEAM, take it.
- **Generic `PubSub<M>`** per instance (Q1); heterogeneous buses are an
app-side enum. `Arc<M>` payloads: one allocation per broadcast.
- **`subscribe(topic) -> Receiver<Arc<M>>`** (Q2), fresh channel per
subscription; `subscribe_as(pid, ..)` pins lifetime to an explicit pid
for relay shapes. Sender-passing fan-in (`subscribe_with`) deliberately
deferred — compatible addition.
- **Unbounded mailboxes** (Q3): broadcast never blocks the table. Cleanup
is monitor `Down` (eager, on subscriber death) + prune-on-send-failure
(lazy, dropped receivers). Bench before sharding.
- **User-owned handle** (Q4). The ratified register-by-name helper turned
out NOT implementable: smarm's registry maps name → Pid and a Pid can't
be turned back into a `ServerRef`. Deferred pending smarm support (or a
type-erased global, rejected). `pid()` exposed.
- **Unique per `(pid, topic)`** (Q5): subscribe idempotent (replaces the
sender; the stale receiver closes). One monitor per live pid.
`handle_down` is a full table scan as ratified; pid→topics reverse
index is the documented optimization if deaths measure hot.
- Table: gen_server `HashMap<Topic, HashMap<Pid, Sender<Arc<M>>>>`
single server, not sharded (bench first). `subscriber_count(topic)`
added (tests need to observe async cleanup; legitimate API).
Deliberately independent of HTTP — usable from any smarm app. Separate
module now, candidate for crate extraction later.
**Discoveries this cycle:**
- `PubSub::new()` is in-runtime only (spawns the table actor) but
pipelines are built pre-runtime → the composition pattern is a
NON-static `Arc<OnceLock<PubSub<M>>>` in the route closure. Non-static
is load-bearing: the drained pipeline drops the cell in-runtime, the
table's inbox closes, the table exits, AllDone is reachable. A `static`
(crud's store shape) hangs `serve_with_shutdown`. Corollary: relays
hold `Receiver` only, never a `PubSub` clone (mutual-keepalive cycle).
Proven by `shutdown_with_open_chat_terminates`.
- **`WsHandler::on_open(&mut self, sender)`** added (defaulted,
non-breaking): without it a listen-only ws client can never be
subscribed (`on_message` never fires). Same panic contract as
`on_message` (1011, no `on_close`). The 101 reaches the client before
`on_open` runs — subscription liveness needs an app-level ack
(documented; the integration tests use a `sync`/`synced` ack).
- hammer.sh default subset now includes `ws_`.
Chunk 2's ws_chat: rooms as topics, `on_open` subscribes (conn-actor
pid) + spawns the relay, `broadcast_from(self_pid(), ..)` for no-echo.
## v0.6 — Channels