1use std::sync::Arc;
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use dashmap::DashMap;
27use serde::{Deserialize, Serialize};
28#[cfg(feature = "native")]
29use tokio::sync::mpsc;
30use tracing::{debug, warn};
31
32use crate::process::{Pid, ProcessState, ProcessTable};
33
34#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
40pub struct SubscriberId(pub u64);
41
42static SUBSCRIBER_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
44
45impl SubscriberId {
46 pub fn next() -> Self {
48 SubscriberId(SUBSCRIBER_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
49 }
50}
51
52#[derive(Clone)]
61pub enum SubscriberSink {
62 PidInbox(Pid),
64 #[cfg(feature = "native")]
72 ExternalStream(mpsc::Sender<Vec<u8>>),
73}
74
75impl std::fmt::Debug for SubscriberSink {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 SubscriberSink::PidInbox(pid) => write!(f, "PidInbox({pid})"),
79 #[cfg(feature = "native")]
80 SubscriberSink::ExternalStream(_) => write!(f, "ExternalStream(..)"),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Subscription {
90 pub topic: String,
92
93 pub subscriber_pid: Pid,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub filter: Option<String>,
99}
100
101pub struct TopicRouter {
116 subscriptions: DashMap<String, Vec<(SubscriberId, SubscriberSink)>>,
121
122 process_table: Arc<ProcessTable>,
124}
125
126impl TopicRouter {
127 pub fn new(process_table: Arc<ProcessTable>) -> Self {
129 Self {
130 subscriptions: DashMap::new(),
131 process_table,
132 }
133 }
134
135 pub fn subscribe_sink(&self, topic: &str, sink: SubscriberSink) -> SubscriberId {
141 if let SubscriberSink::PidInbox(pid) = &sink
144 && let Some(subs) = self.subscriptions.get(topic)
145 {
146 for (existing_id, existing) in subs.iter() {
147 if let SubscriberSink::PidInbox(p) = existing
148 && p == pid
149 {
150 debug!(pid = *pid, topic, "already subscribed");
151 return *existing_id;
152 }
153 }
154 }
155
156 let id = SubscriberId::next();
157 debug!(?sink, topic, id = id.0, "subscribing to topic");
158 self.subscriptions
159 .entry(topic.to_owned())
160 .or_default()
161 .push((id, sink));
162 id
163 }
164
165 pub fn subscribe(&self, pid: Pid, topic: &str) {
170 let _ = self.subscribe_sink(topic, SubscriberSink::PidInbox(pid));
171 }
172
173 pub fn unsubscribe(&self, pid: Pid, topic: &str) {
178 debug!(pid, topic, "unsubscribing pid from topic");
179 if let Some(mut subs) = self.subscriptions.get_mut(topic) {
180 subs.retain(|(_, sink)| !matches!(sink, SubscriberSink::PidInbox(p) if *p == pid));
181 }
182
183 self.subscriptions.retain(|_, subs| !subs.is_empty());
185 }
186
187 pub fn unsubscribe_id(&self, topic: &str, id: SubscriberId) {
193 debug!(topic, id = id.0, "unsubscribing id from topic");
194 if let Some(mut subs) = self.subscriptions.get_mut(topic) {
195 subs.retain(|(existing, _)| *existing != id);
196 }
197 self.subscriptions.retain(|_, subs| !subs.is_empty());
198 }
199
200 pub fn live_sinks(&self, topic: &str) -> Vec<(SubscriberId, SubscriberSink)> {
207 let mut live = Vec::new();
208 let mut dead_ids: Vec<SubscriberId> = Vec::new();
209
210 if let Some(subs) = self.subscriptions.get(topic) {
211 for (id, sink) in subs.iter() {
212 match sink {
213 SubscriberSink::PidInbox(pid) => {
214 if self.is_alive(*pid) {
215 live.push((*id, sink.clone()));
216 } else {
217 dead_ids.push(*id);
218 }
219 }
220 #[cfg(feature = "native")]
221 SubscriberSink::ExternalStream(tx) => {
222 if tx.is_closed() {
223 dead_ids.push(*id);
224 } else {
225 live.push((*id, sink.clone()));
226 }
227 }
228 }
229 }
230 }
231
232 if !dead_ids.is_empty() {
233 if let Some(mut subs) = self.subscriptions.get_mut(topic) {
234 subs.retain(|(id, _)| !dead_ids.contains(id));
235 }
236 warn!(
237 topic,
238 dead_count = dead_ids.len(),
239 "cleaned up dead subscribers"
240 );
241 }
242
243 live
244 }
245
246 pub fn live_subscribers(&self, topic: &str) -> Vec<Pid> {
252 self.live_sinks(topic)
253 .into_iter()
254 .filter_map(|(_, sink)| match sink {
255 SubscriberSink::PidInbox(pid) => Some(pid),
256 #[cfg(feature = "native")]
257 SubscriberSink::ExternalStream(_) => None,
258 })
259 .collect()
260 }
261
262 pub fn subscribers(&self, topic: &str) -> Vec<Pid> {
266 self.subscriptions
267 .get(topic)
268 .map(|subs| {
269 subs.iter()
270 .filter_map(|(_, sink)| match sink {
271 SubscriberSink::PidInbox(pid) => Some(*pid),
272 #[cfg(feature = "native")]
273 SubscriberSink::ExternalStream(_) => None,
274 })
275 .collect()
276 })
277 .unwrap_or_default()
278 }
279
280 pub fn list_topics(&self) -> Vec<(String, usize)> {
282 self.subscriptions
283 .iter()
284 .map(|entry| (entry.key().clone(), entry.value().len()))
285 .collect()
286 }
287
288 pub fn topics_for_pid(&self, pid: Pid) -> Vec<String> {
290 self.subscriptions
291 .iter()
292 .filter(|entry| {
293 entry
294 .value()
295 .iter()
296 .any(|(_, sink)| matches!(sink, SubscriberSink::PidInbox(p) if *p == pid))
297 })
298 .map(|entry| entry.key().clone())
299 .collect()
300 }
301
302 pub fn topic_count(&self) -> usize {
304 self.subscriptions.len()
305 }
306
307 pub fn has_subscribers(&self, topic: &str) -> bool {
309 self.subscriptions
310 .get(topic)
311 .is_some_and(|subs| !subs.is_empty())
312 }
313
314 pub fn unsubscribe_all(&self, pid: Pid) {
316 debug!(pid, "unsubscribing from all topics");
317 for mut entry in self.subscriptions.iter_mut() {
318 entry
319 .value_mut()
320 .retain(|(_, sink)| !matches!(sink, SubscriberSink::PidInbox(p) if *p == pid));
321 }
322 self.subscriptions.retain(|_, subs| !subs.is_empty());
324 }
325
326 fn is_alive(&self, pid: Pid) -> bool {
328 self.process_table
329 .get(pid)
330 .is_some_and(|entry| !matches!(entry.state, ProcessState::Exited(_)))
331 }
332}
333
334#[cfg(all(test, feature = "native"))]
335mod tests {
336 use super::*;
337 use crate::capability::AgentCapabilities;
338 use crate::process::{ProcessEntry, ResourceUsage};
339 use tokio_util::sync::CancellationToken;
340
341 fn make_router_with_processes(count: usize) -> (TopicRouter, Vec<Pid>) {
342 let table = Arc::new(ProcessTable::new(64));
343 let mut pids = Vec::new();
344 for i in 0..count {
345 let entry = ProcessEntry {
346 pid: 0,
347 agent_id: format!("agent-{i}"),
348 state: ProcessState::Running,
349 capabilities: AgentCapabilities::default(),
350 resource_usage: ResourceUsage::default(),
351 cancel_token: CancellationToken::new(),
352 parent_pid: None,
353 };
354 let pid = table.insert(entry).unwrap();
355 pids.push(pid);
356 }
357 (TopicRouter::new(table), pids)
358 }
359
360 #[test]
361 fn subscribe_and_list() {
362 let (router, pids) = make_router_with_processes(2);
363 router.subscribe(pids[0], "build");
364 router.subscribe(pids[1], "build");
365
366 let subs = router.subscribers("build");
367 assert_eq!(subs.len(), 2);
368 assert!(subs.contains(&pids[0]));
369 assert!(subs.contains(&pids[1]));
370 }
371
372 #[test]
373 fn subscribe_idempotent() {
374 let (router, pids) = make_router_with_processes(1);
375 router.subscribe(pids[0], "build");
376 router.subscribe(pids[0], "build");
377
378 let subs = router.subscribers("build");
379 assert_eq!(subs.len(), 1);
380 }
381
382 #[test]
383 fn unsubscribe() {
384 let (router, pids) = make_router_with_processes(2);
385 router.subscribe(pids[0], "build");
386 router.subscribe(pids[1], "build");
387
388 router.unsubscribe(pids[0], "build");
389
390 let subs = router.subscribers("build");
391 assert_eq!(subs.len(), 1);
392 assert_eq!(subs[0], pids[1]);
393 }
394
395 #[test]
396 fn unsubscribe_nonexistent_is_noop() {
397 let (router, _pids) = make_router_with_processes(0);
398 router.unsubscribe(999, "build"); assert!(router.subscribers("build").is_empty());
400 }
401
402 #[test]
403 fn unsubscribe_removes_empty_topic() {
404 let (router, pids) = make_router_with_processes(1);
405 router.subscribe(pids[0], "build");
406 router.unsubscribe(pids[0], "build");
407
408 assert_eq!(router.topic_count(), 0);
409 }
410
411 #[test]
412 fn list_topics() {
413 let (router, pids) = make_router_with_processes(2);
414 router.subscribe(pids[0], "build");
415 router.subscribe(pids[0], "test");
416 router.subscribe(pids[1], "build");
417
418 let topics = router.list_topics();
419 assert_eq!(topics.len(), 2);
420
421 let build_count = topics
422 .iter()
423 .find(|(t, _)| t == "build")
424 .map(|(_, c)| *c)
425 .unwrap();
426 assert_eq!(build_count, 2);
427 }
428
429 #[test]
430 fn topics_for_pid() {
431 let (router, pids) = make_router_with_processes(1);
432 router.subscribe(pids[0], "build");
433 router.subscribe(pids[0], "test");
434 router.subscribe(pids[0], "deploy");
435
436 let topics = router.topics_for_pid(pids[0]);
437 assert_eq!(topics.len(), 3);
438 }
439
440 #[test]
441 fn has_subscribers() {
442 let (router, pids) = make_router_with_processes(1);
443 assert!(!router.has_subscribers("build"));
444
445 router.subscribe(pids[0], "build");
446 assert!(router.has_subscribers("build"));
447 }
448
449 #[test]
450 fn live_subscribers_filters_dead() {
451 let table = Arc::new(ProcessTable::new(64));
452
453 let entry1 = ProcessEntry {
455 pid: 0,
456 agent_id: "alive".to_owned(),
457 state: ProcessState::Running,
458 capabilities: AgentCapabilities::default(),
459 resource_usage: ResourceUsage::default(),
460 cancel_token: CancellationToken::new(),
461 parent_pid: None,
462 };
463 let pid1 = table.insert(entry1).unwrap();
464
465 let entry2 = ProcessEntry {
467 pid: 0,
468 agent_id: "dead".to_owned(),
469 state: ProcessState::Running,
470 capabilities: AgentCapabilities::default(),
471 resource_usage: ResourceUsage::default(),
472 cancel_token: CancellationToken::new(),
473 parent_pid: None,
474 };
475 let pid2 = table.insert(entry2).unwrap();
476 table.update_state(pid2, ProcessState::Exited(0)).unwrap();
477
478 let router = TopicRouter::new(table);
479 router.subscribe(pid1, "build");
480 router.subscribe(pid2, "build");
481
482 assert_eq!(router.subscribers("build").len(), 2);
484
485 let live = router.live_subscribers("build");
487 assert_eq!(live.len(), 1);
488 assert_eq!(live[0], pid1);
489
490 assert_eq!(router.subscribers("build").len(), 1);
492 }
493
494 #[test]
495 fn unsubscribe_all() {
496 let (router, pids) = make_router_with_processes(2);
497 router.subscribe(pids[0], "build");
498 router.subscribe(pids[0], "test");
499 router.subscribe(pids[1], "build");
500
501 router.unsubscribe_all(pids[0]);
502
503 assert!(router.topics_for_pid(pids[0]).is_empty());
504 assert_eq!(router.subscribers("build").len(), 1);
505 assert_eq!(router.topic_count(), 1); }
507
508 #[test]
509 fn subscription_serde_roundtrip() {
510 let sub = Subscription {
511 topic: "build".into(),
512 subscriber_pid: 42,
513 filter: Some("status:*".into()),
514 };
515 let json = serde_json::to_string(&sub).unwrap();
516 let restored: Subscription = serde_json::from_str(&json).unwrap();
517 assert_eq!(restored.topic, "build");
518 assert_eq!(restored.subscriber_pid, 42);
519 assert_eq!(restored.filter, Some("status:*".into()));
520 }
521
522 #[test]
523 fn external_stream_sink_lives_alongside_pid_sinks() {
524 let (router, pids) = make_router_with_processes(1);
525 router.subscribe(pids[0], "events");
526
527 let (tx, _rx) = mpsc::channel::<Vec<u8>>(4);
528 let ext_id = router.subscribe_sink("events", SubscriberSink::ExternalStream(tx));
529
530 let sinks = router.live_sinks("events");
531 assert_eq!(sinks.len(), 2);
532 assert!(sinks.iter().any(|(id, _)| *id == ext_id));
533 assert_eq!(router.subscribers("events"), vec![pids[0]]);
535 }
536
537 #[test]
538 fn unsubscribe_id_removes_only_target() {
539 let (router, pids) = make_router_with_processes(1);
540 router.subscribe(pids[0], "events");
541 let (tx, _rx) = mpsc::channel::<Vec<u8>>(4);
542 let ext_id = router.subscribe_sink("events", SubscriberSink::ExternalStream(tx));
543
544 router.unsubscribe_id("events", ext_id);
545
546 let sinks = router.live_sinks("events");
547 assert_eq!(sinks.len(), 1);
548 assert!(matches!(sinks[0].1, SubscriberSink::PidInbox(_)));
549 }
550
551 #[test]
552 fn closed_external_stream_is_cleaned_on_live_query() {
553 let (router, _) = make_router_with_processes(0);
554 let (tx, rx) = mpsc::channel::<Vec<u8>>(4);
555 router.subscribe_sink("events", SubscriberSink::ExternalStream(tx));
556 drop(rx); let sinks = router.live_sinks("events");
559 assert!(sinks.is_empty(), "closed external streams must be pruned");
560 }
561
562 #[test]
563 fn subscriber_id_is_unique_and_monotonic() {
564 let a = SubscriberId::next();
565 let b = SubscriberId::next();
566 assert_ne!(a, b);
567 assert!(b.0 > a.0);
568 }
569}