Skip to main content

ws_kit/
room.rs

1//! Room-based group messaging.
2//!
3//! [`RoomManager`] owns a [`DashMap`] of [`Room`]s. Each [`Room`] is an
4//! isolated broadcast domain with a participant roster keyed by connection
5//! id (`u32`) mapping to a display name (`String`).
6//!
7//! Since 0.4.0 rooms carry [`Frame`] payloads — both text and binary —
8//! instead of bare `String`s. Existing `room.broadcast(text_string)` call
9//! sites keep compiling (`String: Into<Frame>`, zero-copy move); binary
10//! payloads broadcast via `room.broadcast(bytes)`. Subscribers now receive
11//! [`Frame`]s: match on the variant, or use [`Frame::into_text`] to restore
12//! the old behavior for text-only consumers.
13//!
14//! ## The [`RoomRegistry`] trait
15//!
16//! [`RoomManager`] implements [`RoomRegistry`], the in-memory single-node
17//! registry. Multi-node deployments swap in
18//! [`crate::redis_rooms::RedisRoomRegistry`] (feature `redis`), which
19//! implements the same trait for the local half while fanning broadcasts
20//! out through Redis pub/sub.
21
22use std::sync::Arc;
23
24use dashmap::DashMap;
25use tokio::sync::broadcast;
26
27use crate::codec::Frame;
28use crate::config::WsConfig;
29use crate::error::WsError;
30use crate::stats::StatsRecorder;
31
32/// A single broadcast room with a participant roster.
33pub struct Room {
34    /// Room identifier.
35    pub id: String,
36    tx: broadcast::Sender<Frame>,
37    /// Participant roster: connection id -> display name.
38    participants: DashMap<u32, String>,
39    stats: Option<StatsRecorder>,
40}
41
42impl std::fmt::Debug for Room {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Room")
45            .field("id", &self.id)
46            .field("participants", &self.participant_count())
47            .field("receiver_count", &self.receiver_count())
48            .finish()
49    }
50}
51
52impl Room {
53    /// Create a new room with the given id and broadcast capacity.
54    pub fn new(id: impl Into<String>, capacity: usize) -> Self {
55        Self::with_stats(id, capacity, None)
56    }
57
58    /// Create a new room that records outbound traffic and drops into
59    /// `stats` (0.4.0 backpressure hook — see [`crate::stats`]).
60    pub fn with_stats(
61        id: impl Into<String>,
62        capacity: usize,
63        stats: Option<StatsRecorder>,
64    ) -> Self {
65        let (tx, _) = broadcast::channel(capacity);
66        Self {
67            id: id.into(),
68            tx,
69            participants: DashMap::new(),
70            stats,
71        }
72    }
73
74    /// Subscribe to this room's messages (text **and** binary).
75    pub fn subscribe(&self) -> broadcast::Receiver<Frame> {
76        self.tx.subscribe()
77    }
78
79    /// Broadcast a frame (text or binary) to all participants.
80    ///
81    /// Accepts anything [`Frame`] converts from — `String`, `&str`,
82    /// `bytes::Bytes`, `Vec<u8>` — with no re-encoding for owned payloads.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`WsError::BroadcastFull`] when the room has no active
87    /// receivers; with a [`StatsRecorder`] attached this also counts as a
88    /// drop (backpressure loss).
89    pub fn broadcast(&self, msg: impl Into<Frame>) -> Result<usize, WsError> {
90        let frame = msg.into();
91        let n = self
92            .tx
93            .send(frame.clone())
94            .map_err(|_| self.record_drop())?;
95        if let Some(stats) = &self.stats {
96            stats.record_room_message_out(&self.id, n as u64, frame.len() as u64 * n as u64);
97        }
98        Ok(n)
99    }
100
101    /// Add a participant (or refresh its display name on re-join).
102    pub fn join(&self, participant_id: u32, name: String) {
103        self.participants.insert(participant_id, name);
104    }
105
106    /// Remove a participant. Returns true if it was present.
107    pub fn leave(&self, participant_id: u32) -> bool {
108        self.participants.remove(&participant_id).is_some()
109    }
110
111    /// Check if participant is in room.
112    pub fn contains(&self, participant_id: u32) -> bool {
113        self.participants.contains_key(&participant_id)
114    }
115
116    /// Snapshot of `(participant_id, name)` pairs.
117    pub fn participants(&self) -> Vec<(u32, String)> {
118        self.participants
119            .iter()
120            .map(|r| (*r.key(), r.value().clone()))
121            .collect()
122    }
123
124    /// Snapshot of participant display names.
125    pub fn participant_names(&self) -> Vec<String> {
126        self.participants
127            .iter()
128            .map(|r| r.value().clone())
129            .collect()
130    }
131
132    /// Number of tracked participants.
133    pub fn participant_count(&self) -> usize {
134        self.participants.len()
135    }
136
137    /// Number of active broadcast receivers.
138    pub fn receiver_count(&self) -> usize {
139        self.tx.receiver_count()
140    }
141
142    /// Returns true if no participants and no receivers.
143    pub fn is_empty(&self) -> bool {
144        self.participants.is_empty() && self.tx.receiver_count() == 0
145    }
146
147    /// Internal sender.
148    pub fn sender(&self) -> &broadcast::Sender<Frame> {
149        &self.tx
150    }
151
152    fn record_drop(&self) -> WsError {
153        if let Some(stats) = &self.stats {
154            stats.record_room_drop(&self.id);
155        }
156        WsError::BroadcastFull
157    }
158}
159
160/// Storage for rooms, abstracted so a room *collection* can be swapped
161/// without changing room/messaging code (0.4.0).
162///
163/// The canonical single-node implementation is [`RoomManager`] (in-memory
164/// [`DashMap`]). The multi-node implementation is
165/// [`crate::redis_rooms::RedisRoomRegistry`] (feature `redis`), which
166/// delegates to an inner `RoomManager` for the local node and fans
167/// broadcasts out through Redis pub/sub.
168pub trait RoomRegistry: Send + Sync {
169    /// Get an existing room or create it.
170    fn get_or_create(&self, room_id: &str) -> Arc<Room>;
171
172    /// Get a room if it exists.
173    fn get(&self, room_id: &str) -> Option<Arc<Room>>;
174
175    /// Remove a room by id, returning it if present.
176    fn remove(&self, room_id: &str) -> Option<Arc<Room>>;
177
178    /// Number of rooms.
179    fn room_count(&self) -> usize;
180
181    /// List room ids.
182    fn room_ids(&self) -> Vec<String>;
183
184    /// Total participants across all rooms.
185    fn total_participants(&self) -> usize;
186
187    /// Remove all empty rooms (no participants, no receivers). Returns
188    /// number removed.
189    fn cleanup(&self) -> usize;
190
191    /// Remove all rooms with zero participants (regardless of receivers).
192    /// Returns number removed.
193    fn cleanup_empty(&self) -> usize;
194}
195
196/// Manages a collection of [`Room`]s.
197#[derive(Debug)]
198pub struct RoomManager {
199    rooms: DashMap<String, Arc<Room>>,
200    default_capacity: usize,
201    stats: Option<StatsRecorder>,
202}
203
204impl Default for RoomManager {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl RoomManager {
211    /// Create an empty manager with default capacity (1024).
212    pub fn new() -> Self {
213        Self {
214            rooms: DashMap::new(),
215            default_capacity: 1024,
216            stats: None,
217        }
218    }
219
220    /// Create an empty manager whose default capacity comes from [`WsConfig`].
221    pub fn with_config(config: &WsConfig) -> Self {
222        Self {
223            rooms: DashMap::new(),
224            default_capacity: config.broadcast_capacity,
225            stats: None,
226        }
227    }
228
229    /// Create an empty manager wired to a [`StatsRecorder`]: room
230    /// create/remove updates the room gauge, room broadcasts record
231    /// per-room + global outbound traffic and drops (0.4.0).
232    pub fn with_stats(config: &WsConfig, stats: StatsRecorder) -> Self {
233        Self {
234            rooms: DashMap::new(),
235            default_capacity: config.broadcast_capacity,
236            stats: Some(stats),
237        }
238    }
239
240    /// The recorder this manager reports to, if any.
241    pub fn stats(&self) -> Option<&StatsRecorder> {
242        self.stats.as_ref()
243    }
244}
245
246impl RoomRegistry for RoomManager {
247    // Pure delegation: the stats hooks live in the inherent methods below
248    // (method resolution prefers those, so every call path is counted).
249    fn get_or_create(&self, room_id: &str) -> Arc<Room> {
250        RoomManager::get_or_create(self, room_id)
251    }
252
253    fn get(&self, room_id: &str) -> Option<Arc<Room>> {
254        RoomManager::get(self, room_id)
255    }
256
257    fn remove(&self, room_id: &str) -> Option<Arc<Room>> {
258        RoomManager::remove(self, room_id)
259    }
260
261    fn room_count(&self) -> usize {
262        RoomManager::room_count(self)
263    }
264
265    fn room_ids(&self) -> Vec<String> {
266        RoomManager::room_ids(self)
267    }
268
269    fn total_participants(&self) -> usize {
270        RoomManager::total_participants(self)
271    }
272
273    fn cleanup(&self) -> usize {
274        RoomManager::cleanup(self)
275    }
276
277    fn cleanup_empty(&self) -> usize {
278        RoomManager::cleanup_empty(self)
279    }
280}
281
282impl RoomManager {
283    /// Get an existing room or create it with the manager's default capacity.
284    pub fn get_or_create(&self, room_id: &str) -> Arc<Room> {
285        self.get_or_create_with_capacity(room_id, self.default_capacity)
286    }
287
288    /// Get a room if it exists.
289    pub fn get(&self, room_id: &str) -> Option<Arc<Room>> {
290        self.rooms.get(room_id).map(|r| Arc::clone(&r))
291    }
292
293    /// Remove a room by id, returning it if present.
294    pub fn remove(&self, room_id: &str) -> Option<Arc<Room>> {
295        let removed = self.rooms.remove(room_id).map(|(_, v)| v);
296        if removed.is_some() {
297            if let Some(stats) = &self.stats {
298                stats.room_closed();
299            }
300        }
301        removed
302    }
303
304    /// Number of rooms.
305    pub fn room_count(&self) -> usize {
306        self.rooms.len()
307    }
308
309    /// Total participants across all rooms.
310    pub fn total_participants(&self) -> usize {
311        self.rooms
312            .iter()
313            .map(|e| e.value().participant_count())
314            .sum()
315    }
316
317    /// Remove all empty rooms. Returns number removed.
318    pub fn cleanup(&self) -> usize {
319        let mut to_remove = Vec::new();
320        for entry in self.rooms.iter() {
321            if entry.value().is_empty() {
322                to_remove.push(entry.key().clone());
323            }
324        }
325        let n = to_remove.len();
326        for k in to_remove {
327            self.rooms.remove(&k);
328        }
329        if n > 0 {
330            if let Some(stats) = &self.stats {
331                for _ in 0..n {
332                    stats.room_closed();
333                }
334            }
335        }
336        n
337    }
338
339    /// Remove all rooms with zero participants (regardless of receivers).
340    /// Returns number removed.
341    pub fn cleanup_empty(&self) -> usize {
342        let to_remove: Vec<String> = self
343            .rooms
344            .iter()
345            .filter(|e| e.value().participant_count() == 0)
346            .map(|e| e.key().clone())
347            .collect();
348        let n = to_remove.len();
349        for k in to_remove {
350            self.rooms.remove(&k);
351        }
352        if n > 0 {
353            if let Some(stats) = &self.stats {
354                for _ in 0..n {
355                    stats.room_closed();
356                }
357            }
358        }
359        n
360    }
361
362    /// List room ids.
363    pub fn room_ids(&self) -> Vec<String> {
364        self.rooms.iter().map(|e| e.key().clone()).collect()
365    }
366
367    /// Get an existing room or create with custom capacity.
368    pub fn get_or_create_with_capacity(&self, room_id: &str, capacity: usize) -> Arc<Room> {
369        if let Some(r) = self.rooms.get(room_id) {
370            return Arc::clone(&r);
371        }
372        let room = Arc::new(Room::with_stats(room_id, capacity, self.stats.clone()));
373        if let Some(stats) = &self.stats {
374            stats.room_opened();
375        }
376        // Insert-or-get to handle race. room_opened fires before the
377        // insert-or-get, so a lost race (another thread inserted first)
378        // can transiently overcount; room_closed on removal keeps the
379        // gauge convergent.
380        let entry = self
381            .rooms
382            .entry(room_id.to_string())
383            .or_insert_with(|| Arc::clone(&room));
384        Arc::clone(entry.value())
385    }
386}
387
388// Tests exercise failure paths and invariants directly; unwrap/expect,
389// slicing, and panicking asserts are acceptable here — violations
390// surface as test failures, not production panics.
391#[allow(
392    clippy::unwrap_used,
393    clippy::expect_used,
394    clippy::indexing_slicing,
395    clippy::panic
396)]
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use bytes::Bytes;
401
402    fn name(s: &str) -> String {
403        s.to_string()
404    }
405
406    #[test]
407    fn room_join_leave() {
408        let room = Room::new("lobby", 16);
409        assert_eq!(room.participant_count(), 0);
410        room.join(1, name("alice"));
411        room.join(2, name("bob"));
412        assert_eq!(room.participant_count(), 2);
413        assert!(room.contains(1));
414        assert!(room.leave(1));
415        assert!(!room.contains(1));
416        assert_eq!(room.participant_count(), 1);
417    }
418
419    #[test]
420    fn room_rejoin_updates_name() {
421        let room = Room::new("lobby", 16);
422        room.join(1, name("alice"));
423        room.join(1, name("alice2"));
424        assert_eq!(room.participant_count(), 1);
425        assert_eq!(room.participant_names(), vec!["alice2".to_string()]);
426    }
427
428    #[test]
429    fn room_participant_snapshots() {
430        let room = Room::new("lobby", 16);
431        room.join(7, name("alice"));
432        room.join(3, name("bob"));
433        let mut pairs = room.participants();
434        pairs.sort();
435        assert_eq!(
436            pairs,
437            vec![(3, "bob".to_string()), (7, "alice".to_string())]
438        );
439        let mut names = room.participant_names();
440        names.sort();
441        assert_eq!(names, vec!["alice".to_string(), "bob".to_string()]);
442        assert_eq!(room.participant_count(), 2);
443    }
444
445    #[tokio::test]
446    async fn room_broadcast() {
447        let room = Room::new("chat", 16);
448        let mut rx = room.subscribe();
449        let n = room.broadcast("hello".to_string()).unwrap();
450        assert_eq!(n, 1);
451        assert_eq!(rx.recv().await.unwrap(), Frame::from("hello"));
452    }
453
454    #[tokio::test]
455    async fn room_broadcast_binary() {
456        let room = Room::new("binroom", 16);
457        let mut rx = room.subscribe();
458        let payload = vec![0xde_u8, 0xad, 0xbe, 0xef];
459        let n = room.broadcast(Frame::from(payload.clone())).unwrap();
460        assert_eq!(n, 1);
461        let got = rx.recv().await.unwrap();
462        assert!(got.is_binary());
463        assert_eq!(got.as_bytes(), &payload[..]);
464    }
465
466    #[tokio::test]
467    async fn room_broadcast_bytes_vec_convenience() {
468        // Vec<u8> converts into a Binary frame directly.
469        let room = Room::new("binroom", 16);
470        let mut rx = room.subscribe();
471        room.broadcast(vec![1u8, 2, 3]).unwrap();
472        let got = rx.recv().await.unwrap();
473        assert!(got.is_binary());
474        assert_eq!(got.as_bytes(), &[1, 2, 3]);
475    }
476
477    #[tokio::test]
478    async fn room_text_and_binary_share_one_channel_without_cross_talk() {
479        let room = Room::new("mixed", 16);
480        let mut rx = room.subscribe();
481        room.broadcast("text".to_string()).unwrap();
482        room.broadcast(Bytes::from_static(b"\x00\x01")).unwrap();
483        assert!(rx.recv().await.unwrap().is_text());
484        assert!(rx.recv().await.unwrap().is_binary());
485    }
486
487    #[tokio::test]
488    async fn room_broadcast_no_receiver() {
489        let room = Room::new("empty", 16);
490        let err = room.broadcast("hi".to_string()).unwrap_err();
491        assert_eq!(err, WsError::BroadcastFull);
492    }
493
494    #[tokio::test]
495    async fn room_stats_records_outbound_and_drops() {
496        let stats = StatsRecorder::new();
497        let room = Room::with_stats("metrics", 16, Some(stats.clone()));
498        let mut rx = room.subscribe();
499        room.broadcast("12345".to_string()).unwrap(); // 5 bytes * 1 rx
500        rx.recv().await.unwrap();
501
502        let s = stats.snapshot();
503        let room_stats = s.per_room.get("metrics").unwrap();
504        assert_eq!(room_stats.messages_out, 1);
505        assert_eq!(room_stats.bytes_out, 5);
506        assert_eq!(s.messages_out, 1);
507
508        drop(rx);
509        room.broadcast("nobody".to_string()).unwrap_err();
510        assert_eq!(stats.snapshot().per_room.get("metrics").unwrap().drops, 1);
511        assert_eq!(stats.snapshot().drops, 1);
512    }
513
514    #[test]
515    fn manager_get_or_create_idempotent() {
516        let m = RoomManager::new();
517        let r1 = m.get_or_create("room1");
518        let r2 = m.get_or_create("room1");
519        assert!(Arc::ptr_eq(&r1, &r2));
520        assert_eq!(m.room_count(), 1);
521    }
522
523    #[test]
524    fn manager_with_config_capacity() {
525        let cfg = WsConfig::builder().broadcast_capacity(2048).build();
526        let m = RoomManager::with_config(&cfg);
527        let r = m.get_or_create("room1");
528        // Capacity is not directly observable; verify the room works and is shared.
529        let r2 = m.get_or_create("room1");
530        assert!(Arc::ptr_eq(&r, &r2));
531        assert_eq!(m.room_count(), 1);
532    }
533
534    #[test]
535    fn manager_cleanup_removes_empty() {
536        let m = RoomManager::new();
537        let _r = m.get_or_create("a");
538        // No participants and no receivers -> empty, should be cleaned.
539        assert_eq!(m.cleanup(), 1);
540        assert_eq!(m.room_count(), 0);
541
542        let r = m.get_or_create("b");
543        r.join(1, name("user1"));
544        assert_eq!(m.cleanup(), 0);
545        assert_eq!(m.room_count(), 1);
546    }
547
548    #[test]
549    fn manager_cleanup_empty_removes_zero_participant_rooms() {
550        let m = RoomManager::new();
551        let _a = m.get_or_create("a");
552        // No participants -> removed regardless of receivers.
553        assert_eq!(m.cleanup_empty(), 1);
554        assert_eq!(m.room_count(), 0);
555
556        // Participant present -> kept.
557        let a = m.get_or_create("a");
558        a.join(1, name("alice"));
559        assert_eq!(m.cleanup_empty(), 0);
560        assert_eq!(m.room_count(), 1);
561        let kept = m.get_or_create("a");
562        assert!(Arc::ptr_eq(&a, &kept));
563
564        a.leave(1);
565        assert_eq!(m.cleanup_empty(), 1);
566        assert_eq!(m.room_count(), 0);
567    }
568
569    #[test]
570    fn manager_total_participants() {
571        let m = RoomManager::new();
572        let r1 = m.get_or_create("r1");
573        let r2 = m.get_or_create("r2");
574        assert_eq!(m.total_participants(), 0);
575        r1.join(1, name("alice"));
576        r1.join(2, name("bob"));
577        r2.join(3, name("carol"));
578        assert_eq!(m.total_participants(), 3);
579        r2.leave(3);
580        assert_eq!(m.total_participants(), 2);
581    }
582
583    #[test]
584    fn manager_get_and_remove() {
585        let m = RoomManager::new();
586        assert!(m.get("nonexistent").is_none());
587        let r = m.get_or_create("x");
588        assert!(m.get("x").is_some());
589        let removed = m.remove("x").unwrap();
590        assert!(Arc::ptr_eq(&r, &removed));
591        assert!(m.get("x").is_none());
592    }
593
594    #[test]
595    fn manager_stats_room_lifecycle() {
596        let stats = StatsRecorder::new();
597        let cfg = WsConfig::default();
598        let m = RoomManager::with_stats(&cfg, stats.clone());
599        let _r = m.get_or_create("a");
600        assert_eq!(stats.snapshot().rooms, 1);
601        m.get_or_create("a"); // idempotent: no double count
602        assert_eq!(stats.snapshot().rooms, 1);
603        m.remove("a");
604        assert_eq!(stats.snapshot().rooms, 0);
605        m.get_or_create("b");
606        m.get_or_create("c");
607        m.cleanup();
608        assert_eq!(stats.snapshot().rooms, 0);
609    }
610
611    #[test]
612    fn trait_object_room_registry() {
613        // The trait exists so room *collections* are swappable; prove it is
614        // object-safe and delegates.
615        let manager = RoomManager::new();
616        let registry: &dyn RoomRegistry = &manager;
617        let r = registry.get_or_create("dyn_room");
618        r.join(1, name("alice"));
619        assert_eq!(registry.room_count(), 1);
620        assert_eq!(registry.total_participants(), 1);
621        assert_eq!(registry.room_ids(), vec!["dyn_room".to_string()]);
622        assert!(registry.get("dyn_room").is_some());
623        assert!(registry.remove("dyn_room").is_some());
624        assert_eq!(registry.cleanup(), 0);
625        assert_eq!(registry.cleanup_empty(), 0);
626    }
627
628    #[tokio::test]
629    async fn manager_broadcast_isolation() {
630        let m = RoomManager::new();
631        let r1 = m.get_or_create("r1");
632        let r2 = m.get_or_create("r2");
633        let mut rx1 = r1.subscribe();
634        let mut rx2 = r2.subscribe();
635        r1.broadcast("for r1".to_string()).unwrap();
636        assert_eq!(rx1.recv().await.unwrap(), Frame::from("for r1"));
637        // r2 should not receive r1's message
638        use tokio::time::{timeout, Duration};
639        let res = timeout(Duration::from_millis(10), rx2.recv()).await;
640        assert!(res.is_err());
641    }
642}