Skip to main content

clawft_kernel/
named_pipe.rs

1//! Named pipes for persistent IPC channels.
2//!
3//! [`NamedPipeRegistry`] manages persistent named communication channels
4//! that survive agent restarts. Pipes support multiple senders (fan-in)
5//! delivering to a single receiver endpoint.
6
7use std::sync::RwLock;
8use std::sync::atomic::{AtomicU32, Ordering};
9use std::time::{Duration, Instant};
10
11use chrono::{DateTime, Utc};
12use dashmap::DashMap;
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc;
15
16use crate::error::{KernelError, KernelResult};
17use crate::ipc::KernelMessage;
18
19/// Default pipe channel capacity.
20const DEFAULT_PIPE_CAPACITY: usize = 256;
21
22/// Default TTL after all references are dropped (seconds).
23const DEFAULT_TTL_SECS: u64 = 60;
24
25/// Metadata about a named pipe (serializable snapshot).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PipeInfo {
28    /// Pipe name.
29    pub name: String,
30    /// Channel capacity.
31    pub capacity: usize,
32    /// When the pipe was created.
33    pub created_at: DateTime<Utc>,
34    /// Number of connected receivers.
35    pub receiver_count: u32,
36    /// Number of connected senders.
37    pub sender_count: u32,
38    /// TTL after all references are dropped.
39    pub ttl_after_empty_secs: u64,
40}
41
42/// A named pipe with channel-based message delivery.
43pub struct NamedPipe {
44    /// Pipe name.
45    pub name: String,
46    /// Channel capacity.
47    pub capacity: usize,
48    /// When the pipe was created.
49    pub created_at: DateTime<Utc>,
50    /// Sender half of the pipe channel (cloneable for multiple senders).
51    sender: mpsc::Sender<KernelMessage>,
52    /// Number of connected receivers.
53    pub receiver_count: AtomicU32,
54    /// Number of connected senders.
55    pub sender_count: AtomicU32,
56    /// TTL after all references are dropped.
57    pub ttl_after_empty: Duration,
58    /// Last time the pipe was actively used.
59    pub last_active: RwLock<Instant>,
60}
61
62impl NamedPipe {
63    /// Get a serializable info snapshot.
64    pub fn info(&self) -> PipeInfo {
65        PipeInfo {
66            name: self.name.clone(),
67            capacity: self.capacity,
68            created_at: self.created_at,
69            receiver_count: self.receiver_count.load(Ordering::Relaxed),
70            sender_count: self.sender_count.load(Ordering::Relaxed),
71            ttl_after_empty_secs: self.ttl_after_empty.as_secs(),
72        }
73    }
74
75    /// Touch the pipe to mark it as active.
76    fn touch(&self) {
77        if let Ok(mut last) = self.last_active.write() {
78            *last = Instant::now();
79        }
80    }
81
82    /// Check if the pipe has expired (no references and TTL elapsed).
83    pub fn is_expired(&self) -> bool {
84        let senders = self.sender_count.load(Ordering::Relaxed);
85        let receivers = self.receiver_count.load(Ordering::Relaxed);
86        if senders > 0 || receivers > 0 {
87            return false;
88        }
89        if let Ok(last) = self.last_active.read() {
90            last.elapsed() >= self.ttl_after_empty
91        } else {
92            false
93        }
94    }
95}
96
97/// Registry of named pipes for persistent IPC channels.
98pub struct NamedPipeRegistry {
99    pipes: DashMap<String, NamedPipe>,
100}
101
102impl NamedPipeRegistry {
103    /// Create a new empty pipe registry.
104    pub fn new() -> Self {
105        Self {
106            pipes: DashMap::new(),
107        }
108    }
109
110    /// Create a named pipe with default capacity and TTL.
111    ///
112    /// Returns the receiver half of the pipe channel.
113    /// If a pipe with this name already exists, returns an error.
114    pub fn create(&self, name: impl Into<String>) -> KernelResult<mpsc::Receiver<KernelMessage>> {
115        self.create_with_options(name, DEFAULT_PIPE_CAPACITY, DEFAULT_TTL_SECS)
116    }
117
118    /// Create a named pipe with specific capacity and TTL.
119    pub fn create_with_options(
120        &self,
121        name: impl Into<String>,
122        capacity: usize,
123        ttl_secs: u64,
124    ) -> KernelResult<mpsc::Receiver<KernelMessage>> {
125        let name = name.into();
126        if self.pipes.contains_key(&name) {
127            return Err(KernelError::Ipc(format!(
128                "named pipe '{name}' already exists"
129            )));
130        }
131
132        let (tx, rx) = mpsc::channel(capacity);
133        let pipe = NamedPipe {
134            name: name.clone(),
135            capacity,
136            created_at: Utc::now(),
137            sender: tx,
138            receiver_count: AtomicU32::new(1),
139            sender_count: AtomicU32::new(0),
140            ttl_after_empty: Duration::from_secs(ttl_secs),
141            last_active: RwLock::new(Instant::now()),
142        };
143
144        self.pipes.insert(name, pipe);
145        Ok(rx)
146    }
147
148    /// Connect to an existing named pipe as a sender.
149    ///
150    /// Returns a sender that can push messages into the pipe.
151    pub fn connect_sender(&self, name: &str) -> KernelResult<mpsc::Sender<KernelMessage>> {
152        let pipe = self
153            .pipes
154            .get(name)
155            .ok_or_else(|| KernelError::Ipc(format!("named pipe '{name}' not found")))?;
156
157        pipe.sender_count.fetch_add(1, Ordering::Relaxed);
158        pipe.touch();
159        Ok(pipe.sender.clone())
160    }
161
162    /// Send a message to a named pipe.
163    ///
164    /// This is a convenience method that looks up the pipe and sends
165    /// directly, without requiring the caller to hold a sender.
166    pub async fn send(&self, name: &str, msg: KernelMessage) -> KernelResult<()> {
167        let pipe = self
168            .pipes
169            .get(name)
170            .ok_or_else(|| KernelError::Ipc(format!("named pipe '{name}' not found")))?;
171
172        pipe.touch();
173        pipe.sender.try_send(msg).map_err(|e| match e {
174            mpsc::error::TrySendError::Full(_) => {
175                KernelError::Ipc(format!("named pipe '{name}' is full"))
176            }
177            mpsc::error::TrySendError::Closed(_) => {
178                KernelError::Ipc(format!("named pipe '{name}' receiver dropped"))
179            }
180        })
181    }
182
183    /// Disconnect a sender from a named pipe.
184    pub fn disconnect_sender(&self, name: &str) {
185        if let Some(pipe) = self.pipes.get(name) {
186            let prev = pipe.sender_count.fetch_sub(1, Ordering::Relaxed);
187            if prev == 0 {
188                // Underflow protection
189                pipe.sender_count.store(0, Ordering::Relaxed);
190            }
191            pipe.touch();
192        }
193    }
194
195    /// Disconnect a receiver from a named pipe.
196    pub fn disconnect_receiver(&self, name: &str) {
197        if let Some(pipe) = self.pipes.get(name) {
198            let prev = pipe.receiver_count.fetch_sub(1, Ordering::Relaxed);
199            if prev == 0 {
200                pipe.receiver_count.store(0, Ordering::Relaxed);
201            }
202            pipe.touch();
203        }
204    }
205
206    /// Remove a named pipe.
207    pub fn remove(&self, name: &str) -> bool {
208        self.pipes.remove(name).is_some()
209    }
210
211    /// Check if a named pipe exists.
212    pub fn exists(&self, name: &str) -> bool {
213        self.pipes.contains_key(name)
214    }
215
216    /// List all pipe names.
217    pub fn list(&self) -> Vec<String> {
218        self.pipes.iter().map(|e| e.key().clone()).collect()
219    }
220
221    /// Get info about a specific pipe.
222    pub fn info(&self, name: &str) -> Option<PipeInfo> {
223        self.pipes.get(name).map(|p| p.info())
224    }
225
226    /// Get info about all pipes.
227    pub fn list_info(&self) -> Vec<PipeInfo> {
228        self.pipes.iter().map(|e| e.value().info()).collect()
229    }
230
231    /// Number of registered pipes.
232    pub fn len(&self) -> usize {
233        self.pipes.len()
234    }
235
236    /// Whether the registry is empty.
237    pub fn is_empty(&self) -> bool {
238        self.pipes.is_empty()
239    }
240
241    /// Remove pipes that have expired (no references and TTL elapsed).
242    ///
243    /// Returns the names of removed pipes.
244    pub fn cleanup_expired(&self) -> Vec<String> {
245        let expired: Vec<String> = self
246            .pipes
247            .iter()
248            .filter(|e| e.value().is_expired())
249            .map(|e| e.key().clone())
250            .collect();
251
252        for name in &expired {
253            self.pipes.remove(name);
254        }
255
256        expired
257    }
258}
259
260impl Default for NamedPipeRegistry {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266// ── Registry trait implementation ────────────────────────────────────
267
268impl clawft_types::Registry for NamedPipeRegistry {
269    type Value = PipeInfo;
270
271    fn get(&self, key: &str) -> Option<Self::Value> {
272        self.info(key)
273    }
274
275    fn list_keys(&self) -> Vec<String> {
276        self.list()
277    }
278
279    fn contains(&self, key: &str) -> bool {
280        self.exists(key)
281    }
282
283    fn count(&self) -> usize {
284        self.len()
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::ipc::{MessagePayload, MessageTarget};
292
293    fn make_msg(text: &str) -> KernelMessage {
294        KernelMessage::new(
295            1,
296            MessageTarget::Process(2),
297            MessagePayload::Text(text.into()),
298        )
299    }
300
301    #[test]
302    fn create_and_exists() {
303        let registry = NamedPipeRegistry::new();
304        let _rx = registry.create("test-pipe").unwrap();
305        assert!(registry.exists("test-pipe"));
306        assert_eq!(registry.len(), 1);
307    }
308
309    #[test]
310    fn create_duplicate_fails() {
311        let registry = NamedPipeRegistry::new();
312        let _rx = registry.create("dup").unwrap();
313        let err = registry.create("dup").unwrap_err();
314        assert!(err.to_string().contains("already exists"));
315    }
316
317    #[tokio::test]
318    async fn send_and_receive_roundtrip() {
319        let registry = NamedPipeRegistry::new();
320        let mut rx = registry.create("pipe-1").unwrap();
321
322        let msg = make_msg("hello pipe");
323        registry.send("pipe-1", msg).await.unwrap();
324
325        let received = rx.recv().await.unwrap();
326        match &received.payload {
327            MessagePayload::Text(t) => assert_eq!(t, "hello pipe"),
328            other => panic!("expected Text, got: {other:?}"),
329        }
330    }
331
332    #[tokio::test]
333    async fn multiple_senders_fan_in() {
334        let registry = NamedPipeRegistry::new();
335        let mut rx = registry.create("fan-in").unwrap();
336
337        let tx1 = registry.connect_sender("fan-in").unwrap();
338        let tx2 = registry.connect_sender("fan-in").unwrap();
339
340        tx1.send(make_msg("from-1")).await.unwrap();
341        tx2.send(make_msg("from-2")).await.unwrap();
342
343        let m1 = rx.recv().await.unwrap();
344        let m2 = rx.recv().await.unwrap();
345
346        let texts: Vec<String> = [m1, m2]
347            .iter()
348            .filter_map(|m| match &m.payload {
349                MessagePayload::Text(t) => Some(t.clone()),
350                _ => None,
351            })
352            .collect();
353        assert!(texts.contains(&"from-1".to_string()));
354        assert!(texts.contains(&"from-2".to_string()));
355    }
356
357    #[tokio::test]
358    async fn capacity_limit_returns_error() {
359        let registry = NamedPipeRegistry::new();
360        let _rx = registry.create_with_options("tiny", 2, 60).unwrap();
361
362        // Fill the pipe
363        registry.send("tiny", make_msg("1")).await.unwrap();
364        registry.send("tiny", make_msg("2")).await.unwrap();
365
366        // Third should fail
367        let err = registry.send("tiny", make_msg("3")).await.unwrap_err();
368        assert!(err.to_string().contains("full"));
369    }
370
371    #[test]
372    fn send_to_nonexistent_pipe_fails() {
373        let registry = NamedPipeRegistry::new();
374        let rt = tokio::runtime::Builder::new_current_thread()
375            .build()
376            .unwrap();
377        let err = rt
378            .block_on(registry.send("nope", make_msg("x")))
379            .unwrap_err();
380        assert!(err.to_string().contains("not found"));
381    }
382
383    #[test]
384    fn connect_sender_to_nonexistent_fails() {
385        let registry = NamedPipeRegistry::new();
386        let err = registry.connect_sender("nope").unwrap_err();
387        assert!(err.to_string().contains("not found"));
388    }
389
390    #[test]
391    fn remove_pipe() {
392        let registry = NamedPipeRegistry::new();
393        let _rx = registry.create("removable").unwrap();
394        assert!(registry.exists("removable"));
395        assert!(registry.remove("removable"));
396        assert!(!registry.exists("removable"));
397    }
398
399    #[test]
400    fn list_pipes() {
401        let registry = NamedPipeRegistry::new();
402        let _r1 = registry.create("alpha").unwrap();
403        let _r2 = registry.create("beta").unwrap();
404
405        let names = registry.list();
406        assert_eq!(names.len(), 2);
407        assert!(names.contains(&"alpha".to_string()));
408        assert!(names.contains(&"beta".to_string()));
409    }
410
411    #[test]
412    fn pipe_info() {
413        let registry = NamedPipeRegistry::new();
414        let _rx = registry.create_with_options("info-pipe", 128, 120).unwrap();
415
416        let info = registry.info("info-pipe").unwrap();
417        assert_eq!(info.name, "info-pipe");
418        assert_eq!(info.capacity, 128);
419        assert_eq!(info.receiver_count, 1);
420        assert_eq!(info.sender_count, 0);
421        assert_eq!(info.ttl_after_empty_secs, 120);
422    }
423
424    #[test]
425    fn sender_count_tracking() {
426        let registry = NamedPipeRegistry::new();
427        let _rx = registry.create("counted").unwrap();
428
429        let _tx1 = registry.connect_sender("counted").unwrap();
430        let _tx2 = registry.connect_sender("counted").unwrap();
431
432        let info = registry.info("counted").unwrap();
433        assert_eq!(info.sender_count, 2);
434
435        registry.disconnect_sender("counted");
436        let info = registry.info("counted").unwrap();
437        assert_eq!(info.sender_count, 1);
438    }
439
440    #[test]
441    fn ttl_cleanup() {
442        let registry = NamedPipeRegistry::new();
443        // Create a pipe with 0-second TTL
444        let _rx = registry.create_with_options("ephemeral", 16, 0).unwrap();
445
446        // Disconnect the receiver so ref counts go to zero
447        registry.disconnect_receiver("ephemeral");
448
449        // It should be expired now (TTL=0)
450        let expired = registry.cleanup_expired();
451        assert_eq!(expired, vec!["ephemeral".to_string()]);
452        assert!(!registry.exists("ephemeral"));
453    }
454
455    #[test]
456    fn pipe_not_expired_with_senders() {
457        let registry = NamedPipeRegistry::new();
458        let _rx = registry.create_with_options("active", 16, 0).unwrap();
459        let _tx = registry.connect_sender("active").unwrap();
460
461        // Even with TTL=0, pipe has senders so not expired
462        let expired = registry.cleanup_expired();
463        assert!(expired.is_empty());
464    }
465
466    #[test]
467    fn default_registry() {
468        let registry = NamedPipeRegistry::default();
469        assert!(registry.is_empty());
470    }
471
472    #[test]
473    fn pipe_info_serde_roundtrip() {
474        let info = PipeInfo {
475            name: "test".into(),
476            capacity: 256,
477            created_at: Utc::now(),
478            receiver_count: 1,
479            sender_count: 2,
480            ttl_after_empty_secs: 60,
481        };
482        let json = serde_json::to_string(&info).unwrap();
483        let restored: PipeInfo = serde_json::from_str(&json).unwrap();
484        assert_eq!(restored.name, "test");
485        assert_eq!(restored.sender_count, 2);
486    }
487}