Skip to main content

whycodes_core/
swarm_hub.rs

1//! In-process mailbox for swarm workers (DM + broadcast).
2//!
3//! Process-local. Not a negotiation protocol — just a queue the worker loop
4//! drains between turns.
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8
9/// One message on the swarm bus.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SwarmMessage {
12    pub from: String,
13    pub to: String,
14    pub text: String,
15}
16
17/// Toast / log hook for the parent UI.
18pub type SwarmMessageListener = Arc<dyn Fn(SwarmMessage) + Send + Sync>;
19
20/// Shared mailbox for one swarm run.
21#[derive(Clone, Default)]
22pub struct SwarmHub {
23    inner: Arc<SwarmHubInner>,
24}
25
26struct SwarmHubInner {
27    /// Inbox per recipient id (`parent`, `worker-0`, …).
28    inboxes: Mutex<HashMap<String, Vec<SwarmMessage>>>,
29    listener: Mutex<Option<SwarmMessageListener>>,
30}
31
32impl Default for SwarmHubInner {
33    fn default() -> Self {
34        Self {
35            inboxes: Mutex::new(HashMap::new()),
36            listener: Mutex::new(None),
37        }
38    }
39}
40
41impl std::fmt::Debug for SwarmHub {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let n = recover_lock(self.inner.inboxes.lock())
44            .values()
45            .map(|v| v.len())
46            .sum::<usize>();
47        f.debug_struct("SwarmHub").field("queued", &n).finish()
48    }
49}
50
51impl SwarmHub {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Register a participant so broadcasts reach them even before they send.
57    pub fn ensure(&self, id: &str) {
58        recover_lock(self.inner.inboxes.lock())
59            .entry(id.to_string())
60            .or_default();
61    }
62
63    pub fn set_listener(&self, listener: Option<SwarmMessageListener>) {
64        *recover_lock(self.inner.listener.lock()) = listener;
65    }
66
67    /// Deliver `text` from `from` to `to` (`all` broadcasts to every known inbox
68    /// plus `parent`). A direct message to an unregistered id is refused with
69    /// the known ids, so a typo is not silently queued where nobody reads.
70    pub fn send(&self, from: &str, to: &str, text: &str) -> Result<(), String> {
71        let text = text.trim();
72        if text.is_empty() {
73            return Ok(());
74        }
75        let mut boxes = recover_lock(self.inner.inboxes.lock());
76        let targets: Vec<String> = if to.eq_ignore_ascii_case("all") {
77            let mut ids: Vec<String> = boxes.keys().cloned().collect();
78            if !ids.iter().any(|k| k == "parent") {
79                ids.push("parent".into());
80            }
81            ids.into_iter().filter(|id| id != from).collect()
82        } else if boxes.contains_key(to) {
83            vec![to.to_string()]
84        } else {
85            let mut known: Vec<&str> = boxes.keys().map(String::as_str).collect();
86            known.sort_unstable();
87            return Err(format!(
88                "unknown recipient `{to}`; known: all, {}",
89                known.join(", ")
90            ));
91        };
92        for dest in targets {
93            let msg = SwarmMessage {
94                from: from.to_string(),
95                to: dest.clone(),
96                text: text.to_string(),
97            };
98            boxes.entry(dest).or_default().push(msg.clone());
99            self.emit(&msg);
100        }
101        Ok(())
102    }
103
104    fn emit(&self, msg: &SwarmMessage) {
105        if let Some(ref f) = *recover_lock(self.inner.listener.lock()) {
106            f(msg.clone());
107        }
108    }
109
110    /// Take every pending message for `id`. The inbox stays registered so
111    /// later broadcasts still reach it.
112    pub fn drain(&self, id: &str) -> Vec<SwarmMessage> {
113        recover_lock(self.inner.inboxes.lock())
114            .get_mut(id)
115            .map(std::mem::take)
116            .unwrap_or_default()
117    }
118}
119
120pub(crate) fn recover_lock<T>(res: std::sync::LockResult<T>) -> T {
121    match res {
122        Ok(g) => g,
123        Err(p) => p.into_inner(),
124    }
125}
126
127#[cfg(test)]
128impl SwarmHub {
129    pub(crate) fn poison_inboxes(&self) {
130        poison_mutex(&self.inner.inboxes);
131    }
132    pub(crate) fn poison_listener(&self) {
133        poison_mutex(&self.inner.listener);
134    }
135}
136
137#[cfg(test)]
138fn poison_mutex<T>(m: &Mutex<T>) {
139    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
140        let _g = m.lock().unwrap();
141        panic!("poison");
142    }));
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::sync::{Arc, Mutex};
149
150    #[test]
151    fn send_broadcast_drain_and_listener() {
152        let hub = SwarmHub::new();
153        hub.ensure("parent");
154        hub.ensure("w0");
155        hub.send("w0", "parent", "   ").unwrap();
156        hub.send("w0", "parent", "hello").unwrap();
157        let got = hub.drain("parent");
158        assert_eq!(got.len(), 1);
159        assert_eq!(got[0].text, "hello");
160        assert!(hub.drain("parent").is_empty());
161
162        let seen = Arc::new(Mutex::new(Vec::new()));
163        let seen2 = Arc::clone(&seen);
164        hub.set_listener(Some(Arc::new(move |msg: SwarmMessage| {
165            seen2.lock().unwrap().push(msg.text);
166        })));
167        hub.send("parent", "all", "broadcast").unwrap();
168        assert!(!hub.drain("w0").is_empty());
169        assert!(!seen.lock().unwrap().is_empty());
170        hub.set_listener(None);
171        assert!(format!("{hub:?}").contains("SwarmHub"));
172    }
173
174    #[test]
175    fn broadcast_still_reaches_an_inbox_after_it_was_drained() {
176        let hub = SwarmHub::new();
177        hub.ensure("parent");
178        hub.ensure("worker-0");
179        hub.ensure("worker-1");
180        hub.send("worker-0", "worker-1", "first").unwrap();
181        assert_eq!(hub.drain("worker-1").len(), 1);
182        hub.send("worker-0", "all", "second").unwrap();
183        let got = hub.drain("worker-1");
184        assert_eq!(got.len(), 1);
185        assert_eq!(got[0].text, "second");
186        assert!(hub.drain("worker-0").is_empty(), "sender skips itself");
187        assert_eq!(hub.drain("parent").len(), 1);
188    }
189}