Skip to main content

clawft_kernel/
topic.rs

1//! Pub/sub topic routing for kernel IPC.
2//!
3//! The [`TopicRouter`] manages topic subscriptions and delivers
4//! published messages to all subscribers. Topics are arbitrary
5//! strings (e.g. "build-status", "test-results", "agent.spawned").
6//!
7//! Subscriptions are stored in a [`DashMap`] for lock-free concurrent
8//! access. Dead subscribers (processes that have exited) are lazily
9//! cleaned up during publish.
10//!
11//! # Subscriber kinds
12//!
13//! Subscriptions are generalized via [`SubscriberSink`]. Two sink
14//! kinds exist today:
15//!
16//! - [`SubscriberSink::PidInbox`] — an in-kernel agent identified by
17//!   `Pid`. The A2A router delivers to its `mpsc` inbox.
18//! - [`SubscriberSink::ExternalStream`] — an external Unix-socket
19//!   client (Python bridge, another Claude Code session) that holds
20//!   the read end of a channel the daemon writes one JSON line per
21//!   published message into.
22
23use 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/// Unique identifier for an external streaming subscription.
35///
36/// Used so the daemon can unsubscribe a specific external client
37/// when its socket disconnects, without touching other subscribers
38/// on the same topic.
39#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
40pub struct SubscriberId(pub u64);
41
42/// Global monotonic counter for assigning [`SubscriberId`]s.
43static SUBSCRIBER_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
44
45impl SubscriberId {
46    /// Allocate a fresh, process-unique subscriber id.
47    pub fn next() -> Self {
48        SubscriberId(SUBSCRIBER_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
49    }
50}
51
52/// A subscription sink — where a delivered topic message goes.
53///
54/// See the module-level docs for a description of each variant.
55///
56/// [`SubscriberSink::ExternalStream`] requires the `native` feature
57/// (tokio mpsc). Without `native` (e.g. wasm32-unknown-unknown /
58/// `--no-default-features`), only [`SubscriberSink::PidInbox`] is
59/// available — WEFT-114.
60#[derive(Clone)]
61pub enum SubscriberSink {
62    /// An in-kernel process inbox (existing PID-based delivery).
63    PidInbox(Pid),
64    /// An external streaming client. The daemon serializes each
65    /// published [`crate::ipc::KernelMessage`] as a JSON line (byte
66    /// buffer including a trailing `\n`) and feeds it into this
67    /// sender. A full channel is treated as back-pressure and the
68    /// message is dropped for that client.
69    ///
70    /// Only available under the `native` feature (tokio runtime).
71    #[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/// A topic subscription entry (wire-format; describes a PID-based
86/// subscription only — external stream sinks are ephemeral and not
87/// persisted in this shape).
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Subscription {
90    /// The topic pattern this subscription matches.
91    pub topic: String,
92
93    /// The subscribing process's PID.
94    pub subscriber_pid: Pid,
95
96    /// Optional message filter (future use).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub filter: Option<String>,
99}
100
101/// Pub/sub topic router for kernel IPC.
102///
103/// Manages subscriptions as a mapping from topic name to the list of
104/// subscribers. A subscriber can be an in-kernel PID inbox or an
105/// external streaming client ([`SubscriberSink`]). Uses [`DashMap`] for
106/// lock-free concurrent access.
107///
108/// # Dead subscriber cleanup
109///
110/// When a message is published, the router checks each PID subscriber's
111/// state in the process table. PID subscribers whose processes have
112/// exited are automatically removed from the subscription list (lazy
113/// cleanup). External streams that have closed their receiver are
114/// removed on the next publish attempt.
115pub struct TopicRouter {
116    /// Topic -> list of sinks subscribed to it.
117    ///
118    /// Stored as `(SubscriberId, SubscriberSink)` pairs so the daemon
119    /// can unsubscribe a specific external client by id.
120    subscriptions: DashMap<String, Vec<(SubscriberId, SubscriberSink)>>,
121
122    /// Process table for checking subscriber state.
123    process_table: Arc<ProcessTable>,
124}
125
126impl TopicRouter {
127    /// Create a new topic router.
128    pub fn new(process_table: Arc<ProcessTable>) -> Self {
129        Self {
130            subscriptions: DashMap::new(),
131            process_table,
132        }
133    }
134
135    /// Subscribe a sink to a topic.
136    ///
137    /// Returns the assigned [`SubscriberId`]. For PID inbox sinks, if
138    /// the same PID is already subscribed to the topic the existing
139    /// subscription is kept and its id is returned (idempotent).
140    pub fn subscribe_sink(&self, topic: &str, sink: SubscriberSink) -> SubscriberId {
141        // For PidInbox, preserve the previous idempotent behaviour so
142        // repeated kernel-side subscribe calls do not duplicate inboxes.
143        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    /// Subscribe a process to a topic (convenience for PID inboxes).
166    ///
167    /// If the process is already subscribed, this is a no-op — the
168    /// existing subscription is preserved.
169    pub fn subscribe(&self, pid: Pid, topic: &str) {
170        let _ = self.subscribe_sink(topic, SubscriberSink::PidInbox(pid));
171    }
172
173    /// Unsubscribe a process from a topic.
174    ///
175    /// If the process is not subscribed, this is a no-op.
176    /// Empty subscription lists are removed.
177    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        // Clean up empty topics
184        self.subscriptions.retain(|_, subs| !subs.is_empty());
185    }
186
187    /// Unsubscribe a specific subscriber (by id) from a topic.
188    ///
189    /// Use this to tear down external streaming subscriptions on
190    /// disconnect without affecting other subscribers on the same
191    /// topic.
192    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    /// Get the list of live sinks for a topic.
201    ///
202    /// Performs lazy cleanup: removes PID sinks whose processes have
203    /// exited and [`SubscriberSink::ExternalStream`] sinks whose
204    /// receiver has been dropped. Returns a cloned list safe to use
205    /// outside the lock.
206    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    /// Get the list of running PID subscribers for a topic.
247    ///
248    /// Convenience wrapper around [`Self::live_sinks`] that returns
249    /// only in-kernel agent PIDs. External streaming subscribers are
250    /// handled separately by the caller via [`Self::live_sinks`].
251    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    /// Get all PID subscribers for a topic (including potentially dead ones).
263    ///
264    /// Use [`TopicRouter::live_subscribers`] for a filtered list.
265    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    /// List all topics with their subscriber counts.
281    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    /// List all topics a specific PID is subscribed to.
289    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    /// Get the total number of active topics.
303    pub fn topic_count(&self) -> usize {
304        self.subscriptions.len()
305    }
306
307    /// Check whether a topic has any subscribers.
308    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    /// Remove all subscriptions for a PID (used during process cleanup).
315    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        // Clean up empty topics
323        self.subscriptions.retain(|_, subs| !subs.is_empty());
324    }
325
326    /// Check whether a PID corresponds to an alive process.
327    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"); // Should not panic
399        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        // Create a running process
454        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        // Create a dead process
466        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        // All subscribers includes dead
483        assert_eq!(router.subscribers("build").len(), 2);
484
485        // Live subscribers excludes dead and cleans up
486        let live = router.live_subscribers("build");
487        assert_eq!(live.len(), 1);
488        assert_eq!(live[0], pid1);
489
490        // After cleanup, subscribers list is also cleaned
491        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); // "test" removed (empty)
506    }
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        // PID subscribers view excludes the external stream
534        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); // close the receiver
557
558        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}