Skip to main content

agent_engine/events/
socket.rs

1// src/events/socket.rs
2// Per-session event delivery listener.
3// Unix: a Unix domain socket at `socket_path`.
4// Windows: a named pipe whose name is derived deterministically from
5// `socket_path` (see `pipe_name_for`), so clients holding the registry's
6// socket_path can connect without any registry format change.
7
8use std::sync::{
9    atomic::{AtomicBool, Ordering},
10    Arc,
11};
12
13use tokio::io::AsyncReadExt;
14#[cfg(unix)]
15use tokio::net::UnixListener;
16
17use super::queue::EventQueue;
18use super::types::Event;
19
20const MAX_PAYLOAD: usize = 256 * 1024; // 256KB
21
22/// Derive the Windows named-pipe name for a session socket path. Both the
23/// engine (server) and `synaps send` (client) call this on the registry's
24/// `socket_path`, so the two sides agree without changing the registry format.
25/// The path is hashed because pipe names live in a flat namespace and must
26/// not contain path separators.
27#[cfg(windows)]
28pub fn pipe_name_for(socket_path: &str) -> String {
29    use sha2::{Digest, Sha256};
30    let digest = Sha256::digest(socket_path.as_bytes());
31    // 16 bytes of the hash is plenty for uniqueness and keeps the name short.
32    let hex: String = digest[..16].iter().map(|b| format!("{:02x}", b)).collect();
33    format!(r"\\.\pipe\synaps-session-{}", hex)
34}
35
36/// Remove socket file if it exists. Best-effort, never panics.
37/// Sockets now live in ~/.synaps-cli/run/ (mode 0700), so symlink
38/// attacks from other users are not possible. Still refuse symlinks
39/// as defense-in-depth.
40pub fn cleanup_socket(socket_path: &str) {
41    let path = std::path::Path::new(socket_path);
42    #[cfg(unix)]
43    {
44        if let Ok(meta) = std::fs::symlink_metadata(path) {
45            if meta.file_type().is_symlink() {
46                tracing::warn!("socket: refusing to remove symlink at {}", socket_path);
47                return;
48            }
49        }
50    }
51    match std::fs::remove_file(path) {
52        Ok(()) => {}
53        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
54        Err(e) => tracing::warn!("socket: failed to remove {}: {}", socket_path, e),
55    }
56}
57
58/// Bind a Unix socket at `socket_path`, accept connections, parse incoming
59/// events, push to `queue`. Runs until `shutdown` is set.
60///
61/// Protocol: client connects → sends full JSON event → closes connection.
62/// One event per connection. Max payload 256KB.
63#[cfg(unix)]
64pub fn listen_session_socket(
65    socket_path: String,
66    queue: Arc<EventQueue>,
67    shutdown: Arc<AtomicBool>,
68) -> tokio::task::JoinHandle<()> {
69    tokio::spawn(async move {
70        // Remove stale socket from a previous crash
71        cleanup_socket(&socket_path);
72
73        let listener = match UnixListener::bind(&socket_path) {
74            Ok(l) => l,
75            Err(e) => {
76                tracing::error!("socket: failed to bind {}: {}", socket_path, e);
77                return;
78            }
79        };
80
81        // Lock down the socket — session traffic only
82        #[cfg(unix)]
83        {
84            use std::os::unix::fs::PermissionsExt;
85            if let Ok(meta) = std::fs::metadata(&socket_path) {
86                let mut perms = meta.permissions();
87                perms.set_mode(0o600);
88                let _ = std::fs::set_permissions(&socket_path, perms);
89            }
90        }
91
92        tracing::info!("socket: listening on {}", socket_path);
93
94        loop {
95            if shutdown.load(Ordering::Acquire) {
96                break;
97            }
98
99            // Poll accept with a timeout so we can check shutdown periodically
100            let accept =
101                tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
102
103            match accept.await {
104                Ok(Ok((mut stream, _addr))) => {
105                    let queue = queue.clone();
106                    tokio::spawn(async move {
107                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
108                        let _ = tokio::time::timeout(
109                            std::time::Duration::from_secs(5),
110                            handle_connection(&mut stream, &queue),
111                        )
112                        .await;
113                    });
114                }
115                Ok(Err(e)) => {
116                    tracing::warn!("socket: accept error: {}", e);
117                }
118                Err(_) => {
119                    // Timeout — loop around and check shutdown flag
120                }
121            }
122        }
123
124        cleanup_socket(&socket_path);
125        tracing::info!("socket: shut down, removed {}", socket_path);
126    })
127}
128
129/// Windows implementation: a named pipe server. Same protocol and shutdown
130/// semantics as the Unix listener. The `socket_path` is never created on
131/// disk — it only seeds the pipe name via `pipe_name_for`.
132#[cfg(windows)]
133pub fn listen_session_socket(
134    socket_path: String,
135    queue: Arc<EventQueue>,
136    shutdown: Arc<AtomicBool>,
137) -> tokio::task::JoinHandle<()> {
138    use tokio::net::windows::named_pipe::ServerOptions;
139    tokio::spawn(async move {
140        let pipe_name = pipe_name_for(&socket_path);
141        let mut server = match ServerOptions::new()
142            .first_pipe_instance(true)
143            .create(&pipe_name)
144        {
145            Ok(s) => s,
146            Err(e) => {
147                tracing::error!("socket: failed to create pipe {}: {}", pipe_name, e);
148                return;
149            }
150        };
151
152        tracing::info!("socket: listening on {}", pipe_name);
153
154        loop {
155            if shutdown.load(Ordering::Acquire) {
156                break;
157            }
158
159            // Poll connect with a timeout so we can check shutdown
160            // periodically (mirrors the Unix accept loop). connect() is
161            // resumable after a timeout drop on the same server instance.
162            let accept =
163                tokio::time::timeout(std::time::Duration::from_millis(500), server.connect());
164
165            match accept.await {
166                Ok(Ok(())) => {
167                    // Spin up the next pipe instance before handing this
168                    // connection off, so new clients can connect immediately.
169                    let next = match ServerOptions::new().create(&pipe_name) {
170                        Ok(s) => s,
171                        Err(e) => {
172                            tracing::error!("socket: failed to recreate pipe: {}", e);
173                            break;
174                        }
175                    };
176                    let mut connected = std::mem::replace(&mut server, next);
177                    let queue = queue.clone();
178                    tokio::spawn(async move {
179                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
180                        let _ = tokio::time::timeout(
181                            std::time::Duration::from_secs(5),
182                            handle_connection(&mut connected, &queue),
183                        )
184                        .await;
185                    });
186                }
187                Ok(Err(e)) => {
188                    tracing::warn!("socket: accept error: {}", e);
189                }
190                Err(_) => {
191                    // Timeout — loop around and check shutdown flag
192                }
193            }
194        }
195
196        tracing::info!("socket: shut down {}", pipe_name);
197    })
198}
199
200async fn handle_connection<S: tokio::io::AsyncRead + Unpin>(stream: &mut S, queue: &EventQueue) {
201    // Read up to MAX_PAYLOAD + 1 so we can detect oversized payloads
202    let mut buf = Vec::with_capacity(4096);
203    let mut chunk = [0u8; 8192];
204
205    loop {
206        match stream.read(&mut chunk).await {
207            Ok(0) => break, // EOF — client closed connection
208            // Named pipes surface client disconnect as BrokenPipe, not Ok(0)
209            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => break,
210            Ok(n) => {
211                if buf.len() + n > MAX_PAYLOAD {
212                    tracing::warn!(
213                        "socket: payload exceeds {}KB limit, dropping connection",
214                        MAX_PAYLOAD / 1024
215                    );
216                    return;
217                }
218                buf.extend_from_slice(&chunk[..n]);
219            }
220            Err(e) => {
221                tracing::warn!("socket: read error: {}", e);
222                return;
223            }
224        }
225    }
226
227    if buf.is_empty() {
228        return;
229    }
230
231    match serde_json::from_slice::<Event>(&buf) {
232        Ok(event) => {
233            tracing::info!(
234                "socket: event {} from {}",
235                event.id,
236                event.source.source_type
237            );
238            if let Err(e) = queue.push(event) {
239                tracing::warn!("socket: queue push failed: {}", e);
240            }
241        }
242        Err(e) => {
243            tracing::warn!("socket: invalid JSON payload: {}", e);
244        }
245    }
246}
247
248#[cfg(all(test, unix))]
249mod tests {
250    use super::*;
251    use crate::events::{Event, Severity};
252    use std::sync::atomic::AtomicBool;
253    use tokio::io::AsyncWriteExt;
254    use tokio::net::UnixStream;
255
256    fn tmp_socket_path() -> String {
257        format!(
258            "/tmp/test-session-socket-{}.sock",
259            uuid::Uuid::new_v4().simple()
260        )
261    }
262
263    async fn wait_for_socket(path: &str) {
264        for _ in 0..50 {
265            if std::path::Path::new(path).exists() {
266                return;
267            }
268            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
269        }
270        panic!("socket never appeared at {}", path);
271    }
272
273    #[tokio::test]
274    async fn delivers_event_to_queue() {
275        let path = tmp_socket_path();
276        let queue = Arc::new(EventQueue::new(10));
277        let shutdown = Arc::new(AtomicBool::new(false));
278
279        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
280        wait_for_socket(&path).await;
281
282        let event = Event::simple("test", "hello socket", Some(Severity::High));
283        let json = serde_json::to_vec(&event).unwrap();
284
285        let mut client = UnixStream::connect(&path).await.unwrap();
286        client.write_all(&json).await.unwrap();
287        client.shutdown().await.unwrap();
288
289        // Give the task a moment to push
290        for _ in 0..50 {
291            if !queue.is_empty() {
292                break;
293            }
294            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
295        }
296
297        shutdown.store(true, Ordering::Release);
298        handle.await.unwrap();
299
300        let popped = queue.pop().expect("event should be in queue");
301        assert_eq!(popped.content.text, "hello socket");
302        assert_eq!(popped.source.source_type, "test");
303    }
304
305    #[tokio::test]
306    async fn rejects_oversized_payload() {
307        let path = tmp_socket_path();
308        let queue = Arc::new(EventQueue::new(10));
309        let shutdown = Arc::new(AtomicBool::new(false));
310
311        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
312        wait_for_socket(&path).await;
313
314        // 257KB of junk — over the limit
315        let oversized = vec![b'x'; MAX_PAYLOAD + 1024];
316        let mut client = UnixStream::connect(&path).await.unwrap();
317        client.write_all(&oversized).await.unwrap();
318        client.shutdown().await.unwrap();
319
320        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
321
322        shutdown.store(true, Ordering::Release);
323        handle.await.unwrap();
324
325        assert_eq!(queue.len(), 0, "oversized payload should not reach queue");
326    }
327
328    #[tokio::test]
329    async fn invalid_json_does_not_crash() {
330        let path = tmp_socket_path();
331        let queue = Arc::new(EventQueue::new(10));
332        let shutdown = Arc::new(AtomicBool::new(false));
333
334        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
335        wait_for_socket(&path).await;
336
337        let mut client = UnixStream::connect(&path).await.unwrap();
338        client.write_all(b"this is not json at all").await.unwrap();
339        client.shutdown().await.unwrap();
340
341        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
342
343        // Send a valid event after — proves listener is still running
344        let event = Event::simple("test", "still alive", None);
345        let json = serde_json::to_vec(&event).unwrap();
346        let mut client2 = UnixStream::connect(&path).await.unwrap();
347        client2.write_all(&json).await.unwrap();
348        client2.shutdown().await.unwrap();
349
350        for _ in 0..50 {
351            if !queue.is_empty() {
352                break;
353            }
354            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
355        }
356
357        shutdown.store(true, Ordering::Release);
358        handle.await.unwrap();
359
360        assert_eq!(queue.len(), 1);
361        assert_eq!(queue.pop().unwrap().content.text, "still alive");
362    }
363
364    #[tokio::test]
365    async fn stale_socket_removed_on_startup() {
366        let path = tmp_socket_path();
367        // Plant a stale file
368        std::fs::write(&path, b"stale").unwrap();
369        assert!(std::path::Path::new(&path).exists());
370
371        let queue = Arc::new(EventQueue::new(10));
372        let shutdown = Arc::new(AtomicBool::new(false));
373
374        // Should not panic — bind replaces the stale file
375        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
376        wait_for_socket(&path).await;
377
378        shutdown.store(true, Ordering::Release);
379        handle.await.unwrap();
380    }
381}