1use 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#[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
46pub struct DaemonClient {
48 socket: Socket,
49}
50
51impl DaemonClient {
52 #[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 let addr = address.clone();
62 let socket = tokio::task::spawn_blocking(move || -> Result<Socket, IpcError> {
63 let socket = Socket::new(Protocol::Req0).map_err(|e| {
65 IpcError::ConnectionFailed(format!("Failed to create socket: {}", e))
66 })?;
67
68 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 socket.dial(&addr).map_err(|e| {
82 if matches!(e, nng::Error::ConnectionRefused) {
83 IpcError::DaemonNotRunning(addr.clone())
84 } else {
85 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 #[instrument(skip(self))]
103 pub async fn send(&self, request: &Request) -> Result<Response, IpcError> {
104 let frame = framing::encode(request)?;
106
107 let socket = self.socket.clone();
109
110 let response_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, IpcError> {
111 socket
113 .send(&frame)
114 .map_err(|(_, e)| IpcError::SendFailed(format!("Send failed: {}", e)))?;
115
116 debug!("Sent request, waiting for response");
117
118 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 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 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 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 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
190pub 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 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(_)) => {} 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}