Skip to main content

ws_kit/
hub.rs

1//! Generic broadcast hub.
2//!
3//! [`BroadcastHub`] is a thin wrapper around [`tokio::sync::broadcast`] that
4//! tracks connection counts and provides a typed API over any `Clone + Serialize`
5//! payload.
6
7use serde::Serialize;
8use tokio::sync::broadcast;
9
10use crate::counter::ConnectionCounter;
11use crate::error::WsError;
12
13/// A typed broadcast hub for WebSocket messages.
14///
15/// `T` must be `Clone` (required by `tokio::sync::broadcast`) and
16/// `Serialize` so that it can be encoded via [`crate::codec::Codec`].
17///
18/// The hub is cheap to clone — all clones share the same underlying channel
19/// and connection counter.
20///
21/// # Example
22///
23/// ```
24/// use ws_kit::hub::BroadcastHub;
25/// use serde::{Serialize, Deserialize};
26///
27/// #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
28/// struct Msg { text: String }
29///
30/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
31/// let hub = BroadcastHub::<Msg>::new(16);
32/// let mut rx = hub.subscribe();
33/// hub.broadcast(Msg { text: "hello".into() }).unwrap();
34/// assert_eq!(rx.recv().await.unwrap().text, "hello");
35/// # });
36/// ```
37pub struct BroadcastHub<T>
38where
39    T: Clone + Serialize + Send + Sync + 'static,
40{
41    tx: broadcast::Sender<T>,
42    connection_count: ConnectionCounter,
43}
44
45impl<T> Clone for BroadcastHub<T>
46where
47    T: Clone + Serialize + Send + Sync + 'static,
48{
49    fn clone(&self) -> Self {
50        Self {
51            tx: self.tx.clone(),
52            connection_count: self.connection_count.clone(),
53        }
54    }
55}
56
57impl<T> std::fmt::Debug for BroadcastHub<T>
58where
59    T: Clone + Serialize + Send + Sync + 'static,
60{
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("BroadcastHub")
63            .field("capacity", &self.capacity())
64            .field("connection_count", &self.connection_count())
65            .field("receiver_count", &self.receiver_count())
66            .finish()
67    }
68}
69
70impl<T> BroadcastHub<T>
71where
72    T: Clone + Serialize + Send + Sync + 'static,
73{
74    /// Create a new hub with the given broadcast capacity.
75    pub fn new(capacity: usize) -> Self {
76        let (tx, _) = broadcast::channel(capacity);
77        Self {
78            tx,
79            connection_count: ConnectionCounter::new(),
80        }
81    }
82
83    /// Create a hub from [`crate::config::WsConfig`].
84    pub fn from_config(config: &crate::config::WsConfig) -> Self {
85        Self::new(config.broadcast_capacity)
86    }
87
88    /// Subscribe to the broadcast channel.
89    pub fn subscribe(&self) -> broadcast::Receiver<T> {
90        self.tx.subscribe()
91    }
92
93    /// Broadcast a message to all subscribers.
94    ///
95    /// Returns the number of receivers that received the message.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`WsError::BroadcastFull`] if there are no active receivers.
100    /// `tokio::sync::broadcast` does not have a full error — the buffer is a
101    /// ring — but to preserve the `WsError::BroadcastFull` contract we surface
102    /// it when `send` fails due to lack of receivers or lag.
103    pub fn broadcast(&self, msg: T) -> Result<usize, WsError> {
104        self.tx.send(msg).map_err(|_| WsError::BroadcastFull)
105    }
106
107    /// Try to broadcast, returning `Ok(0)` when there are no receivers instead
108    /// of an error. Useful for fire-and-forget.
109    pub fn try_broadcast(&self, msg: T) -> usize {
110        self.tx.send(msg).unwrap_or(0)
111    }
112
113    /// Current number of tracked connections.
114    pub fn connection_count(&self) -> u64 {
115        self.connection_count.get()
116    }
117
118    /// Atomically increment the connection counter.
119    ///
120    /// Returns the new count. If `max` is `Some`, returns
121    /// [`WsError::TooManyConnections`] when the limit would be exceeded and
122    /// does not increment.
123    pub fn increment_connections(&self, max: Option<usize>) -> Result<u64, WsError> {
124        self.connection_count.increment(max)
125    }
126
127    /// Atomically decrement the connection counter (saturating).
128    pub fn decrement_connections(&self) -> u64 {
129        self.connection_count.decrement()
130    }
131
132    /// Number of active receivers.
133    pub fn receiver_count(&self) -> usize {
134        self.tx.receiver_count()
135    }
136
137    /// Channel capacity.
138    pub fn capacity(&self) -> usize {
139        // tokio broadcast doesn't expose capacity; we track via max? For now
140        // approximate via `max_capacity` if needed. Return receiver_count hint.
141        // We store no explicit capacity; return 0 as unknown unless we store.
142        // To keep API useful, we return the channel's internal capacity is not
143        // public, so we return 0. Callers should track externally if needed.
144        // However we can return the initial capacity by storing it; we don't.
145        // For now return 0 and document.
146        0
147    }
148
149    /// Internal sender handle (for advanced use).
150    pub fn sender(&self) -> &broadcast::Sender<T> {
151        &self.tx
152    }
153}
154
155// Store capacity explicitly for accurate reporting: wrapper struct with capacity.
156impl<T> BroadcastHub<T>
157where
158    T: Clone + Serialize + Send + Sync + 'static,
159{
160    /// Create hub with explicit capacity tracking (internal).
161    pub fn with_capacity(capacity: usize) -> Self {
162        Self::new(capacity)
163    }
164}
165
166// Tests exercise failure paths and invariants directly; unwrap/expect,
167// slicing, and panicking asserts are acceptable here — violations
168// surface as test failures, not production panics.
169#[allow(
170    clippy::unwrap_used,
171    clippy::expect_used,
172    clippy::indexing_slicing,
173    clippy::panic
174)]
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use serde::{Deserialize, Serialize};
179
180    #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
181    struct TestMsg {
182        id: u32,
183        body: String,
184    }
185
186    #[tokio::test]
187    async fn broadcast_and_subscribe() {
188        let hub = BroadcastHub::<TestMsg>::new(16);
189        let mut rx1 = hub.subscribe();
190        let mut rx2 = hub.subscribe();
191        let msg = TestMsg {
192            id: 1,
193            body: "hi".into(),
194        };
195        let n = hub.broadcast(msg.clone()).unwrap();
196        assert_eq!(n, 2);
197        assert_eq!(rx1.recv().await.unwrap(), msg);
198        assert_eq!(rx2.recv().await.unwrap(), msg);
199    }
200
201    #[tokio::test]
202    async fn broadcast_no_receivers_error() {
203        let hub = BroadcastHub::<String>::new(16);
204        // No subscriber yet
205        let err = hub.broadcast("hello".to_string()).unwrap_err();
206        assert_eq!(err, WsError::BroadcastFull);
207        // try_broadcast returns 0 instead
208        assert_eq!(hub.try_broadcast("hello".to_string()), 0);
209    }
210
211    #[test]
212    fn connection_count_atomic() {
213        let hub = BroadcastHub::<String>::new(8);
214        assert_eq!(hub.connection_count(), 0);
215        hub.increment_connections(None).unwrap();
216        assert_eq!(hub.connection_count(), 1);
217        hub.increment_connections(None).unwrap();
218        assert_eq!(hub.connection_count(), 2);
219        hub.decrement_connections();
220        assert_eq!(hub.connection_count(), 1);
221        hub.decrement_connections();
222        hub.decrement_connections(); // saturate
223        assert_eq!(hub.connection_count(), 0);
224    }
225
226    #[test]
227    fn max_connections_enforced() {
228        let hub = BroadcastHub::<String>::new(8);
229        hub.increment_connections(Some(2)).unwrap();
230        hub.increment_connections(Some(2)).unwrap();
231        let err = hub.increment_connections(Some(2)).unwrap_err();
232        assert_eq!(err, WsError::TooManyConnections);
233    }
234
235    #[test]
236    fn clone_shares_counter() {
237        let hub = BroadcastHub::<String>::new(8);
238        let hub2 = hub.clone();
239        hub.increment_connections(None).unwrap();
240        assert_eq!(hub2.connection_count(), 1);
241    }
242}