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:
@@ -1280,3 +1280,131 @@ fn shutdown_force_stops_open_ws() {
|
||||
let mut rest = Vec::new();
|
||||
let _ = s.read_to_end(&mut rest); // EOF or reset; both fine
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.5: pubsub wired to ws (the chat shape) — see examples/ws_chat.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimal chat handler mirroring the example: on_open subscribes (conn
|
||||
/// actor pid) + spawns a relay holding ONLY the Receiver and a WsSender
|
||||
/// clone; on_message broadcast_from's, skipping the sender's own relay.
|
||||
struct Chat {
|
||||
bus: std::sync::Arc<std::sync::OnceLock<urus::PubSub<String>>>,
|
||||
}
|
||||
|
||||
impl urus::WsHandler for Chat {
|
||||
fn on_open(&mut self, sender: &urus::WsSender) {
|
||||
let bus = self.bus.get_or_init(urus::PubSub::new);
|
||||
let rx = bus.subscribe("room").unwrap();
|
||||
let _ = bus.broadcast_from(smarm::self_pid(), "room", "joined".into());
|
||||
let out = sender.clone();
|
||||
smarm::spawn(move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
if out.send(urus::Message::Text((*msg).clone())).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn on_message(&mut self, msg: urus::Message, sender: &urus::WsSender) {
|
||||
if let urus::Message::Text(t) = msg {
|
||||
// "sync" is the join-ack: a direct (non-broadcast) reply
|
||||
// proving on_open — and therefore this client's
|
||||
// subscription — completed. The tests need it because the
|
||||
// 101 reaches the client BEFORE the conn actor runs
|
||||
// on_open; without an ack, "subscribed yet?" is a race.
|
||||
if t == "sync" {
|
||||
let _ = sender.send(urus::Message::Text("synced".into()));
|
||||
return;
|
||||
}
|
||||
let bus = self.bus.get_or_init(urus::PubSub::new);
|
||||
let _ = bus.broadcast_from(smarm::self_pid(), "room", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_pipeline() -> Pipeline {
|
||||
let bus: std::sync::Arc<std::sync::OnceLock<urus::PubSub<String>>> =
|
||||
std::sync::Arc::new(std::sync::OnceLock::new());
|
||||
Pipeline::new().plug(Router::new().get("/ws", move |c: Conn, _n: Next| {
|
||||
let bus = bus.clone();
|
||||
c.upgrade(Chat { bus })
|
||||
}))
|
||||
}
|
||||
|
||||
/// Two clients in a room: a broadcast reaches the other client and (via
|
||||
/// broadcast_from) never echoes to the sender. The on_open hook is what
|
||||
/// makes the listen-only direction work at all — B receives without
|
||||
/// having sent a single frame when A's first message arrives.
|
||||
#[test]
|
||||
fn ws_chat_broadcast_reaches_other_client_not_sender() {
|
||||
let port = spawn_server(chat_pipeline());
|
||||
|
||||
let mut a = ws_connect(port);
|
||||
let mut a_buf = Vec::new();
|
||||
// Join-ack A before B connects: A's sub must predate B's join
|
||||
// notice for the read sequence below to be deterministic.
|
||||
ws_send(&mut a, &Frame::new(Opcode::Text, "sync"));
|
||||
assert_eq!(ws_read_frame(&mut a, &mut a_buf).payload, b"synced");
|
||||
|
||||
let mut b = ws_connect(port);
|
||||
let mut b_buf = Vec::new();
|
||||
ws_send(&mut b, &Frame::new(Opcode::Text, "sync"));
|
||||
assert_eq!(ws_read_frame(&mut b, &mut b_buf).payload, b"synced");
|
||||
|
||||
// A sees B's join notice — proves A's relay is live and B's
|
||||
// subscription (made in on_open, before any client frame) is in.
|
||||
assert_eq!(ws_read_frame(&mut a, &mut a_buf).payload, b"joined");
|
||||
|
||||
// A speaks; B receives it as its FIRST broadcast frame.
|
||||
ws_send(&mut a, &Frame::new(Opcode::Text, "hello"));
|
||||
assert_eq!(ws_read_frame(&mut b, &mut b_buf).payload, b"hello");
|
||||
|
||||
// The no-echo proof without timeout reads: B speaks, and A's next
|
||||
// frame must be B's message — if A had been echoed its own "hello",
|
||||
// that would arrive first.
|
||||
ws_send(&mut b, &Frame::new(Opcode::Text, "yo"));
|
||||
assert_eq!(
|
||||
ws_read_frame(&mut a, &mut a_buf).payload,
|
||||
b"yo",
|
||||
"sender was echoed its own broadcast_from message"
|
||||
);
|
||||
}
|
||||
|
||||
/// THE structural test for the v0.5 shutdown chain: with two open chat
|
||||
/// sockets, live relays, and a live pubsub table, graceful shutdown must
|
||||
/// still terminate. Chain under test: drain force-stops conn actors →
|
||||
/// monitors prune subscriptions (relay senders drop, relays' recv errs)
|
||||
/// → listeners stop → last Arc<Pipeline> drops the non-static bus cell
|
||||
/// in-runtime → table inbox closes → table exits → AllDone → serve
|
||||
/// returns. A static bus, or a relay holding a PubSub clone, hangs here.
|
||||
#[test]
|
||||
fn shutdown_with_open_chat_terminates() {
|
||||
let (port, handle, done_rx) =
|
||||
spawn_server_with_handle(chat_pipeline(), Duration::from_millis(300));
|
||||
|
||||
let mut a = ws_connect(port);
|
||||
let mut a_buf = Vec::new();
|
||||
ws_send(&mut a, &Frame::new(Opcode::Text, "sync"));
|
||||
assert_eq!(ws_read_frame(&mut a, &mut a_buf).payload, b"synced");
|
||||
|
||||
let mut b = ws_connect(port);
|
||||
let mut b_buf = Vec::new();
|
||||
ws_send(&mut b, &Frame::new(Opcode::Text, "sync"));
|
||||
assert_eq!(ws_read_frame(&mut b, &mut b_buf).payload, b"synced");
|
||||
|
||||
// Prove the full pubsub path is live before shutting down.
|
||||
assert_eq!(ws_read_frame(&mut a, &mut a_buf).payload, b"joined");
|
||||
ws_send(&mut b, &Frame::new(Opcode::Text, "pre-shutdown"));
|
||||
assert_eq!(ws_read_frame(&mut a, &mut a_buf).payload, b"pre-shutdown");
|
||||
|
||||
handle.shutdown();
|
||||
done_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("serve did not return: pubsub table or a relay outlived the drain");
|
||||
|
||||
let mut rest = Vec::new();
|
||||
let _ = a.read_to_end(&mut rest);
|
||||
let _ = b.read_to_end(&mut rest);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user