//! IPC client — connects to the daemon's Unix socket and sends/receives //! framed JSON messages. //! //! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be //! shared across threads (e.g. the TUI event loop and the render task). use crate::conn::Connection; use anyhow::{Context, Result}; use serde::de::DeserializeOwned; use serde::Serialize; use std::os::unix::net::UnixStream; use std::sync::Mutex; /// A thread-safe IPC client connected to a Zesdex daemon over a Unix /// socket. pub struct IpcClient { /// Inner connection protected by a mutex for shared access. conn: Mutex, } impl IpcClient { /// Connect to the daemon listening at `path` (a Unix socket path). /// /// # Errors /// /// Returns an error if the socket path does not exist, the connection /// is refused, or the caller lacks permission. pub fn connect_unix(path: &str) -> Result { let stream = UnixStream::connect(path) .with_context(|| format!("failed to connect to Unix socket at {path:?}"))?; let conn = Connection::new(stream); Ok(Self { conn: Mutex::new(conn), }) } /// Serialise `msg` to JSON and send it as a length-prefixed frame. /// /// # Panics /// /// Panics if the internal mutex is poisoned (a previous operation /// panicked while holding the lock). /// /// # Errors /// /// Delegates to the underlying [`Connection::send`]. pub fn send(&self, msg: &T) -> Result<()> { let mut guard = self .conn .lock() .expect("IpcClient mutex poisoned — the previous operation panicked"); guard.send(msg) } /// Read one framed JSON message and deserialise it. /// /// Returns `Ok(None)` on clean EOF (daemon closed the connection). /// /// # Panics /// /// Panics if the internal mutex is poisoned (a previous operation /// panicked while holding the lock). /// /// # Errors /// /// Delegates to the underlying [`Connection::receive`]. pub fn receive(&self) -> Result> { let mut guard = self .conn .lock() .expect("IpcClient mutex poisoned — the previous operation panicked"); guard.receive() } } #[cfg(test)] mod tests { use super::*; use std::os::unix::net::UnixListener; use crate::test_utils::Ping; #[test] fn connect_and_round_trip() { let id = crate::test_utils::TEST_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}-{}", std::process::id(), id)); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let sock_path = dir.join("test.sock"); let sock_path_str = sock_path.to_string_lossy().to_string(); // Start a minimal echo server in a background thread. let listener = UnixListener::bind(&sock_path).unwrap(); let server_handle = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut conn = Connection::new(stream); // Echo one message back. let req: Ping = conn.receive().unwrap().unwrap(); conn.send(&req).unwrap(); }); // Client connects and sends a ping, then receives the echo. let client = IpcClient::connect_unix(&sock_path_str).unwrap(); client.send(&Ping { seq: 7 }).unwrap(); let resp: Ping = client.receive().unwrap().expect("expected a response"); assert_eq!(resp, Ping { seq: 7 }); server_handle.join().unwrap(); let _ = std::fs::remove_dir_all(&dir); } }