Skip to main content

agent_engine/events/
socket.rs

1// src/events/socket.rs
2// Unix socket listener for per-session event delivery
3
4use std::sync::{
5    atomic::{AtomicBool, Ordering},
6    Arc,
7};
8
9use tokio::io::AsyncReadExt;
10use tokio::net::UnixListener;
11
12use super::queue::EventQueue;
13use super::types::Event;
14
15const MAX_PAYLOAD: usize = 256 * 1024; // 256KB
16
17/// Remove socket file if it exists. Best-effort, never panics.
18/// Sockets now live in ~/.synaps-cli/run/ (mode 0700), so symlink
19/// attacks from other users are not possible. Still refuse symlinks
20/// as defense-in-depth.
21pub fn cleanup_socket(socket_path: &str) {
22    let path = std::path::Path::new(socket_path);
23    #[cfg(unix)]
24    {
25        if let Ok(meta) = std::fs::symlink_metadata(path) {
26            if meta.file_type().is_symlink() {
27                tracing::warn!("socket: refusing to remove symlink at {}", socket_path);
28                return;
29            }
30        }
31    }
32    match std::fs::remove_file(path) {
33        Ok(()) => {}
34        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
35        Err(e) => tracing::warn!("socket: failed to remove {}: {}", socket_path, e),
36    }
37}
38
39/// Bind a Unix socket at `socket_path`, accept connections, parse incoming
40/// events, push to `queue`. Runs until `shutdown` is set.
41///
42/// Protocol: client connects → sends full JSON event → closes connection.
43/// One event per connection. Max payload 256KB.
44pub fn listen_session_socket(
45    socket_path: String,
46    queue: Arc<EventQueue>,
47    shutdown: Arc<AtomicBool>,
48) -> tokio::task::JoinHandle<()> {
49    tokio::spawn(async move {
50        // Remove stale socket from a previous crash
51        cleanup_socket(&socket_path);
52
53        let listener = match UnixListener::bind(&socket_path) {
54            Ok(l) => l,
55            Err(e) => {
56                tracing::error!("socket: failed to bind {}: {}", socket_path, e);
57                return;
58            }
59        };
60
61        // Lock down the socket — session traffic only
62        #[cfg(unix)]
63        {
64            use std::os::unix::fs::PermissionsExt;
65            if let Ok(meta) = std::fs::metadata(&socket_path) {
66                let mut perms = meta.permissions();
67                perms.set_mode(0o600);
68                let _ = std::fs::set_permissions(&socket_path, perms);
69            }
70        }
71
72        tracing::info!("socket: listening on {}", socket_path);
73
74        loop {
75            if shutdown.load(Ordering::Acquire) {
76                break;
77            }
78
79            // Poll accept with a timeout so we can check shutdown periodically
80            let accept =
81                tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept());
82
83            match accept.await {
84                Ok(Ok((mut stream, _addr))) => {
85                    let queue = queue.clone();
86                    tokio::spawn(async move {
87                        // 5s timeout prevents slow-send DoS from parking tasks indefinitely
88                        let _ = tokio::time::timeout(
89                            std::time::Duration::from_secs(5),
90                            handle_connection(&mut stream, &queue),
91                        )
92                        .await;
93                    });
94                }
95                Ok(Err(e)) => {
96                    tracing::warn!("socket: accept error: {}", e);
97                }
98                Err(_) => {
99                    // Timeout — loop around and check shutdown flag
100                }
101            }
102        }
103
104        cleanup_socket(&socket_path);
105        tracing::info!("socket: shut down, removed {}", socket_path);
106    })
107}
108
109async fn handle_connection(stream: &mut tokio::net::UnixStream, queue: &EventQueue) {
110    // Read up to MAX_PAYLOAD + 1 so we can detect oversized payloads
111    let mut buf = Vec::with_capacity(4096);
112    let mut chunk = [0u8; 8192];
113
114    loop {
115        match stream.read(&mut chunk).await {
116            Ok(0) => break, // EOF — client closed connection
117            Ok(n) => {
118                if buf.len() + n > MAX_PAYLOAD {
119                    tracing::warn!(
120                        "socket: payload exceeds {}KB limit, dropping connection",
121                        MAX_PAYLOAD / 1024
122                    );
123                    return;
124                }
125                buf.extend_from_slice(&chunk[..n]);
126            }
127            Err(e) => {
128                tracing::warn!("socket: read error: {}", e);
129                return;
130            }
131        }
132    }
133
134    if buf.is_empty() {
135        return;
136    }
137
138    match serde_json::from_slice::<Event>(&buf) {
139        Ok(event) => {
140            tracing::info!(
141                "socket: event {} from {}",
142                event.id,
143                event.source.source_type
144            );
145            if let Err(e) = queue.push(event) {
146                tracing::warn!("socket: queue push failed: {}", e);
147            }
148        }
149        Err(e) => {
150            tracing::warn!("socket: invalid JSON payload: {}", e);
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::events::{Event, Severity};
159    use std::sync::atomic::AtomicBool;
160    use tokio::io::AsyncWriteExt;
161    use tokio::net::UnixStream;
162
163    fn tmp_socket_path() -> String {
164        format!(
165            "/tmp/test-session-socket-{}.sock",
166            uuid::Uuid::new_v4().simple()
167        )
168    }
169
170    async fn wait_for_socket(path: &str) {
171        for _ in 0..50 {
172            if std::path::Path::new(path).exists() {
173                return;
174            }
175            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
176        }
177        panic!("socket never appeared at {}", path);
178    }
179
180    #[tokio::test]
181    async fn delivers_event_to_queue() {
182        let path = tmp_socket_path();
183        let queue = Arc::new(EventQueue::new(10));
184        let shutdown = Arc::new(AtomicBool::new(false));
185
186        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
187        wait_for_socket(&path).await;
188
189        let event = Event::simple("test", "hello socket", Some(Severity::High));
190        let json = serde_json::to_vec(&event).unwrap();
191
192        let mut client = UnixStream::connect(&path).await.unwrap();
193        client.write_all(&json).await.unwrap();
194        client.shutdown().await.unwrap();
195
196        // Give the task a moment to push
197        for _ in 0..50 {
198            if !queue.is_empty() {
199                break;
200            }
201            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
202        }
203
204        shutdown.store(true, Ordering::Release);
205        handle.await.unwrap();
206
207        let popped = queue.pop().expect("event should be in queue");
208        assert_eq!(popped.content.text, "hello socket");
209        assert_eq!(popped.source.source_type, "test");
210    }
211
212    #[tokio::test]
213    async fn rejects_oversized_payload() {
214        let path = tmp_socket_path();
215        let queue = Arc::new(EventQueue::new(10));
216        let shutdown = Arc::new(AtomicBool::new(false));
217
218        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
219        wait_for_socket(&path).await;
220
221        // 257KB of junk — over the limit
222        let oversized = vec![b'x'; MAX_PAYLOAD + 1024];
223        let mut client = UnixStream::connect(&path).await.unwrap();
224        client.write_all(&oversized).await.unwrap();
225        client.shutdown().await.unwrap();
226
227        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
228
229        shutdown.store(true, Ordering::Release);
230        handle.await.unwrap();
231
232        assert_eq!(queue.len(), 0, "oversized payload should not reach queue");
233    }
234
235    #[tokio::test]
236    async fn invalid_json_does_not_crash() {
237        let path = tmp_socket_path();
238        let queue = Arc::new(EventQueue::new(10));
239        let shutdown = Arc::new(AtomicBool::new(false));
240
241        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
242        wait_for_socket(&path).await;
243
244        let mut client = UnixStream::connect(&path).await.unwrap();
245        client.write_all(b"this is not json at all").await.unwrap();
246        client.shutdown().await.unwrap();
247
248        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
249
250        // Send a valid event after — proves listener is still running
251        let event = Event::simple("test", "still alive", None);
252        let json = serde_json::to_vec(&event).unwrap();
253        let mut client2 = UnixStream::connect(&path).await.unwrap();
254        client2.write_all(&json).await.unwrap();
255        client2.shutdown().await.unwrap();
256
257        for _ in 0..50 {
258            if !queue.is_empty() {
259                break;
260            }
261            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
262        }
263
264        shutdown.store(true, Ordering::Release);
265        handle.await.unwrap();
266
267        assert_eq!(queue.len(), 1);
268        assert_eq!(queue.pop().unwrap().content.text, "still alive");
269    }
270
271    #[tokio::test]
272    async fn stale_socket_removed_on_startup() {
273        let path = tmp_socket_path();
274        // Plant a stale file
275        std::fs::write(&path, b"stale").unwrap();
276        assert!(std::path::Path::new(&path).exists());
277
278        let queue = Arc::new(EventQueue::new(10));
279        let shutdown = Arc::new(AtomicBool::new(false));
280
281        // Should not panic — bind replaces the stale file
282        let handle = listen_session_socket(path.clone(), queue.clone(), shutdown.clone());
283        wait_for_socket(&path).await;
284
285        shutdown.store(true, Ordering::Release);
286        handle.await.unwrap();
287    }
288}