Skip to main content

ws_kit/
redis_rooms.rs

1//! Multi-node room fan-out over Redis pub/sub (`redis` feature, off by
2//! default).
3//!
4//! ## Topology
5//!
6//! Each application node keeps its local rooms in an ordinary
7//! [`RoomManager`]. A [`RedisRoomRegistry`] wraps that local manager plus a
8//! multiplexed Redis connection:
9//!
10//! ```text
11//!        node A                                node B
12//!  ┌─────────────────┐                  ┌─────────────────┐
13//!  │ RoomManager (A) │                  │ RoomManager (B) │
14//!  │   room "lobby"  │                  │   room "lobby"  │
15//!  └───┬────────┬────┘                  └───┬────────┬────┘
16//!      │        │                           │        │
17//!   local    PUBLISH ws-kit:room:lobby   local    PUBLISH
18//!   sinks          │                     sinks        │
19//!      │        ┌───▼───────────────────────┼────────┘ │
20//!      │        │            Redis          │          │
21//!      │        └───┬───────────────────────▼──────────┘
22//!      │            │        (pub/sub, no persistence)
23//!   every node PSUBSCRIBEs ws-kit:room:* → delivers each message
24//!   to the LOCAL room with that id (if the node hosts one)
25//! ```
26//!
27//! - **Broadcast** ([`RedisRoomRegistry::broadcast`]): delivers to the
28//!   local room's sinks **and** `PUBLISH`es once to the room's channel.
29//! - **Subscribe**: each node runs a subscriber loop
30//!   ([`RedisRoomRegistry::run_subscriber`], spawn it once at startup)
31//!   `PSUBSCRIBE ws-kit:room:*`. Every received message is applied to the
32//!   local room of that id — *if* the node hosts it. Remote messages never
33//!   materialize new rooms.
34//! - **Self-echo suppression**: each registry carries a random 16-byte node
35//!   id; the envelope is stamped with it and a node ignores its own
36//!   publishes (otherwise a node's local receivers would get every message
37//!   twice: once directly, once via the Redis echo).
38//!
39//! ## Delivery semantics — read this before trusting it
40//!
41//! Redis pub/sub is **at-most-once, best-effort**: no persistence, no
42//! replay, no acknowledgement. If a node is disconnected, restarting, or a
43//! subscriber briefly lags, messages published in that window are **gone**
44//! — this crate does *not* provide at-least-once delivery, and makes no
45//! persistence claim. Use it for ephemeral fan-out (presence, chat, live
46//! notifications) where losing the odd message on failover beats carrying
47//! infrastructure; for at-least-once, switch the transport to Redis Streams
48//! or a log — the [`RoomRegistry`] trait keeps that swap local to this
49//! module.
50//!
51//! ## Security
52//!
53//! The Redis channel is a **trusted bus**: any process that can PUBLISH to
54//! `ws-kit:room:*` can inject messages into any room on any node, and any
55//! process that can SUBSCRIBE can read them. Restrict access (Redis ACLs,
56//! network isolation), use `rediss://` (TLS) for untrusted links, and treat
57//! channel payloads as you would any network input — this module validates
58//! the envelope (`version` byte, kind tag, UTF-8 for text) and fails closed
59//! on anything unexpected.
60//!
61//! # Example
62//!
63//! ```no_run
64//! # async fn demo() -> Result<(), ws_kit::error::WsError> {
65//! use std::sync::Arc;
66//! use ws_kit::codec::Frame;
67//! use ws_kit::room::{RoomRegistry, RoomManager};
68//! use ws_kit::redis_rooms::RedisRoomRegistry;
69//!
70//! let registry = Arc::new(RedisRoomRegistry::connect("redis://127.0.0.1:6379").await?);
71//!
72//! // One subscriber task per node, at startup:
73//! let sub = Arc::clone(&registry);
74//! tokio::spawn(async move {
75//!     let _ = sub.run_subscriber().await; // ends only on redis failure
76//! });
77//!
78//! // Local room + fan-out to every node hosting "lobby":
79//! registry.get_or_create("lobby").join(1, "alice".into());
80//! registry.broadcast("lobby", Frame::from("hello all nodes")).await?;
81//!
82//! // The plain RoomManager API still works for local-only concerns:
83//! let local: &RoomManager = registry.local();
84//! assert!(local.get("lobby").is_some());
85//! # Ok(())
86//! # }
87//! ```
88
89use std::sync::Arc;
90
91use redis::aio::{ConnectionLike, MultiplexedConnection};
92use redis::{Client, Cmd};
93
94use crate::codec::Frame;
95use crate::error::WsError;
96use crate::room::{RoomManager, RoomRegistry};
97
98/// Default Redis channel prefix: `ws-kit:room:{room_id}`.
99pub const DEFAULT_CHANNEL_PREFIX: &str = "ws-kit:room:";
100
101/// Envelope protocol version byte (fail-closed on mismatch).
102const ENVELOPE_VERSION: u8 = 1;
103
104/// Payload kind tags.
105const KIND_TEXT: u8 = 0;
106const KIND_BINARY: u8 = 1;
107
108/// Total envelope header length: version (1) + node id (16) + kind (1).
109const HEADER_LEN: usize = 18;
110
111/// Best-effort unique node id: two independently-seeded `RandomState`
112/// hashers give 128 random-ish bits. Not cryptographic — collisions are
113/// merely improbable and would only re-enable self-echo duplication for a
114/// specific message, never cross-node corruption (the id is opaque to
115/// receivers).
116fn generate_node_id() -> [u8; 16] {
117    use std::collections::hash_map::RandomState;
118    use std::hash::{BuildHasher, Hasher};
119    let mut a = RandomState::new().build_hasher();
120    let mut b = RandomState::new().build_hasher();
121    a.write_u64(0x77_73_2d_6b_69_74_01);
122    b.write_u64(std::process::id() as u64);
123    let hi = a.finish();
124    let lo = b.finish();
125    let mut id = [0u8; 16];
126    let (left, right) = id.split_at_mut(8);
127    left.copy_from_slice(&hi.to_le_bytes());
128    right.copy_from_slice(&lo.to_le_bytes());
129    id
130}
131
132/// Encode a frame into the pub/sub envelope:
133/// `version(1) | node_id(16) | kind(1) | payload`.
134fn encode_envelope(node_id: &[u8; 16], frame: &Frame) -> Vec<u8> {
135    let payload = frame.as_bytes();
136    let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
137    out.push(ENVELOPE_VERSION);
138    out.extend_from_slice(node_id);
139    out.push(match frame {
140        Frame::Text(_) => KIND_TEXT,
141        Frame::Binary(_) => KIND_BINARY,
142    });
143    out.extend_from_slice(payload);
144    out
145}
146
147/// Decode an envelope, keeping the publisher's node id so receivers can
148/// drop self-echoes. Fails closed on wrong version, unknown kind, or
149/// truncated header.
150fn decode_envelope(bytes: &[u8]) -> Result<([u8; 16], Frame), WsError> {
151    if bytes.len() < HEADER_LEN || bytes.first() != Some(&ENVELOPE_VERSION) {
152        return Err(WsError::InvalidMessage);
153    }
154    let mut node_id = [0u8; 16];
155    let id_bytes = bytes.get(1..17).ok_or(WsError::InvalidMessage)?;
156    node_id.copy_from_slice(id_bytes);
157    let kind = bytes.get(17).copied().ok_or(WsError::InvalidMessage)?;
158    let payload = bytes.get(HEADER_LEN..).ok_or(WsError::InvalidMessage)?;
159    let frame = match kind {
160        KIND_TEXT => {
161            let s = String::from_utf8(payload.to_vec()).map_err(|_| WsError::InvalidMessage)?;
162            Frame::Text(s)
163        }
164        KIND_BINARY => Frame::Binary(payload.to_vec().into()),
165        _ => return Err(WsError::InvalidMessage),
166    };
167    Ok((node_id, frame))
168}
169
170/// Multi-node [`crate::room::RoomRegistry`]: local [`RoomManager`] + Redis
171/// pub/sub fan-out. See the [module docs](self) for topology and delivery
172/// semantics.
173///
174/// The generic parameter is the publish connection (redis'
175/// `ConnectionLike`); it defaults to the production
176/// `MultiplexedConnection` and exists so tests can substitute a mock —
177/// in application code always write the default `RedisRoomRegistry`.
178pub struct RedisRoomRegistry<C = MultiplexedConnection> {
179    local: RoomManager,
180    client: Client,
181    publish: Arc<tokio::sync::Mutex<C>>,
182    prefix: String,
183    node_id: [u8; 16],
184}
185
186impl RedisRoomRegistry<MultiplexedConnection> {
187    /// Connect with the default channel prefix
188    /// ([`DEFAULT_CHANNEL_PREFIX`]).
189    ///
190    /// # Errors
191    ///
192    /// [`WsError::Redis`] when the URL is invalid or the server is
193    /// unreachable.
194    pub async fn connect(url: &str) -> Result<Self, WsError> {
195        Self::connect_with_prefix(url, DEFAULT_CHANNEL_PREFIX).await
196    }
197
198    /// Connect with a custom channel prefix (rooms of unrelated
199    /// deployments can share one Redis by differing prefixes).
200    pub async fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self, WsError> {
201        let client = Client::open(url.to_string()).map_err(redis_err)?;
202        let publish = client
203            .get_multiplexed_async_connection()
204            .await
205            .map_err(redis_err)?;
206        Ok(Self::from_parts(
207            RoomManager::new(),
208            client,
209            publish,
210            prefix,
211        ))
212    }
213}
214
215impl<C> RedisRoomRegistry<C>
216where
217    C: ConnectionLike + Clone + Send + 'static,
218{
219    /// Assemble a registry from a local manager, a Redis client (used by
220    /// [`Self::run_subscriber`]), and an existing publish connection.
221    ///
222    /// Public to let integrators and tests supply custom
223    /// `ConnectionLike` implementations (the trait-mocked publish path);
224    /// prefer [`Self::connect`] in application code.
225    pub fn from_parts(
226        local: RoomManager,
227        client: Client,
228        publish: C,
229        prefix: impl Into<String>,
230    ) -> Self {
231        Self {
232            local,
233            client,
234            publish: Arc::new(tokio::sync::Mutex::new(publish)),
235            prefix: prefix.into(),
236            node_id: generate_node_id(),
237        }
238    }
239}
240
241impl<C> RedisRoomRegistry<C> {
242    /// The local half of the registry — the ordinary [`RoomManager`] API
243    /// for joins, leaves, local subscription, and cleanup.
244    pub fn local(&self) -> &RoomManager {
245        &self.local
246    }
247
248    /// Channel prefix in use.
249    pub fn prefix(&self) -> &str {
250        &self.prefix
251    }
252
253    /// Channel name for a room id.
254    pub fn channel_name(&self, room_id: &str) -> String {
255        format!("{}{}", self.prefix, room_id)
256    }
257
258    /// Room id carried by a channel name, if it matches the prefix.
259    pub fn room_id_from_channel<'a>(&self, channel: &'a str) -> Option<&'a str> {
260        channel.strip_prefix(self.prefix.as_str())
261    }
262
263    /// This registry's random publisher id (self-echo suppression).
264    pub fn node_id(&self) -> [u8; 16] {
265        self.node_id
266    }
267}
268
269impl<C> RedisRoomRegistry<C>
270where
271    C: ConnectionLike + Clone + Send + 'static,
272{
273    /// Broadcast a frame to a room across the fleet.
274    ///
275    /// 1. Fans out to the **local** room's receivers (creating the room if
276    ///    this node does not host it yet — matching
277    ///    [`RoomRegistry::get_or_create`] semantics).
278    /// 2. `PUBLISH`es the envelope to `prefix + room_id` for every other
279    ///    node.
280    ///
281    /// Returns the number of **local** receivers reached. An empty local
282    /// room is *not* an error here (unlike [`crate::room::Room::broadcast`]):
283    /// other nodes may still hold receivers, so `Ok(0)` just means "no local
284    /// subscriber". A Redis failure surfaces as [`WsError::Redis`].
285    ///
286    /// **At-most-once**: see the module docs — delivery on other nodes is
287    /// best-effort.
288    pub async fn broadcast(
289        &self,
290        room_id: &str,
291        frame: impl Into<Frame>,
292    ) -> Result<usize, WsError> {
293        let frame = frame.into();
294        // Local first: room-local receivers must not wait on the network.
295        let local_n = match self.local.get_or_create(room_id).broadcast(frame.clone()) {
296            Ok(n) => n,
297            // No local receivers is fine — the PUBLISH still fans out.
298            Err(WsError::BroadcastFull) => 0,
299            Err(e) => return Err(e),
300        };
301        self.publish(&frame, room_id).await?;
302        Ok(local_n)
303    }
304
305    /// Publish one frame to `room_id`'s channel (no local delivery).
306    pub async fn publish(&self, frame: &Frame, room_id: &str) -> Result<(), WsError> {
307        let envelope = encode_envelope(&self.node_id, frame);
308        let mut conn = self.publish.lock().await;
309        let mut cmd = Cmd::new();
310        cmd.arg("PUBLISH")
311            .arg(self.channel_name(room_id))
312            .arg(envelope);
313        conn.req_packed_command(&cmd).await.map_err(redis_err)?;
314        Ok(())
315    }
316
317    /// Apply a raw pub/sub envelope to the local registry: decode it, drop
318    /// self-echoes, and deliver to the local room **only if this node
319    /// hosts it** (remote traffic never materializes rooms).
320    ///
321    /// Returns `Ok(Some(n))` with the local receiver count when delivered,
322    /// `Ok(None)` when the message was a self-echo or for an unhosted room,
323    /// and the decode error for malformed envelopes.
324    pub fn apply_remote(&self, room_id: &str, envelope: &[u8]) -> Result<Option<usize>, WsError> {
325        let (publisher, frame) = decode_envelope(envelope)?;
326        if publisher == self.node_id {
327            return Ok(None);
328        }
329        match self.local.get(room_id) {
330            Some(room) => match room.broadcast(frame) {
331                Ok(n) => Ok(Some(n)),
332                Err(WsError::BroadcastFull) => Ok(None),
333                Err(e) => Err(e),
334            },
335            None => Ok(None),
336        }
337    }
338}
339
340impl<C> RoomRegistry for RedisRoomRegistry<C>
341where
342    C: ConnectionLike + Clone + Send + 'static,
343{
344    // The local half implements the room-collection trait: a
345    // RedisRoomRegistry can drop in anywhere a RoomManager did, while the
346    // cross-node fan-out is the inherent `broadcast`/`run_subscriber` API.
347    fn get_or_create(&self, room_id: &str) -> std::sync::Arc<crate::room::Room> {
348        self.local.get_or_create(room_id)
349    }
350
351    fn get(&self, room_id: &str) -> Option<std::sync::Arc<crate::room::Room>> {
352        self.local.get(room_id)
353    }
354
355    fn remove(&self, room_id: &str) -> Option<std::sync::Arc<crate::room::Room>> {
356        self.local.remove(room_id)
357    }
358
359    fn room_count(&self) -> usize {
360        self.local.room_count()
361    }
362
363    fn room_ids(&self) -> Vec<String> {
364        self.local.room_ids()
365    }
366
367    fn total_participants(&self) -> usize {
368        self.local.total_participants()
369    }
370
371    fn cleanup(&self) -> usize {
372        self.local.cleanup()
373    }
374
375    fn cleanup_empty(&self) -> usize {
376        self.local.cleanup_empty()
377    }
378}
379
380impl RedisRoomRegistry<MultiplexedConnection> {
381    /// Run the subscriber loop **on the current task**: `PSUBSCRIBE
382    /// prefix*` and apply every message to the local registry until the
383    /// Redis connection dies.
384    ///
385    /// Spawn it once per node at startup (see the module example); to
386    /// survive Redis restarts wrap it in your own retry/backoff — the loop
387    /// intentionally returns instead of hiding reconnect policy.
388    ///
389    /// # Errors
390    ///
391    /// [`WsError::Redis`] on subscription/transport failure. Malformed
392    /// envelopes from the channel are dropped (fail-closed), not fatal.
393    pub async fn run_subscriber(&self) -> Result<(), WsError> {
394        let mut pubsub = self.client.get_async_pubsub().await.map_err(redis_err)?;
395        let pattern = format!("{}*", self.prefix);
396        pubsub.psubscribe(pattern).await.map_err(redis_err)?;
397        let mut stream = pubsub.on_message();
398        while let Some(msg) = futures_util::StreamExt::next(&mut stream).await {
399            let channel = msg.get_channel_name().to_string();
400            let payload = msg.get_payload_bytes().to_vec();
401            if let Some(room_id) = self.room_id_from_channel(&channel) {
402                // Fail-closed on malformed envelopes: count as nothing,
403                // keep the loop alive.
404                let _ = self.apply_remote(room_id, &payload);
405            }
406        }
407        Err(WsError::Closed)
408    }
409}
410
411fn redis_err(e: redis::RedisError) -> WsError {
412    WsError::Redis(e.to_string())
413}
414
415// Tests exercise failure paths and invariants directly; unwrap/expect,
416// slicing, and panicking asserts are acceptable here — violations
417// surface as test failures, not production panics.
418#[allow(
419    clippy::unwrap_used,
420    clippy::expect_used,
421    clippy::indexing_slicing,
422    clippy::panic
423)]
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use bytes::Bytes;
428    use std::sync::{Arc, Mutex};
429
430    /// In-memory publish connection: records PUBLISH payloads so the
431    /// fan-out path is testable without Redis (the trait-mocked pubsub
432    /// strategy; full loop is covered by the #[ignore] live fixture).
433    #[derive(Clone, Default)]
434    struct MockPublishConn {
435        published: Arc<Mutex<Vec<Vec<u8>>>>,
436    }
437
438    impl ConnectionLike for MockPublishConn {
439        fn req_packed_command<'a>(
440            &'a mut self,
441            cmd: &'a Cmd,
442        ) -> redis::RedisFuture<'a, redis::Value> {
443            let packed = cmd.get_packed_command();
444            self.published.lock().unwrap().push(packed);
445            Box::pin(async { Ok(redis::Value::Nil) })
446        }
447
448        fn req_packed_commands<'a>(
449            &'a mut self,
450            _cmd: &'a redis::Pipeline,
451            _offset: usize,
452            _count: usize,
453        ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
454            Box::pin(async { Ok(Vec::new()) })
455        }
456
457        fn get_db(&self) -> i64 {
458            0
459        }
460    }
461
462    type PublishedLog = Arc<Mutex<Vec<Vec<u8>>>>;
463
464    fn test_registry() -> (RedisRoomRegistry<MockPublishConn>, PublishedLog) {
465        let conn = MockPublishConn::default();
466        let published = Arc::clone(&conn.published);
467        // Client::open only parses the URL — no I/O, safe for mocks.
468        let client = Client::open("redis://127.0.0.1:6379").unwrap();
469        let reg =
470            RedisRoomRegistry::from_parts(RoomManager::new(), client, conn, DEFAULT_CHANNEL_PREFIX);
471        (reg, published)
472    }
473
474    #[tokio::test]
475    async fn broadcast_fans_out_locally_and_publishes() {
476        let (reg, published) = test_registry();
477        let mut rx = reg.local().get_or_create("lobby").subscribe();
478        let n = reg
479            .broadcast("lobby", Frame::from("hi fleet"))
480            .await
481            .unwrap();
482        assert_eq!(n, 1, "one local receiver");
483        assert_eq!(
484            rx.recv().await.unwrap(),
485            Frame::from("hi fleet"),
486            "local sink got the frame directly"
487        );
488        let sent = published.lock().unwrap();
489        assert_eq!(sent.len(), 1, "exactly one PUBLISH");
490        let packed = &sent[0];
491        let packed_str = String::from_utf8_lossy(packed).to_string();
492        assert!(
493            packed_str.contains("ws-kit:room:lobby"),
494            "PUBLISH targets the room channel: {packed_str:?}"
495        );
496        // The full envelope (version | node id | kind | payload) rides the
497        // same command.
498        let expected = encode_envelope(&reg.node_id(), &Frame::from("hi fleet"));
499        assert!(
500            packed
501                .windows(expected.len())
502                .any(|w| w == expected.as_slice()),
503            "PUBLISH carries the exact envelope"
504        );
505    }
506
507    #[tokio::test]
508    async fn broadcast_creates_local_room_on_demand() {
509        let (reg, _published) = test_registry();
510        assert!(reg.local().get("fresh").is_none());
511        // No local receivers: still Ok, still publishes.
512        let n = reg.broadcast("fresh", Frame::from("x")).await.unwrap();
513        assert_eq!(n, 0);
514        assert!(reg.local().get("fresh").is_some(), "room materialized");
515    }
516
517    #[tokio::test]
518    async fn remote_envelope_reaches_hosted_room() {
519        let (reg, _published) = test_registry();
520        let mut rx = reg.local().get_or_create("lobby").subscribe();
521
522        // A peer publishes; craft the envelope as their node would.
523        let peer_id = [7u8; 16];
524        let frame = Frame::from("from peer");
525        let envelope = encode_envelope(&peer_id, &frame);
526        let delivered = reg.apply_remote("lobby", &envelope).unwrap();
527        assert_eq!(delivered, Some(1));
528        assert_eq!(rx.recv().await.unwrap(), frame);
529    }
530
531    #[tokio::test]
532    async fn remote_binary_envelope_roundtrips() {
533        let (reg, _published) = test_registry();
534        let mut rx = reg.local().get_or_create("bin").subscribe();
535        let frame = Frame::Binary(Bytes::from_static(b"\x00\xff\x10"));
536        let envelope = encode_envelope(&[9u8; 16], &frame);
537        reg.apply_remote("bin", &envelope).unwrap();
538        let got = rx.recv().await.unwrap();
539        assert!(got.is_binary());
540        assert_eq!(got.as_bytes(), b"\x00\xff\x10");
541    }
542
543    #[tokio::test]
544    async fn self_echo_is_dropped() {
545        let (reg, _published) = test_registry();
546        let mut rx = reg.local().get_or_create("lobby").subscribe();
547        let envelope = encode_envelope(&reg.node_id(), &Frame::from("my own publish"));
548        assert_eq!(reg.apply_remote("lobby", &envelope).unwrap(), None);
549        use tokio::time::{timeout, Duration};
550        assert!(timeout(Duration::from_millis(20), rx.recv()).await.is_err());
551    }
552
553    #[tokio::test]
554    async fn remote_traffic_never_materializes_rooms() {
555        let (reg, _published) = test_registry();
556        let envelope = encode_envelope(&[3u8; 16], &Frame::from("anyone there?"));
557        assert_eq!(reg.apply_remote("ghost", &envelope).unwrap(), None);
558        assert!(reg.local().get("ghost").is_none());
559    }
560
561    #[test]
562    fn malformed_envelopes_fail_closed() {
563        let (reg, _published) = test_registry();
564        // Truncated header
565        assert_eq!(
566            reg.apply_remote("r", &[0u8; 10]).unwrap_err(),
567            WsError::InvalidMessage
568        );
569        // Empty
570        assert_eq!(
571            reg.apply_remote("r", &[]).unwrap_err(),
572            WsError::InvalidMessage
573        );
574        // Wrong version byte
575        let mut evil = encode_envelope(&[0u8; 16], &Frame::from("v0"));
576        evil[0] = 99;
577        assert_eq!(
578            reg.apply_remote("r", &evil).unwrap_err(),
579            WsError::InvalidMessage
580        );
581        // Unknown kind tag
582        let mut evil = encode_envelope(&[0u8; 16], &Frame::from("bad kind"));
583        evil[17] = 42;
584        assert_eq!(
585            reg.apply_remote("r", &evil).unwrap_err(),
586            WsError::InvalidMessage
587        );
588        // Text frame with non-UTF-8 payload
589        let mut evil = encode_envelope(&[0u8; 16], &Frame::from("ok"));
590        evil[17] = KIND_TEXT;
591        evil[18..].copy_from_slice(&[0xFF, 0xFE]);
592        assert_eq!(
593            reg.apply_remote("r", &evil).unwrap_err(),
594            WsError::InvalidMessage
595        );
596    }
597
598    #[test]
599    fn channel_names_roundtrip() {
600        let (reg, _published) = test_registry();
601        assert_eq!(reg.channel_name("lobby"), "ws-kit:room:lobby");
602        assert_eq!(reg.room_id_from_channel("ws-kit:room:lobby"), Some("lobby"));
603        // Room ids may themselves contain ':' — prefix split is exact.
604        assert_eq!(reg.room_id_from_channel("ws-kit:room:a:b:c"), Some("a:b:c"));
605        assert_eq!(reg.room_id_from_channel("other:room:x"), None);
606        assert_eq!(reg.room_id_from_channel("ws-kit:room:"), Some(""));
607    }
608
609    #[test]
610    fn custom_prefix_changes_channels() {
611        let conn = MockPublishConn::default();
612        let client = Client::open("redis://127.0.0.1:6379").unwrap();
613        let reg: RedisRoomRegistry<MockPublishConn> =
614            RedisRoomRegistry::from_parts(RoomManager::new(), client, conn, "tenant-a/rooms:");
615        assert_eq!(reg.channel_name("lobby"), "tenant-a/rooms:lobby");
616        assert_eq!(
617            reg.room_id_from_channel("tenant-a/rooms:lobby"),
618            Some("lobby")
619        );
620        assert_eq!(reg.prefix(), "tenant-a/rooms:");
621    }
622
623    #[test]
624    fn envelope_decode_roundtrip_preserves_frame_kind() {
625        let node_id = [1u8; 16];
626        for frame in [
627            Frame::from("hello".to_string()),
628            Frame::Binary(Bytes::from_static(&[0, 1, 2, 255])),
629        ] {
630            let env = encode_envelope(&node_id, &frame);
631            let (got_id, got_frame) = decode_envelope(&env).unwrap();
632            assert_eq!(got_id, node_id);
633            assert_eq!(got_frame, frame);
634        }
635    }
636
637    #[test]
638    fn node_ids_are_distinct_per_registry() {
639        let (a, _) = test_registry();
640        let (b, _) = test_registry();
641        assert_ne!(a.node_id(), b.node_id());
642    }
643
644    /// Live fixture — requires a Redis server (docker: `docker run -p
645    /// 6379:6379 redis:7`). Run: `cargo test --features redis --test
646    /// redis_rooms -- --ignored`. The full two-node pub/sub loop cannot be
647    /// exercised by the in-memory publish mock; these cover it.
648    #[tokio::test]
649    #[ignore = "requires a live Redis server (docker run -p 6379:6379 redis:7)"]
650    async fn live_two_nodes_fan_out_over_pubsub() {
651        let node_a = Arc::new(
652            RedisRoomRegistry::connect("redis://127.0.0.1:6379")
653                .await
654                .unwrap(),
655        );
656        let node_b = Arc::new(
657            RedisRoomRegistry::connect("redis://127.0.0.1:6379")
658                .await
659                .unwrap(),
660        );
661        assert_ne!(node_a.node_id(), node_b.node_id());
662
663        // Subscriber loops on both nodes.
664        let sub_a = Arc::clone(&node_a);
665        let sub_b = Arc::clone(&node_b);
666        tokio::spawn(async move { sub_a.run_subscriber().await });
667        tokio::spawn(async move { sub_b.run_subscriber().await });
668
669        // Both nodes host the room; A subscribes locally.
670        let mut rx_a = node_a.local().get_or_create("live").subscribe();
671        let _rx_b = node_b.local().get_or_create("live").subscribe();
672
673        // Wait for PSUBSCRIBE to be established on both nodes.
674        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
675
676        // B broadcasts: A must receive it via pub/sub.
677        node_b
678            .broadcast("live", Frame::from("cross-node hello"))
679            .await
680            .unwrap();
681        let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
682            .await
683            .unwrap()
684            .unwrap();
685        assert_eq!(got, Frame::from("cross-node hello"));
686
687        // Self-echo suppression: A broadcasts to a room it hosts; its own
688        // subscriber must NOT double-deliver.
689        node_a
690            .broadcast("live", Frame::from("from a itself"))
691            .await
692            .unwrap();
693        let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
694            .await
695            .unwrap()
696            .unwrap();
697        assert_eq!(got, Frame::from("from a itself"));
698        // No duplicate within a grace window:
699        let dup = tokio::time::timeout(std::time::Duration::from_millis(300), rx_a.recv()).await;
700        assert!(dup.is_err(), "self-echo must be suppressed");
701    }
702
703    /// Live fixture: binary frames survive the pub/sub hop.
704    #[tokio::test]
705    #[ignore = "requires a live Redis server (docker run -p 6379:6379 redis:7)"]
706    async fn live_binary_frame_crosses_nodes() {
707        let node_a = Arc::new(
708            RedisRoomRegistry::connect("redis://127.0.0.1:6379")
709                .await
710                .unwrap(),
711        );
712        let node_b = Arc::new(
713            RedisRoomRegistry::connect("redis://127.0.0.1:6379")
714                .await
715                .unwrap(),
716        );
717        let sub_a = Arc::clone(&node_a);
718        tokio::spawn(async move { sub_a.run_subscriber().await });
719        let mut rx_a = node_a.local().get_or_create("bin-live").subscribe();
720        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
721
722        node_b
723            .broadcast(
724                "bin-live",
725                Frame::Binary(Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])),
726            )
727            .await
728            .unwrap();
729        let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx_a.recv())
730            .await
731            .unwrap()
732            .unwrap();
733        assert!(got.is_binary());
734        assert_eq!(got.as_bytes(), &[0xde, 0xad, 0xbe, 0xef]);
735    }
736
737    /// Publish connection that always fails — proves the redis error is
738    /// mapped into [`WsError`] instead of leaking.
739    #[derive(Clone, Default)]
740    struct FailingConn;
741
742    impl ConnectionLike for FailingConn {
743        fn req_packed_command<'a>(
744            &'a mut self,
745            _cmd: &'a Cmd,
746        ) -> redis::RedisFuture<'a, redis::Value> {
747            Box::pin(async {
748                Err(redis::RedisError::from((
749                    redis::ErrorKind::IoError,
750                    "connection dropped",
751                )))
752            })
753        }
754
755        fn req_packed_commands<'a>(
756            &'a mut self,
757            _cmd: &'a redis::Pipeline,
758            _offset: usize,
759            _count: usize,
760        ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
761            Box::pin(async {
762                Err(redis::RedisError::from((
763                    redis::ErrorKind::IoError,
764                    "connection dropped",
765                )))
766            })
767        }
768
769        fn get_db(&self) -> i64 {
770            0
771        }
772    }
773
774    #[tokio::test]
775    async fn publish_connection_failure_maps_to_ws_error() {
776        let client = Client::open("redis://127.0.0.1:6379").unwrap();
777        let reg = RedisRoomRegistry::from_parts(
778            RoomManager::new(),
779            client,
780            FailingConn,
781            DEFAULT_CHANNEL_PREFIX,
782        );
783        let err = reg
784            .publish(&Frame::from("x"), "lobby")
785            .await
786            .expect_err("failing conn must surface an error");
787        assert!(
788            matches!(err, WsError::Redis(_)),
789            "redis failure must map to WsError::Redis, got: {err:?}"
790        );
791    }
792
793    #[tokio::test]
794    async fn connect_rejects_invalid_url() {
795        assert!(RedisRoomRegistry::connect("not a url").await.is_err());
796    }
797
798    // The registry implements the same `RoomRegistry` trait as
799    // `RoomManager` by delegating to its local half — exercise every
800    // delegation through the trait object.
801    #[test]
802    fn room_registry_trait_delegates_to_local_manager() {
803        let (reg, _published) = test_registry();
804        let reg: Arc<dyn RoomRegistry> = Arc::new(reg);
805
806        let room = reg.get_or_create("trait-lobby");
807        assert!(reg.get("trait-lobby").is_some());
808        assert_eq!(reg.room_count(), 1);
809        assert_eq!(reg.room_ids(), vec!["trait-lobby".to_string()]);
810
811        room.join(7, "alice".to_string());
812        assert!(room.contains(7));
813        assert_eq!(reg.total_participants(), 1);
814
815        assert!(reg.remove("trait-lobby").is_some());
816        assert!(reg.get("trait-lobby").is_none());
817        assert_eq!(reg.room_count(), 0);
818
819        assert_eq!(reg.cleanup(), 0);
820        assert_eq!(reg.cleanup_empty(), 0);
821    }
822}