Skip to main content

monocoque_core/
ipc.rs

1//! IPC transport via Unix domain sockets.
2//!
3//! Provides Unix domain socket support for inter-process communication
4//! with zero-copy and low-latency characteristics.
5
6#[cfg(unix)]
7use crate::rt::{UnixListener, UnixStream};
8#[cfg(unix)]
9use std::os::unix::fs::FileTypeExt;
10#[cfg(unix)]
11use std::path::{Path, PathBuf};
12
13/// A bound Unix domain socket listener that unlinks its socket file on drop.
14///
15/// A `UnixListener` does not remove its filesystem node when closed, so every
16/// IPC endpoint would otherwise leave a stale socket behind (libzmq unlinks the
17/// endpoint on close). This wrapper records the bound path and removes it on
18/// [`Drop`], guarding the unlink so it only ever removes a socket node.
19///
20/// It [`Deref`](std::ops::Deref)s to the underlying [`UnixListener`], so it can be used anywhere
21/// a `&UnixListener` is expected (e.g. [`accept`]).
22#[cfg(unix)]
23#[derive(Debug)]
24pub struct IpcListener {
25    listener: UnixListener,
26    path: PathBuf,
27}
28
29#[cfg(unix)]
30impl IpcListener {
31    /// Borrow the underlying listener.
32    #[must_use]
33    pub const fn listener(&self) -> &UnixListener {
34        &self.listener
35    }
36
37    /// The filesystem path this listener is bound to.
38    #[must_use]
39    pub fn path(&self) -> &Path {
40        &self.path
41    }
42}
43
44#[cfg(unix)]
45impl std::ops::Deref for IpcListener {
46    type Target = UnixListener;
47
48    fn deref(&self) -> &Self::Target {
49        &self.listener
50    }
51}
52
53#[cfg(unix)]
54impl Drop for IpcListener {
55    fn drop(&mut self) {
56        // Only remove the node if it is still the socket we bound. This avoids
57        // deleting a regular file that raced into the path, and is a no-op if
58        // the socket was already unlinked.
59        if let Ok(metadata) = std::fs::symlink_metadata(&self.path)
60            && metadata.file_type().is_socket()
61        {
62            let _ = std::fs::remove_file(&self.path);
63        }
64    }
65}
66
67#[cfg(unix)]
68/// Connect to a Unix domain socket.
69///
70/// # Examples
71///
72/// ```no_run
73/// use monocoque_core::ipc;
74///
75/// # async fn example() -> std::io::Result<()> {
76/// let stream = ipc::connect("/tmp/socket.sock").await?;
77/// # Ok(())
78/// # }
79/// ```
80pub async fn connect<P: AsRef<Path>>(path: P) -> std::io::Result<UnixStream> {
81    UnixStream::connect(path).await
82}
83
84#[cfg(unix)]
85/// Bind a Unix domain socket listener.
86///
87/// Returns the listener ready to accept connections.
88///
89/// # Examples
90///
91/// ```no_run
92/// use monocoque_core::ipc;
93///
94/// # async fn example() -> std::io::Result<()> {
95/// let listener = ipc::bind("/tmp/socket.sock").await?;
96/// # Ok(())
97/// # }
98/// ```
99pub async fn bind<P: AsRef<Path>>(path: P) -> std::io::Result<IpcListener> {
100    let path_ref = path.as_ref();
101    if path_ref.exists() {
102        let metadata = std::fs::symlink_metadata(path_ref)?;
103        if metadata.file_type().is_socket() {
104            std::fs::remove_file(path_ref)?;
105        } else {
106            return Err(std::io::Error::new(
107                std::io::ErrorKind::AlreadyExists,
108                "IPC bind path exists and is not a Unix socket",
109            ));
110        }
111    }
112
113    let listener = UnixListener::bind(path_ref).await?;
114    Ok(IpcListener {
115        listener,
116        path: path_ref.to_path_buf(),
117    })
118}
119
120#[cfg(unix)]
121/// Accept a connection on a Unix domain socket listener.
122pub async fn accept(listener: &UnixListener) -> std::io::Result<UnixStream> {
123    let (stream, _addr) = listener.accept().await?;
124    Ok(stream)
125}
126
127#[cfg(test)]
128#[cfg(unix)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_ipc_connect_bind() {
134        crate::rt::LocalRuntime::new()
135            .unwrap()
136            .block_on(test_ipc_connect_bind_impl());
137    }
138
139    async fn test_ipc_connect_bind_impl() {
140        let path = "/tmp/monocoque_test_ipc.sock";
141
142        // Clean up any existing socket
143        let _ = std::fs::remove_file(path);
144
145        let listener = bind(path).await.unwrap();
146
147        // Spawn accept task
148        let accept_handle = crate::rt::spawn(async move { accept(&listener).await });
149
150        // Give listener time to start
151        crate::rt::sleep(std::time::Duration::from_millis(10)).await;
152
153        // Connect
154        let client = connect(path).await.unwrap();
155
156        // Wait for accept
157        let server = crate::rt::join(accept_handle).await.unwrap();
158
159        assert!(client.peer_addr().is_ok());
160        assert!(server.local_addr().is_ok());
161
162        // Cleanup
163        drop(client);
164        drop(server);
165        let _ = std::fs::remove_file(path);
166    }
167
168    #[test]
169    fn drop_unlinks_socket_file() {
170        crate::rt::LocalRuntime::new()
171            .unwrap()
172            .block_on(drop_unlinks_socket_file_impl());
173    }
174
175    async fn drop_unlinks_socket_file_impl() {
176        let path =
177            std::env::temp_dir().join(format!("monocoque-ipc-unlink-{}.sock", std::process::id()));
178        let _ = std::fs::remove_file(&path);
179
180        let listener = bind(&path).await.unwrap();
181        assert!(path.exists(), "bind should create the socket node");
182
183        drop(listener);
184        assert!(
185            !path.exists(),
186            "dropping IpcListener must unlink the socket file"
187        );
188    }
189
190    #[test]
191    fn drop_leaves_non_socket_at_path_untouched() {
192        crate::rt::LocalRuntime::new()
193            .unwrap()
194            .block_on(drop_leaves_non_socket_at_path_untouched_impl());
195    }
196
197    async fn drop_leaves_non_socket_at_path_untouched_impl() {
198        // Bind, then race a regular file into the same path before drop. Drop
199        // must not delete a node that is no longer our socket.
200        let path =
201            std::env::temp_dir().join(format!("monocoque-ipc-guard-{}.sock", std::process::id()));
202        let _ = std::fs::remove_file(&path);
203
204        let listener = bind(&path).await.unwrap();
205        std::fs::remove_file(&path).unwrap();
206        std::fs::write(&path, b"not a socket").unwrap();
207
208        drop(listener);
209        assert!(
210            path.exists(),
211            "Drop must not remove a non-socket node at the bound path"
212        );
213        let _ = std::fs::remove_file(&path);
214    }
215
216    #[test]
217    fn bind_does_not_unlink_existing_regular_file() {
218        crate::rt::LocalRuntime::new()
219            .unwrap()
220            .block_on(bind_does_not_unlink_existing_regular_file_impl());
221    }
222
223    async fn bind_does_not_unlink_existing_regular_file_impl() {
224        let path = std::env::temp_dir().join(format!(
225            "monocoque-ipc-regular-file-{}.sock",
226            std::process::id()
227        ));
228
229        std::fs::write(&path, b"do not delete").unwrap();
230
231        let result = bind(&path).await;
232
233        assert!(
234            result.is_err(),
235            "binding over an existing regular file should fail instead of unlinking it"
236        );
237        assert!(
238            path.exists(),
239            "ipc::bind unlinked an existing regular file before binding"
240        );
241
242        let _ = std::fs::remove_file(path);
243    }
244}