Skip to main content

monocoque_core/
router.rs

1//! ROUTER Hub (Phase 2.1)
2//!
3//! Goals:
4//! - Runtime-agnostic async loop (`flume::select`!, no tokio)
5//! - Strict types: `RouterCmd` has envelope, `PeerCmd` is body-only
6//! - Envelope normalization:
7//!   - inbound (actor->user) is normalized elsewhere to [ID, Empty, Body...]
8//!   - outbound (user->hub) accepts [ID, (Empty), Body...] in Standard mode
9//! - Load balancer mode: round-robin dispatch when no explicit routing id is used
10//! - "Ghost peer" self-heal: stale IDs removed from rr list when detected
11
12use bytes::Bytes;
13use flume::{Receiver, Sender};
14use std::collections::HashMap;
15use std::collections::hash_map::RandomState;
16use std::sync::Arc;
17
18/// Map keyed by peer-reported routing identity.
19///
20/// The key is attacker-controlled (a peer announces its own routing id), so
21/// this uses the per-process randomly seeded [`RandomState`] (`SipHash`) rather
22/// than a fixed-seed hasher, to resist hash-flooding.
23type PeerMap<V> = HashMap<Bytes, V, RandomState>;
24
25/// Commands sent from application to Router Hub
26#[derive(Debug)]
27pub enum RouterCmd {
28    /// Send a message (with routing envelope in Standard mode, or body-only in LB mode)
29    SendMessage(Vec<Bytes>),
30    /// Close all peers
31    Close,
32}
33
34/// Commands sent from Hub -> Peer (body only; hub strips any envelope)
35#[derive(Debug)]
36pub enum PeerCmd {
37    /// Send a message body to the peer.
38    ///
39    /// The frames are shared via `Arc` so a fan-out (PUB/SUB) hands every
40    /// matching peer the same allocation instead of cloning a fresh
41    /// `Vec<Bytes>` per peer.
42    SendBody(Arc<Vec<Bytes>>),
43    Close,
44}
45
46/// Events sent from Peer -> Hub (lifecycle)
47#[derive(Debug)]
48pub enum HubEvent {
49    PeerUp {
50        routing_id: Bytes, // Owned + stable
51        tx: Sender<PeerCmd>,
52    },
53    PeerDown {
54        routing_id: Bytes,
55    },
56}
57
58/// Router behavior modes.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RouterBehavior {
61    /// Standard ROUTER: expects user outbound as [ID, (Empty), Body...]
62    /// If ID is unknown, drop silently (libzmq behavior).
63    Standard,
64
65    /// Load balancer: expects user outbound as [Body...]
66    /// Hub picks a peer using strict-ish RR.
67    LoadBalancer,
68}
69
70/// The Router Supervisor.
71///
72/// This runs once per ROUTER socket (listener), and coordinates N peers.
73pub struct RouterHub {
74    // routing table (keyed by attacker-controlled identity; seeded hasher)
75    peers: PeerMap<Sender<PeerCmd>>,
76
77    // LB rotation list (routing IDs)
78    lb_list: Vec<Bytes>,
79    lb_cursor: usize,
80    behavior: RouterBehavior,
81
82    // channels
83    hub_rx: Receiver<HubEvent>,
84    user_tx_rx: Receiver<RouterCmd>,
85}
86
87impl RouterHub {
88    #[must_use]
89    pub fn new(
90        hub_rx: Receiver<HubEvent>,
91        user_tx_rx: Receiver<RouterCmd>,
92        behavior: RouterBehavior,
93    ) -> Self {
94        Self {
95            peers: PeerMap::default(),
96            lb_list: Vec::new(),
97            lb_cursor: 0,
98            behavior,
99            hub_rx,
100            user_tx_rx,
101        }
102    }
103
104    pub async fn run(mut self) {
105        use futures::FutureExt;
106        use futures::select;
107
108        loop {
109            // Use futures::select! for runtime-agnostic multiplexing
110            select! {
111                msg = self.hub_rx.recv_async().fuse() => {
112                    match msg {
113                        Ok(ev) => self.handle_peer_event(ev),
114                        Err(_) => break, // channel closed
115                    }
116                }
117                msg = self.user_tx_rx.recv_async().fuse() => {
118                    match msg {
119                        Ok(cmd) => self.handle_user_cmd(cmd),
120                        Err(_) => break, // channel closed
121                    }
122                }
123            }
124        }
125
126        // Best-effort: close all peers on hub shutdown.
127        for tx in self.peers.values() {
128            let _ = tx.send(PeerCmd::Close);
129        }
130    }
131
132    fn handle_peer_event(&mut self, event: HubEvent) {
133        match event {
134            HubEvent::PeerUp { routing_id, tx } => {
135                // Strict dedup: if ID exists, remove it from lb_list first to prevent drift.
136                if self.peers.contains_key(&routing_id)
137                    && let Some(pos) = self.lb_list.iter().position(|x| x == &routing_id)
138                {
139                    self.lb_list.remove(pos);
140                    if self.lb_cursor >= self.lb_list.len() {
141                        self.lb_cursor = 0;
142                    }
143                }
144
145                // Move routing_id into lb_list, clone for peers map
146                self.lb_list.push(routing_id.clone());
147                self.peers.insert(routing_id, tx);
148            }
149
150            HubEvent::PeerDown { routing_id } => {
151                self.peers.remove(&routing_id);
152
153                // Remove from LB list (O(N) but churn is not hot-path).
154                if let Some(pos) = self.lb_list.iter().position(|x| x == &routing_id) {
155                    self.lb_list.remove(pos);
156                    if self.lb_cursor >= self.lb_list.len() {
157                        self.lb_cursor = 0;
158                    }
159                }
160            }
161        }
162    }
163
164    fn handle_user_cmd(&mut self, cmd: RouterCmd) {
165        match cmd {
166            RouterCmd::SendMessage(parts) => self.route_outbound(parts),
167            RouterCmd::Close => {
168                // broadcast close to peers
169                for tx in self.peers.values() {
170                    let _ = tx.send(PeerCmd::Close);
171                }
172            }
173        }
174    }
175
176    /// Self-healing Round Robin peer selection.
177    ///
178    /// Returns a routing id that is present in `peers`, while repairing stale entries in `lb_list`.
179    fn pick_rr_peer(&mut self) -> Option<Bytes> {
180        let mut attempts = 0usize;
181        let max_attempts = self.lb_list.len();
182
183        while !self.lb_list.is_empty() && attempts <= max_attempts {
184            if self.lb_cursor >= self.lb_list.len() {
185                self.lb_cursor = 0;
186            }
187
188            let id = self.lb_list[self.lb_cursor].clone();
189            // advance cursor for next pick
190            self.lb_cursor = (self.lb_cursor + 1) % self.lb_list.len();
191
192            if self.peers.contains_key(&id) {
193                return Some(id);
194            }
195
196            // stale entry => repair
197            if let Some(pos) = self.lb_list.iter().position(|x| x == &id) {
198                self.lb_list.remove(pos);
199                // cursor might now be out of bounds; loop header fixes it.
200            }
201
202            attempts += 1;
203        }
204
205        None
206    }
207
208    fn route_outbound(&mut self, mut parts: Vec<Bytes>) {
209        if parts.is_empty() {
210            return;
211        }
212
213        match self.behavior {
214            RouterBehavior::Standard => {
215                // Expect: [ID, (Empty), Body...]
216                // NOTE: `remove(0)` is O(n), but this is hub-path, not IO hot loop.
217                let target_id = parts.remove(0);
218
219                // Normalize: drop optional empty delimiter frame
220                if !parts.is_empty() && parts[0].is_empty() {
221                    parts.remove(0);
222                }
223
224                if let Some(tx) = self.peers.get(&target_id) {
225                    let _ = tx.send(PeerCmd::SendBody(Arc::new(parts)));
226                } else {
227                    // ZMQ behavior: silently drop if unknown id
228                }
229            }
230
231            RouterBehavior::LoadBalancer => {
232                // Expect: [Body...]
233                if let Some(id) = self.pick_rr_peer() {
234                    if let Some(tx) = self.peers.get(&id) {
235                        let _ = tx.send(PeerCmd::SendBody(Arc::new(parts)));
236                    }
237                } else {
238                    // No peers available: drop for now (backpressure elsewhere)
239                }
240            }
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::time::Duration;
249
250    fn b(s: &str) -> Bytes {
251        Bytes::copy_from_slice(s.as_bytes())
252    }
253
254    /// Receive the next `PeerCmd` body within a timeout; None on timeout/close.
255    async fn recv_body(rx: &Receiver<PeerCmd>) -> Option<Vec<Bytes>> {
256        match crate::rt::timeout(Duration::from_secs(1), rx.recv_async()).await {
257            Ok(Ok(PeerCmd::SendBody(parts))) => Some((*parts).clone()),
258            _ => None,
259        }
260    }
261
262    /// True if no message body arrives within a short window. A timeout or a
263    /// closed channel both count as "no body"; only an actual `SendBody` fails.
264    async fn expect_no_body(rx: &Receiver<PeerCmd>) -> bool {
265        !matches!(
266            crate::rt::timeout(Duration::from_millis(150), rx.recv_async()).await,
267            Ok(Ok(PeerCmd::SendBody(_)))
268        )
269    }
270
271    #[test]
272    fn standard_routes_by_id_strips_envelope_and_drops_unknown() {
273        crate::rt::LocalRuntime::new().unwrap().block_on(async {
274            let (hub_tx, hub_rx) = flume::unbounded::<HubEvent>();
275            let (user_tx, user_rx) = flume::unbounded::<RouterCmd>();
276            let hub = RouterHub::new(hub_rx, user_rx, RouterBehavior::Standard);
277            let handle = crate::rt::spawn(hub.run());
278
279            let (peer_a_tx, peer_a_rx) = flume::unbounded::<PeerCmd>();
280            hub_tx
281                .send(HubEvent::PeerUp {
282                    routing_id: b("A"),
283                    tx: peer_a_tx,
284                })
285                .unwrap();
286            // Let the hub register the peer before routing to it.
287            crate::rt::sleep(Duration::from_millis(30)).await;
288
289            // [ID, empty, body] -> peer receives [body] (id + delimiter stripped).
290            user_tx
291                .send(RouterCmd::SendMessage(vec![
292                    b("A"),
293                    Bytes::new(),
294                    b("hello"),
295                ]))
296                .unwrap();
297            assert_eq!(recv_body(&peer_a_rx).await, Some(vec![b("hello")]));
298
299            // Unknown id is silently dropped.
300            user_tx
301                .send(RouterCmd::SendMessage(vec![b("Z"), b("payload")]))
302                .unwrap();
303            assert!(
304                expect_no_body(&peer_a_rx).await,
305                "unknown id must be dropped"
306            );
307
308            drop(hub_tx);
309            drop(user_tx);
310            crate::rt::join(handle).await;
311        });
312    }
313
314    #[test]
315    fn load_balancer_round_robins_across_peers() {
316        crate::rt::LocalRuntime::new().unwrap().block_on(async {
317            let (hub_tx, hub_rx) = flume::unbounded::<HubEvent>();
318            let (user_tx, user_rx) = flume::unbounded::<RouterCmd>();
319            let hub = RouterHub::new(hub_rx, user_rx, RouterBehavior::LoadBalancer);
320            let handle = crate::rt::spawn(hub.run());
321
322            let (peer_a_tx, peer_a_rx) = flume::unbounded::<PeerCmd>();
323            let (peer_b_tx, peer_b_rx) = flume::unbounded::<PeerCmd>();
324            hub_tx
325                .send(HubEvent::PeerUp {
326                    routing_id: b("A"),
327                    tx: peer_a_tx,
328                })
329                .unwrap();
330            hub_tx
331                .send(HubEvent::PeerUp {
332                    routing_id: b("B"),
333                    tx: peer_b_tx,
334                })
335                .unwrap();
336            crate::rt::sleep(Duration::from_millis(30)).await;
337
338            // Two sends should hit the two peers, one each (round robin).
339            user_tx.send(RouterCmd::SendMessage(vec![b("m1")])).unwrap();
340            user_tx.send(RouterCmd::SendMessage(vec![b("m2")])).unwrap();
341
342            let got_a = recv_body(&peer_a_rx).await;
343            let got_b = recv_body(&peer_b_rx).await;
344            assert!(
345                got_a.is_some() && got_b.is_some(),
346                "each peer should receive exactly one message under round robin"
347            );
348            let mut bodies = vec![got_a.unwrap(), got_b.unwrap()];
349            bodies.sort();
350            assert_eq!(bodies, vec![vec![b("m1")], vec![b("m2")]]);
351
352            drop(hub_tx);
353            drop(user_tx);
354            crate::rt::join(handle).await;
355        });
356    }
357
358    #[test]
359    fn peer_down_stops_delivery() {
360        crate::rt::LocalRuntime::new().unwrap().block_on(async {
361            let (hub_tx, hub_rx) = flume::unbounded::<HubEvent>();
362            let (user_tx, user_rx) = flume::unbounded::<RouterCmd>();
363            let hub = RouterHub::new(hub_rx, user_rx, RouterBehavior::Standard);
364            let handle = crate::rt::spawn(hub.run());
365
366            let (peer_a_tx, peer_a_rx) = flume::unbounded::<PeerCmd>();
367            hub_tx
368                .send(HubEvent::PeerUp {
369                    routing_id: b("A"),
370                    tx: peer_a_tx,
371                })
372                .unwrap();
373            crate::rt::sleep(Duration::from_millis(30)).await;
374            hub_tx
375                .send(HubEvent::PeerDown { routing_id: b("A") })
376                .unwrap();
377            crate::rt::sleep(Duration::from_millis(30)).await;
378
379            user_tx
380                .send(RouterCmd::SendMessage(vec![b("A"), b("late")]))
381                .unwrap();
382            assert!(
383                expect_no_body(&peer_a_rx).await,
384                "a downed peer must not receive messages"
385            );
386
387            drop(hub_tx);
388            drop(user_tx);
389            crate::rt::join(handle).await;
390        });
391    }
392}