Skip to main content

rpytest_ipc/
transport.rs

1//! Transport layer for daemon communication.
2//!
3//! Provides async client for connecting to the rpytest daemon over NNG sockets.
4
5use std::path::Path;
6use std::time::Duration;
7
8use nng::{
9    options::{Options, RecvTimeout, SendTimeout},
10    Protocol, Socket,
11};
12use rpytest_core::protocol::{Request, Response};
13use thiserror::Error;
14use tracing::{debug, instrument};
15
16use crate::framing::{self, FramingError};
17
18/// Errors that can occur during IPC operations.
19#[derive(Debug, Error)]
20pub enum IpcError {
21    #[error("Connection failed: {0}")]
22    ConnectionFailed(String),
23
24    #[error("Daemon not running at {0}")]
25    DaemonNotRunning(String),
26
27    #[error("Send failed: {0}")]
28    SendFailed(String),
29
30    #[error("Receive failed: {0}")]
31    ReceiveFailed(String),
32
33    #[error("Framing error: {0}")]
34    Framing(#[from] FramingError),
35
36    #[error("Connection closed unexpectedly")]
37    ConnectionClosed,
38
39    #[error("Operation timed out after {0:?}")]
40    Timeout(Duration),
41
42    #[error("NNG error: {0}")]
43    Nng(#[from] nng::Error),
44}
45
46/// Client for communicating with the rpytest daemon.
47pub struct DaemonClient {
48    socket: Socket,
49}
50
51impl DaemonClient {
52    /// Connect to the daemon at the given socket path.
53    #[instrument(skip_all, fields(path = %path.as_ref().display()))]
54    pub async fn connect(path: impl AsRef<Path>) -> Result<Self, IpcError> {
55        let path = path.as_ref();
56        let address = format!("ipc://{}", path.display());
57
58        debug!("Connecting to daemon at {}", address);
59
60        // Run NNG operations in a blocking task since nng is synchronous
61        let addr = address.clone();
62        let socket = tokio::task::spawn_blocking(move || -> Result<Socket, IpcError> {
63            // Create a REQ socket (request-reply client)
64            let socket = Socket::new(Protocol::Req0).map_err(|e| {
65                IpcError::ConnectionFailed(format!("Failed to create socket: {}", e))
66            })?;
67
68            // Set timeouts
69            socket
70                .set_opt::<RecvTimeout>(Some(Duration::from_secs(30)))
71                .map_err(|e| {
72                    IpcError::ConnectionFailed(format!("Failed to set recv timeout: {}", e))
73                })?;
74            socket
75                .set_opt::<SendTimeout>(Some(Duration::from_secs(10)))
76                .map_err(|e| {
77                    IpcError::ConnectionFailed(format!("Failed to set send timeout: {}", e))
78                })?;
79
80            // Connect to the daemon
81            socket.dial(&addr).map_err(|e| {
82                if matches!(e, nng::Error::ConnectionRefused) {
83                    IpcError::DaemonNotRunning(addr.clone())
84                } else {
85                    // Treat other connection errors as "daemon not running"
86                    // since the socket file might not exist
87                    IpcError::DaemonNotRunning(format!("{}: {}", addr, e))
88                }
89            })?;
90
91            Ok(socket)
92        })
93        .await
94        .map_err(|e| IpcError::ConnectionFailed(format!("Task join error: {}", e)))??;
95
96        debug!("Connected to daemon");
97
98        Ok(Self { socket })
99    }
100
101    /// Send a request and wait for a response.
102    #[instrument(skip(self))]
103    pub async fn send(&self, request: &Request) -> Result<Response, IpcError> {
104        // Encode the request with length prefix
105        let frame = framing::encode(request)?;
106
107        // Clone socket for the blocking task
108        let socket = self.socket.clone();
109
110        let response_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, IpcError> {
111            // Send the frame
112            socket
113                .send(&frame)
114                .map_err(|(_, e)| IpcError::SendFailed(format!("Send failed: {}", e)))?;
115
116            debug!("Sent request, waiting for response");
117
118            // Receive response
119            let msg = socket.recv().map_err(|e| {
120                if matches!(e, nng::Error::Closed | nng::Error::ConnectionReset) {
121                    IpcError::ConnectionClosed
122                } else if matches!(e, nng::Error::TimedOut) {
123                    IpcError::Timeout(Duration::from_secs(30))
124                } else {
125                    IpcError::ReceiveFailed(format!("Receive failed: {}", e))
126                }
127            })?;
128
129            Ok(msg.to_vec())
130        })
131        .await
132        .map_err(|e| IpcError::ReceiveFailed(format!("Task join error: {}", e)))??;
133
134        // Parse the length-prefixed response
135        if response_bytes.len() < 4 {
136            return Err(IpcError::Framing(FramingError::InvalidFrame(
137                "Response too short for length prefix".to_string(),
138            )));
139        }
140
141        let len = u32::from_le_bytes(response_bytes[0..4].try_into().unwrap()) as usize;
142
143        if response_bytes.len() < 4 + len {
144            return Err(IpcError::Framing(FramingError::InvalidFrame(format!(
145                "Response incomplete: expected {} bytes, got {}",
146                4 + len,
147                response_bytes.len()
148            ))));
149        }
150
151        let payload = &response_bytes[4..4 + len];
152        let response: Response = framing::decode(payload)?;
153
154        debug!(?response, "Received response");
155
156        Ok(response)
157    }
158
159    /// Send a request with a timeout.
160    pub async fn send_timeout(
161        &self,
162        request: &Request,
163        timeout: Duration,
164    ) -> Result<Response, IpcError> {
165        tokio::time::timeout(timeout, self.send(request))
166            .await
167            .map_err(|_| IpcError::Timeout(timeout))?
168    }
169
170    /// Check if the daemon is alive with a ping.
171    pub async fn ping(&self) -> Result<bool, IpcError> {
172        match self.send(&Request::Ping).await? {
173            Response::Pong => Ok(true),
174            _ => Ok(false),
175        }
176    }
177
178    /// Close the connection gracefully.
179    pub async fn close(self) -> Result<(), IpcError> {
180        let socket = self.socket;
181        tokio::task::spawn_blocking(move || {
182            socket.close();
183        })
184        .await
185        .map_err(|e| IpcError::ConnectionFailed(format!("Close failed: {}", e)))?;
186        Ok(())
187    }
188}
189
190/// Check if the daemon is running at the given socket path.
191pub async fn is_daemon_running(path: impl AsRef<Path>) -> bool {
192    match DaemonClient::connect(path).await {
193        Ok(client) => {
194            let ping_result = client.ping().await;
195            // Log ping failures for debugging but still return false
196            if let Err(ref e) = ping_result {
197                debug!("Ping to daemon failed: {}", e);
198            }
199            let close_result = client.close().await;
200            if let Err(ref e) = close_result {
201                debug!("Failed to close daemon connection: {}", e);
202            }
203            ping_result.unwrap_or(false)
204        }
205        Err(_) => false,
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use tempfile::tempdir;
213
214    #[tokio::test]
215    async fn test_connect_nonexistent_socket() {
216        let tmp = tempdir().unwrap();
217        let socket_path = tmp.path().join("nonexistent.sock");
218
219        let result = DaemonClient::connect(&socket_path).await;
220        assert!(result.is_err());
221
222        match result {
223            Err(IpcError::DaemonNotRunning(_)) => {} // Expected
224            Err(e) => panic!("Unexpected error type: {:?}", e),
225            Ok(_) => panic!("Should have failed to connect"),
226        }
227    }
228
229    #[tokio::test]
230    async fn test_is_daemon_running_nonexistent() {
231        let tmp = tempdir().unwrap();
232        let socket_path = tmp.path().join("nonexistent.sock");
233
234        let running = is_daemon_running(&socket_path).await;
235        assert!(!running);
236    }
237
238    #[test]
239    fn test_ipc_error_display() {
240        let err = IpcError::ConnectionFailed("test error".to_string());
241        assert_eq!(err.to_string(), "Connection failed: test error");
242
243        let err = IpcError::DaemonNotRunning("/tmp/test.sock".to_string());
244        assert_eq!(err.to_string(), "Daemon not running at /tmp/test.sock");
245
246        let err = IpcError::Timeout(Duration::from_secs(5));
247        assert_eq!(err.to_string(), "Operation timed out after 5s");
248    }
249}