Skip to main content

infinity_bridge_host/
hub.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::Duration;
5
6use infinity_bridge_wire::{
7    AckPayload, BridgeError, CmdPayload, EventPayload, HelloPayload, WireMsg,
8};
9use serde_json::Value;
10use tokio::sync::{Mutex, broadcast, mpsc, oneshot, watch};
11use uuid::Uuid;
12
13use crate::client::Client;
14
15#[derive(Debug, Clone)]
16pub struct ClientInfo {
17    pub id: u64,
18    pub hello: Option<HelloPayload>,
19}
20
21pub(crate) struct Hub {
22    next_id: AtomicU64,
23    clients: Mutex<HashMap<u64, Client>>,
24    pending: Mutex<HashMap<String, oneshot::Sender<AckPayload>>>,
25
26    event_tx: broadcast::Sender<EventPayload>,
27
28    connection_tx: watch::Sender<bool>,
29    connection_rx: watch::Receiver<bool>,
30
31    connect_notify: tokio::sync::Notify,
32}
33
34impl Hub {
35    pub fn new(event_capacity: usize) -> Arc<Self> {
36        let (event_tx, _) = broadcast::channel(event_capacity);
37        let (connection_tx, connection_rx) = watch::channel(false);
38
39        Arc::new(Self {
40            next_id: AtomicU64::new(1),
41            clients: Mutex::new(HashMap::new()),
42            pending: Mutex::new(HashMap::new()),
43            event_tx,
44            connection_tx,
45            connection_rx,
46            connect_notify: tokio::sync::Notify::new(),
47        })
48    }
49
50    pub async fn register_client(&self, tx: mpsc::UnboundedSender<String>) -> u64 {
51        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
52        let mut clients = self.clients.lock().await;
53        clients.insert(
54            id,
55            Client {
56                tx,
57                hello: None,
58                last_seen: tokio::time::Instant::now(),
59                ready: false,
60            },
61        );
62        self.connection_tx.send_replace(true);
63        self.connect_notify.notify_waiters();
64        id
65    }
66
67    pub async fn set_client_hello(&self, id: u64, hello: HelloPayload) {
68        let mut clients = self.clients.lock().await;
69        if let Some(c) = clients.get_mut(&id) {
70            c.hello = Some(hello);
71        }
72    }
73
74    pub async fn unregister_client(&self, id: u64) {
75        let mut clients = self.clients.lock().await;
76        clients.remove(&id);
77        let connected = !clients.is_empty();
78        self.connection_tx.send_replace(connected);
79    }
80
81    pub async fn touch_client(&self, id: u64) {
82        let mut clients = self.clients.lock().await;
83        if let Some(c) = clients.get_mut(&id) {
84            c.last_seen = tokio::time::Instant::now();
85        }
86    }
87
88    pub async fn reap_dead_clients(&self, timeout: Duration) -> Vec<u64> {
89        let now = tokio::time::Instant::now();
90        let mut clients = self.clients.lock().await;
91        let dead: Vec<u64> = clients
92            .iter()
93            .filter(|(_, c)| now.duration_since(c.last_seen) > timeout)
94            .map(|(&id, _)| id)
95            .collect();
96
97        for &id in &dead {
98            clients.remove(&id);
99        }
100
101        if !dead.is_empty() {
102            let connected = !clients.is_empty();
103            self.connection_tx.send_replace(connected);
104        }
105
106        dead
107    }
108
109    /// Record a client's downstream readiness (see
110    /// [`infinity_bridge_wire::READY_EVENT`]).
111    pub async fn set_client_ready(&self, id: u64, ready: bool) {
112        let mut clients = self.clients.lock().await;
113        if let Some(c) = clients.get_mut(&id) {
114            c.ready = ready;
115        }
116    }
117
118    pub async fn is_connected(&self) -> bool {
119        !self.clients.lock().await.is_empty()
120    }
121
122    /// `true` when at least one client has reported its downstream link bound.
123    ///
124    /// Distinct from [`Self::is_connected`]: a relay gauge can hold an open
125    /// socket for the whole session while the module it fronts is still
126    /// loading, absent, or has lost its IPC binding.
127    pub async fn is_ready(&self) -> bool {
128        self.clients.lock().await.values().any(|c| c.ready)
129    }
130
131    pub async fn wait_connected(&self) {
132        loop {
133            if self.is_connected().await {
134                return;
135            }
136            self.connect_notify.notified().await;
137        }
138    }
139
140    pub fn subscribe_connection_status(&self) -> watch::Receiver<bool> {
141        self.connection_rx.clone()
142    }
143
144    pub async fn connected_clients(&self) -> Vec<ClientInfo> {
145        let clients = self.clients.lock().await;
146        clients
147            .iter()
148            .map(|(&id, c)| ClientInfo {
149                id,
150                hello: c.hello.clone(),
151            })
152            .collect()
153    }
154
155    pub fn subscribe_events(&self) -> broadcast::Receiver<EventPayload> {
156        self.event_tx.subscribe()
157    }
158
159    pub fn dispatch_event(&self, event: EventPayload) {
160        let _ = self.event_tx.send(event);
161    }
162
163    pub async fn emit(&self, name: impl Into<String>, data: Value) -> Result<(), BridgeError> {
164        let msg = WireMsg::Event(EventPayload::new(name, data));
165        let json = msg.to_json()?;
166
167        let clients = self.clients.lock().await;
168        if clients.is_empty() {
169            return Err(BridgeError::no_clients(
170                "no gauges connected — event dropped",
171            ));
172        }
173
174        let mut send_failures = 0u32;
175        for client in clients.values() {
176            if client.tx.send(json.clone()).is_err() {
177                send_failures += 1;
178            }
179        }
180
181        if send_failures > 0 && send_failures as usize == clients.len() {
182            return Err(BridgeError::transport(
183                "all gauge connections failed to accept event",
184            ));
185        }
186
187        Ok(())
188    }
189
190    pub async fn command(
191        &self,
192        name: Option<&str>,
193        payload: Value,
194        timeout: Duration,
195    ) -> Result<Value, BridgeError> {
196        let id = Uuid::new_v4().to_string();
197
198        let cmd = match name {
199            Some(n) => CmdPayload::named(id.clone(), n, payload),
200            None => CmdPayload::new(id.clone(), payload),
201        };
202        let msg = WireMsg::Cmd(cmd);
203        let json = msg.to_json()?;
204
205        let (ack_tx, ack_rx) = oneshot::channel();
206        self.pending.lock().await.insert(id.clone(), ack_tx);
207
208        {
209            let clients = self.clients.lock().await;
210            if clients.is_empty() {
211                self.pending.lock().await.remove(&id);
212                return Err(BridgeError::no_clients(
213                    "no gauges connected — cannot send command",
214                ));
215            }
216
217            // Prefer clients that have reported their downstream link bound.
218            // Broadcasting to a relay whose CommBus isn't attached burns the
219            // full timeout on a socket that was never going to answer. When
220            // nothing has ever reported ready the flag carries no information
221            // (older relays don't send it), so fall back to every client.
222            let any_ready = clients.values().any(|c| c.ready);
223            let mut delivered = 0usize;
224            for client in clients.values() {
225                if any_ready && !client.ready {
226                    continue;
227                }
228                if client.tx.send(json.clone()).is_ok() {
229                    delivered += 1;
230                }
231            }
232
233            // Every target's writer half is gone: the sockets are dead but not
234            // yet unregistered. Fail now instead of waiting out the timeout —
235            // the caller can retry into a fresh connection immediately.
236            if delivered == 0 {
237                self.pending.lock().await.remove(&id);
238                return Err(BridgeError::transport(
239                    "no gauge connection accepted the command",
240                ));
241            }
242        }
243
244        let ack_result = tokio::time::timeout(timeout, ack_rx).await;
245
246        self.pending.lock().await.remove(&id);
247
248        match ack_result {
249            Ok(Ok(ack)) => {
250                if ack.ok {
251                    Ok(ack.response.unwrap_or(Value::Null))
252                } else {
253                    Err(BridgeError::application(
254                        ack.error.unwrap_or_else(|| "unknown error".into()),
255                    ))
256                }
257            }
258            Ok(Err(_)) => Err(BridgeError::transport(
259                "all gauge connections dropped before ack",
260            )),
261            Err(_) => Err(BridgeError::timeout(format!(
262                "no ack received within {timeout:?}"
263            ))),
264        }
265    }
266
267    pub async fn dispatch_ack(&self, ack: AckPayload) {
268        let tx = self.pending.lock().await.remove(&ack.id);
269        if let Some(tx) = tx {
270            let _ = tx.send(ack);
271        }
272    }
273
274    pub async fn send_to(&self, client_id: u64, json: String) -> Result<(), BridgeError> {
275        let clients = self.clients.lock().await;
276        let client = clients
277            .get(&client_id)
278            .ok_or_else(|| BridgeError::transport(format!("client {client_id} not found")))?;
279        client
280            .tx
281            .send(json)
282            .map_err(|_| BridgeError::transport(format!("client {client_id} channel closed")))
283    }
284
285    pub async fn broadcast(&self, json: String) -> Result<(), BridgeError> {
286        let clients = self.clients.lock().await;
287        if clients.is_empty() {
288            return Err(BridgeError::no_clients("no gauges connected"));
289        }
290        for client in clients.values() {
291            let _ = client.tx.send(json.clone());
292        }
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use infinity_bridge_wire::ErrorKind;
301
302    /// Long enough to prove nothing was delivered, short enough to keep the
303    /// suite quick — no ack is ever sent in these tests.
304    const NO_ACK: Duration = Duration::from_millis(50);
305
306    #[tokio::test]
307    async fn readiness_is_tracked_per_client() {
308        let hub = Hub::new(16);
309        let (tx_a, _rx_a) = mpsc::unbounded_channel();
310        let (tx_b, _rx_b) = mpsc::unbounded_channel();
311        let a = hub.register_client(tx_a).await;
312        let _b = hub.register_client(tx_b).await;
313
314        assert!(hub.is_connected().await);
315        assert!(!hub.is_ready().await, "a fresh client is not yet ready");
316
317        hub.set_client_ready(a, true).await;
318        assert!(hub.is_ready().await);
319
320        hub.set_client_ready(a, false).await;
321        assert!(!hub.is_ready().await);
322        assert!(hub.is_connected().await, "readiness is not connectedness");
323    }
324
325    #[tokio::test]
326    async fn a_command_skips_clients_that_are_not_ready() {
327        let hub = Hub::new(16);
328        let (tx_stale, mut rx_stale) = mpsc::unbounded_channel();
329        let (tx_live, mut rx_live) = mpsc::unbounded_channel();
330        let _stale = hub.register_client(tx_stale).await;
331        let live = hub.register_client(tx_live).await;
332        hub.set_client_ready(live, true).await;
333
334        let err = hub
335            .command(Some("ping"), Value::Null, NO_ACK)
336            .await
337            .expect_err("nothing acks in this test");
338        assert_eq!(err.kind(), ErrorKind::Timeout);
339
340        assert!(
341            rx_live.try_recv().is_ok(),
342            "the ready client got the command"
343        );
344        assert!(
345            rx_stale.try_recv().is_err(),
346            "a relay that never reported ready must not absorb the timeout budget"
347        );
348    }
349
350    #[tokio::test]
351    async fn a_command_goes_everywhere_while_readiness_is_unknown() {
352        // Relays predating the ready event never send one. Their silence must
353        // read as "unknown", not "unreachable", or they stop working entirely.
354        let hub = Hub::new(16);
355        let (tx_a, mut rx_a) = mpsc::unbounded_channel();
356        let (tx_b, mut rx_b) = mpsc::unbounded_channel();
357        hub.register_client(tx_a).await;
358        hub.register_client(tx_b).await;
359
360        let _ = hub.command(Some("ping"), Value::Null, NO_ACK).await;
361
362        assert!(rx_a.try_recv().is_ok());
363        assert!(rx_b.try_recv().is_ok());
364    }
365
366    #[tokio::test]
367    async fn a_command_fails_fast_when_every_connection_is_gone() {
368        // Sockets can be dead before the reaper has unregistered them. Waiting
369        // out the full timeout for a send that provably went nowhere is time
370        // the caller could have spent retrying into a fresh connection.
371        let hub = Hub::new(16);
372        let (tx, rx) = mpsc::unbounded_channel();
373        hub.register_client(tx).await;
374        drop(rx);
375
376        let start = tokio::time::Instant::now();
377        let err = hub
378            .command(Some("ping"), Value::Null, Duration::from_secs(30))
379            .await
380            .expect_err("the only receiver is gone");
381        assert_eq!(err.kind(), ErrorKind::Transport);
382        assert!(
383            start.elapsed() < Duration::from_secs(1),
384            "should not have waited out the timeout"
385        );
386    }
387
388    #[tokio::test]
389    async fn a_timed_out_command_leaves_no_pending_entry() {
390        let hub = Hub::new(16);
391        let (tx, _rx) = mpsc::unbounded_channel();
392        hub.register_client(tx).await;
393
394        let _ = hub.command(Some("ping"), Value::Null, NO_ACK).await;
395        assert!(hub.pending.lock().await.is_empty());
396    }
397
398    #[tokio::test(start_paused = true)]
399    async fn a_silent_client_is_reaped_and_named() {
400        let hub = Hub::new(16);
401        let (tx, _rx) = mpsc::unbounded_channel();
402        let id = hub.register_client(tx).await;
403
404        tokio::time::advance(Duration::from_secs(31)).await;
405        let dead = hub.reap_dead_clients(Duration::from_secs(30)).await;
406
407        assert_eq!(dead, vec![id], "the reaper names who it dropped");
408        assert!(!hub.is_connected().await);
409    }
410}