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:
+42
-13
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user