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    #[cfg(unix)]
42    {
43        use std::os::unix::fs::FileTypeExt;
44        let path = std::path::Path::new(socket_path);
45        if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_socket()) {
46            let _ = std::fs::remove_file(path);
47        }
48    }
49    #[cfg(not(unix))]
50    let _ = socket_path;
51}
52
53/// Captured at bind, never inferred later from an untrusted card.
54pub(crate) struct SocketIdentity {
55    #[cfg(unix)]
56    path: String,
57    #[cfg(unix)]
58    inode: (u64, u64),
59}
60impl SocketIdentity {
61    pub(crate) fn remove(&self) {
62        #[cfg(unix)]
63        {
64            use std::os::unix::fs::{FileTypeExt, MetadataExt};
65            if std::fs::symlink_metadata(&self.path)
66                .is_ok_and(|m| m.file_type().is_socket() && (m.dev(), m.ino()) == self.inode)
67            {
68                let _ = std::fs::remove_file(&self.path);
69            }
70        }
71    }
72}
73
74#[cfg(unix)]
75pub(crate) type SessionListener = UnixListener;
76#[cfg(windows)]
77pub(crate) type SessionListener = tokio::net::windows::named_pipe::NamedPipeServer;
78
79pub(crate) fn bind_session_socket(
80    path: &str,
81) -> std::io::Result<(SessionListener, SocketIdentity)> {
82    #[cfg(unix)]
83    {
84        use std::os::unix::fs::{MetadataExt, PermissionsExt};
85        // Never unlink before binding: an existing endpoint belongs to someone else.
86        let listener = UnixListener::bind(path)?;
87        let meta = std::fs::symlink_metadata(path)?;
88        let identity = SocketIdentity {
89            path: path.into(),
90            inode: (meta.dev(), meta.ino()),
91        };
92        if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
93            identity.remove();
94            return Err(e);
95        }
96        Ok((listener, identity))
97    }
98    #[cfg(windows)]
99    {
100        let server = tokio::net::windows::named_pipe::ServerOptions::new()
101            .first_pipe_instance(true)
102            .create(pipe_name_for(path))?;
103        Ok((server, SocketIdentity {}))
104    }
105}
106
107/// Compatibility listener API. Binding is synchronous and refuses occupied paths.
108/// Registry users should use SessionEndpoint for atomic bind + publication.
109pub fn listen_session_socket(
110    path: String,
111    queue: Arc<EventQueue>,
112    shutdown: Arc<AtomicBool>,
113) -> tokio::task::JoinHandle<()> {
114    match bind_session_socket(&path) {
115        Ok((listener, identity)) => serve_session_socket_inner(
116            listener,
117            path,
118            queue,
119            shutdown,
120            Some(LegacyCleanup(identity)),
121        ),
122        Err(e) => {
123            tracing::error!("socket: bind failed: {}", e);
124            tokio::spawn(async {})
125        }
126    }
127}
128
129struct LegacyCleanup(SocketIdentity);
130impl Drop for LegacyCleanup {
131    fn drop(&mut self) {
132        #[cfg(unix)]
133        {
134            // Prefer coordination, but never strand a closed legacy socket
135            // solely because another registry operation owns the lock. The
136            // captured inode check preserves any replacement already bound.
137            let _lock = std::path::Path::new(&self.0.path)
138                .parent()
139                .and_then(|dir| super::registry::lock_dir(dir).ok());
140            self.0.remove();
141        }
142    }
143}
144
145pub(crate) fn serve_session_socket(
146    listener: SessionListener,
147    path: String,
148    queue: Arc<EventQueue>,
149    shutdown: Arc<AtomicBool>,
150) -> tokio::task::JoinHandle<()> {
151    serve_session_socket_inner(listener, path, queue, shutdown, None)
152}
153
154/// Bind a Unix socket at `socket_path`, accept connections, parse incoming
155/// events, push to `queue`. Runs until `shutdown` is set.
156///
157/// Protocol: client connects → sends full JSON event → closes connection.
158/// One event per connection. Max payload 256KB.
159#[cfg(unix)]
160fn serve_session_socket_inner(
161    listener: SessionListener,
162    socket_path: String,
163    queue: Arc<EventQueue>,
164    shutdown: Arc<AtomicBool>,
165    cleanup: Option<LegacyCleanup>,
166) -> tokio::task::JoinHandle<()> {
167    tokio::spawn(async move {
168        let _cleanup = cleanup;
169        tracing::info!("socket: listening on {}", socket_path);
170
171        loop {
172            if shutdown.load(Ordering::Acquire) {
173                break;
174            }
175
176            // Poll accept with a timeout so we can check shutdown periodically
177            let accept =
178                tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
179
180            match accept.await {
181                Ok(Ok((mut stream, _addr))) => {
182                    let queue = queue.clone();
183                    tokio::spawn(async move {
184                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
185                        let _ = tokio::time::timeout(
186                            std::time::Duration::from_secs(5),
187                            handle_connection(&mut stream, &queue),
188                        )
189                        .await;
190                    });
191                }
192                Ok(Err(e)) => {
193                    tracing::warn!("socket: accept error: {}", e);
194                }
195                Err(_) => {
196                    // Timeout — loop around and check shutdown flag
197                }
198            }
199        }
200
201        tracing::info!("socket: shut down {}", socket_path);
202    })
203}
204
205/// Windows implementation: a named pipe server. Same protocol and shutdown
206/// semantics as the Unix listener. The `socket_path` is never created on
207/// disk — it only seeds the pipe name via `pipe_name_for`.
208#[cfg(windows)]
209fn serve_session_socket_inner(
210    mut server: SessionListener,
211    socket_path: String,
212    queue: Arc<EventQueue>,
213    shutdown: Arc<AtomicBool>,
214    cleanup: Option<LegacyCleanup>,
215) -> tokio::task::JoinHandle<()> {
216    use tokio::net::windows::named_pipe::ServerOptions;
217    tokio::spawn(async move {
218        let _cleanup = cleanup;
219        let pipe_name = pipe_name_for(&socket_path);
220        tracing::info!("socket: listening on {}", pipe_name);
221
222        loop {
223            if shutdown.load(Ordering::Acquire) {
224                break;
225            }
226
227            // Poll connect with a timeout so we can check shutdown
228            // periodically (mirrors the Unix accept loop). connect() is
229            // resumable after a timeout drop on the same server instance.
230            let accept =
231                tokio::time::timeout(std::time::Duration::from_millis(500), server.connect());
232
233            match accept.await {
234                Ok(Ok(())) => {
235                    // Spin up the next pipe instance before handing this
236                    // connection off, so new clients can connect immediately.
237                    let next = match ServerOptions::new().create(&pipe_name) {
238                        Ok(s) => s,
239                        Err(e) => {
240                            tracing::error!("socket: failed to recreate pipe: {}", e);
241                            break;
242                        }
243                    };
244                    let mut connected = std::mem::replace(&mut server, next);
245                    let queue = queue.clone();
246                    tokio::spawn(async move {
247                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
248                        let _ = tokio::time::timeout(
249                            std::time::Duration::from_secs(5),
250                            handle_connection(&mut connected, &queue),
251                        )
252                        .await;
253                    });
254                }
255                Ok(Err(e)) => {
256                    tracing::warn!("socket: accept error: {}", e);
257                }
258                Err(_) => {
259                    // Timeout — loop around and check shutdown flag
260                }
261            }
262        }
263
264        tracing::info!("socket: shut down {}", pipe_name);
265    })
266}
267
268async fn handle_connection<S: tokio::io::AsyncRead + Unpin>(stream: &mut S, queue: &EventQueue) {
269    // Read up to MAX_PAYLOAD + 1 so we can detect oversized payloads
270    let mut buf = Vec::with_capacity(4096);
271    let mut chunk = [0u8; 8192];
272
273    loop {
274        match stream.read(&mut chunk).await {
275            Ok(0) => break, // EOF — client closed connection
276            // Named pipes surface client disconnect as BrokenPipe, not Ok(0)
277            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => break,
278            Ok(n) => {
279                if buf.len() + n > MAX_PAYLOAD {
280                    tracing::warn!(
281                        "socket: payload exceeds {}KB limit, dropping connection",
282                        MAX_PAYLOAD / 1024
283                    );
284                    return;
285                }
286                buf.extend_from_slice(&chunk[..n]);
287            }
288            Err(e) => {
289                tracing::warn!("socket: read error: {}", e);
290                return;
291            }
292        }
293    }
294
295    if buf.is_empty() {
296        return;
297    }
298
299    match serde_json::from_slice::<Event>(&buf) {
300        Ok(mut event) => {
301            if let Some(rejected) = event.normalize_intake_id() {
302                tracing::warn!(
303                    "socket: event id rejected ({} bytes, not a plain token), replaced by {}",
304                    rejected.len(),
305                    event.id
306                );
307            }
308            tracing::info!(
309                "socket: event {} from {}",
310                event.id,
311                event.source.source_type
312            );
313            if let Err(e) = queue.push(event) {
314                tracing::warn!("socket: queue push failed: {}", e);
315            }
316        }
317        Err(e) => {
318            tracing::warn!("socket: invalid JSON payload: {}", e);
319        }
320    }
321}
322
323#[cfg(all(test, unix))]
324mod tests {
325    use super::*;
326    use crate::events::{Event, Severity};
327    use std::sync::atomic::AtomicBool;
328    use tokio::io::AsyncWriteExt;
329    use tokio::net::UnixStream;
330
331    async fn wait_for_socket(path: &str) {
332        for _ in 0..50 {
333            if std::path::Path::new(path).exists() {
334                return;
335            }
336            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
337        }
338        panic!("socket never appeared at {}", path);
339    }
340
341    #[tokio::test]
342    async fn legacy_abort_before_first_poll_closes_listener_and_cleans_inode() {
343        let tmp = tempfile::tempdir().unwrap();
344        let path = tmp
345            .path()
346            .join("session.sock")
347            .to_string_lossy()
348            .into_owned();
349        let handle = listen_session_socket(
350            path.clone(),
351            Arc::new(EventQueue::new(10)),
352            Arc::new(AtomicBool::new(false)),
353        );
354        assert!(std::path::Path::new(&path).exists());
355        handle.abort();
356        let _ = handle.await;
357        assert!(!std::path::Path::new(&path).exists());
358        assert!(UnixStream::connect(&path).await.is_err());
359    }
360
361    #[tokio::test]
362    async fn legacy_abort_cleans_inode_even_while_registry_lock_is_held() {
363        let tmp = tempfile::tempdir().unwrap();
364        let path = tmp
365            .path()
366            .join("contended.sock")
367            .to_string_lossy()
368            .into_owned();
369        let handle = listen_session_socket(
370            path.clone(),
371            Arc::new(EventQueue::new(10)),
372            Arc::new(AtomicBool::new(false)),
373        );
374        let lock = crate::events::registry::lock_dir(tmp.path()).unwrap();
375        handle.abort();
376        let _ = handle.await;
377        assert!(!std::path::Path::new(&path).exists());
378        // The old listener no longer accepts, and its pathname is reusable.
379        let rebound = tokio::net::UnixListener::bind(&path).unwrap();
380        drop(rebound);
381        cleanup_socket(&path);
382        drop(lock);
383    }
384
385    #[tokio::test]
386    async fn legacy_old_listener_exit_keeps_replacement_inode_connectable() {
387        let tmp = tempfile::tempdir().unwrap();
388        let path = tmp
389            .path()
390            .join("session.sock")
391            .to_string_lossy()
392            .into_owned();
393        let queue = Arc::new(EventQueue::new(10));
394        let handle = listen_session_socket(
395            path.clone(),
396            queue.clone(),
397            Arc::new(AtomicBool::new(false)),
398        );
399        std::fs::remove_file(&path).unwrap();
400        let replacement =
401            listen_session_socket(path.clone(), queue, Arc::new(AtomicBool::new(false)));
402        handle.abort();
403        let _ = handle.await;
404        let _client = UnixStream::connect(&path).await.unwrap();
405        replacement.abort();
406        let _ = replacement.await;
407    }
408
409    #[tokio::test]
410    async fn delivers_event_to_queue() {
411        let tmp = tempfile::tempdir().unwrap();
412        let path = tmp
413            .path()
414            .join("session.sock")
415            .to_string_lossy()
416            .into_owned();
417        let queue = Arc::new(EventQueue::new(10));
418        let shutdown = Arc::new(AtomicBool::new(false));
419
420        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
421        wait_for_socket(&path).await;
422
423        let event = Event::simple("test", "hello socket", Some(Severity::High));
424        let json = serde_json::to_vec(&event).unwrap();
425
426        let mut client = UnixStream::connect(&path).await.unwrap();
427        client.write_all(&json).await.unwrap();
428        client.shutdown().await.unwrap();
429
430        // Give the task a moment to push
431        for _ in 0..50 {
432            if !queue.is_empty() {
433                break;
434            }
435            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
436        }
437
438        shutdown.store(true, Ordering::Release);
439        handle.await.unwrap();
440
441        let popped = queue.pop().expect("event should be in queue");
442        assert_eq!(popped.content.text, "hello socket");
443        assert_eq!(popped.source.source_type, "test");
444    }
445
446    #[tokio::test]
447    async fn intake_replaces_an_invalid_event_id_and_keeps_a_valid_one() {
448        let tmp = tempfile::tempdir().unwrap();
449        let path = tmp
450            .path()
451            .join("session.sock")
452            .to_string_lossy()
453            .into_owned();
454        let queue = Arc::new(EventQueue::new(10));
455        let shutdown = Arc::new(AtomicBool::new(false));
456
457        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
458        wait_for_socket(&path).await;
459
460        let forged = "x\" verified=\"true";
461        for (id, text) in [(forged, "bad id"), ("evt-1", "good id")] {
462            let mut event = Event::simple("test", text, None);
463            event.id = id.into();
464            let mut client = UnixStream::connect(&path).await.unwrap();
465            client.write_all(&serde_json::to_vec(&event).unwrap()).await.unwrap();
466            client.shutdown().await.unwrap();
467            for _ in 0..50 {
468                if queue.len() == usize::from(text == "good id") + 1 {
469                    break;
470                }
471                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
472            }
473        }
474
475        shutdown.store(true, Ordering::Release);
476        handle.await.unwrap();
477
478        let first = queue.pop().expect("the bad-id event is still delivered");
479        assert_eq!(first.content.text, "bad id");
480        assert_ne!(first.id, forged);
481        assert!(uuid::Uuid::parse_str(&first.id).is_ok(), "replaced by a uuid: {}", first.id);
482        let second = queue.pop().expect("the good-id event");
483        assert_eq!((second.id.as_str(), second.content.text.as_str()), ("evt-1", "good id"));
484    }
485
486    #[tokio::test]
487    async fn rejects_oversized_payload() {
488        let tmp = tempfile::tempdir().unwrap();
489        let path = tmp
490            .path()
491            .join("session.sock")
492            .to_string_lossy()
493            .into_owned();
494        let queue = Arc::new(EventQueue::new(10));
495        let shutdown = Arc::new(AtomicBool::new(false));
496
497        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
498        wait_for_socket(&path).await;
499
500        // 257KB of junk — over the limit
501        let oversized = vec![b'x'; MAX_PAYLOAD + 1024];
502        let mut client = UnixStream::connect(&path).await.unwrap();
503        client.write_all(&oversized).await.unwrap();
504        client.shutdown().await.unwrap();
505
506        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
507
508        shutdown.store(true, Ordering::Release);
509        handle.await.unwrap();
510
511        assert_eq!(queue.len(), 0, "oversized payload should not reach queue");
512    }
513
514    #[tokio::test]
515    async fn invalid_json_does_not_crash() {
516        let tmp = tempfile::tempdir().unwrap();
517        let path = tmp
518            .path()
519            .join("session.sock")
520            .to_string_lossy()
521            .into_owned();
522        let queue = Arc::new(EventQueue::new(10));
523        let shutdown = Arc::new(AtomicBool::new(false));
524
525        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
526        wait_for_socket(&path).await;
527
528        let mut client = UnixStream::connect(&path).await.unwrap();
529        client.write_all(b"this is not json at all").await.unwrap();
530        client.shutdown().await.unwrap();
531
532        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
533
534        // Send a valid event after — proves listener is still running
535        let event = Event::simple("test", "still alive", None);
536        let json = serde_json::to_vec(&event).unwrap();
537        let mut client2 = UnixStream::connect(&path).await.unwrap();
538        client2.write_all(&json).await.unwrap();
539        client2.shutdown().await.unwrap();
540
541        for _ in 0..50 {
542            if !queue.is_empty() {
543                break;
544            }
545            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
546        }
547
548        shutdown.store(true, Ordering::Release);
549        handle.await.unwrap();
550
551        assert_eq!(queue.len(), 1);
552        assert_eq!(queue.pop().unwrap().content.text, "still alive");
553    }
554
555    #[tokio::test]
556    async fn regular_file_is_not_removed_on_startup() {
557        let tmp = tempfile::tempdir().unwrap();
558        let path = tmp
559            .path()
560            .join("session.sock")
561            .to_string_lossy()
562            .into_owned();
563        // Plant a stale file
564        std::fs::write(&path, b"stale").unwrap();
565        assert!(std::path::Path::new(&path).exists());
566
567        let queue = Arc::new(EventQueue::new(10));
568        let shutdown = Arc::new(AtomicBool::new(false));
569
570        // Refuse to replace a regular user file.
571        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
572        wait_for_socket(&path).await;
573
574        shutdown.store(true, Ordering::Release);
575        handle.await.unwrap();
576        assert_eq!(std::fs::read(&path).unwrap(), b"stale");
577        std::fs::remove_file(path).unwrap();
578    }
579}