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