use std::path::Path;
use std::time::Duration;
use nng::{
options::{Options, RecvTimeout, SendTimeout},
Protocol, Socket,
};
use rpytest_core::protocol::{Request, Response};
use thiserror::Error;
use tracing::{debug, instrument};
use crate::framing::{self, FramingError};
#[derive(Debug, Error)]
pub enum IpcError {
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Daemon not running at {0}")]
DaemonNotRunning(String),
#[error("Send failed: {0}")]
SendFailed(String),
#[error("Receive failed: {0}")]
ReceiveFailed(String),
#[error("Framing error: {0}")]
Framing(#[from] FramingError),
#[error("Connection closed unexpectedly")]
ConnectionClosed,
#[error("Operation timed out after {0:?}")]
Timeout(Duration),
#[error("NNG error: {0}")]
Nng(#[from] nng::Error),
}
pub struct DaemonClient {
socket: Socket,
}
impl DaemonClient {
#[instrument(skip_all, fields(path = %path.as_ref().display()))]
pub async fn connect(path: impl AsRef<Path>) -> Result<Self, IpcError> {
let path = path.as_ref();
let address = format!("ipc://{}", path.display());
debug!("Connecting to daemon at {}", address);
let addr = address.clone();
let socket = tokio::task::spawn_blocking(move || -> Result<Socket, IpcError> {
let socket = Socket::new(Protocol::Req0).map_err(|e| {
IpcError::ConnectionFailed(format!("Failed to create socket: {}", e))
})?;
socket
.set_opt::<RecvTimeout>(Some(Duration::from_secs(30)))
.map_err(|e| {
IpcError::ConnectionFailed(format!("Failed to set recv timeout: {}", e))
})?;
socket
.set_opt::<SendTimeout>(Some(Duration::from_secs(10)))
.map_err(|e| {
IpcError::ConnectionFailed(format!("Failed to set send timeout: {}", e))
})?;
socket.dial(&addr).map_err(|e| {
if matches!(e, nng::Error::ConnectionRefused) {
IpcError::DaemonNotRunning(addr.clone())
} else {
IpcError::DaemonNotRunning(format!("{}: {}", addr, e))
}
})?;
Ok(socket)
})
.await
.map_err(|e| IpcError::ConnectionFailed(format!("Task join error: {}", e)))??;
debug!("Connected to daemon");
Ok(Self { socket })
}
#[instrument(skip(self))]
pub async fn send(&self, request: &Request) -> Result<Response, IpcError> {
let frame = framing::encode(request)?;
let socket = self.socket.clone();
let response_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, IpcError> {
socket
.send(&frame)
.map_err(|(_, e)| IpcError::SendFailed(format!("Send failed: {}", e)))?;
debug!("Sent request, waiting for response");
let msg = socket.recv().map_err(|e| {
if matches!(e, nng::Error::Closed | nng::Error::ConnectionReset) {
IpcError::ConnectionClosed
} else if matches!(e, nng::Error::TimedOut) {
IpcError::Timeout(Duration::from_secs(30))
} else {
IpcError::ReceiveFailed(format!("Receive failed: {}", e))
}
})?;
Ok(msg.to_vec())
})
.await
.map_err(|e| IpcError::ReceiveFailed(format!("Task join error: {}", e)))??;
if response_bytes.len() < 4 {
return Err(IpcError::Framing(FramingError::InvalidFrame(
"Response too short for length prefix".to_string(),
)));
}
let len = u32::from_le_bytes(response_bytes[0..4].try_into().unwrap()) as usize;
if response_bytes.len() < 4 + len {
return Err(IpcError::Framing(FramingError::InvalidFrame(format!(
"Response incomplete: expected {} bytes, got {}",
4 + len,
response_bytes.len()
))));
}
let payload = &response_bytes[4..4 + len];
let response: Response = framing::decode(payload)?;
debug!(?response, "Received response");
Ok(response)
}
pub async fn send_timeout(
&self,
request: &Request,
timeout: Duration,
) -> Result<Response, IpcError> {
tokio::time::timeout(timeout, self.send(request))
.await
.map_err(|_| IpcError::Timeout(timeout))?
}
pub async fn ping(&self) -> Result<bool, IpcError> {
match self.send(&Request::Ping).await? {
Response::Pong => Ok(true),
_ => Ok(false),
}
}
pub async fn close(self) -> Result<(), IpcError> {
let socket = self.socket;
tokio::task::spawn_blocking(move || {
socket.close();
})
.await
.map_err(|e| IpcError::ConnectionFailed(format!("Close failed: {}", e)))?;
Ok(())
}
}
pub async fn is_daemon_running(path: impl AsRef<Path>) -> bool {
match DaemonClient::connect(path).await {
Ok(client) => {
let ping_result = client.ping().await;
if let Err(ref e) = ping_result {
debug!("Ping to daemon failed: {}", e);
}
let close_result = client.close().await;
if let Err(ref e) = close_result {
debug!("Failed to close daemon connection: {}", e);
}
ping_result.unwrap_or(false)
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_connect_nonexistent_socket() {
let tmp = tempdir().unwrap();
let socket_path = tmp.path().join("nonexistent.sock");
let result = DaemonClient::connect(&socket_path).await;
assert!(result.is_err());
match result {
Err(IpcError::DaemonNotRunning(_)) => {} Err(e) => panic!("Unexpected error type: {:?}", e),
Ok(_) => panic!("Should have failed to connect"),
}
}
#[tokio::test]
async fn test_is_daemon_running_nonexistent() {
let tmp = tempdir().unwrap();
let socket_path = tmp.path().join("nonexistent.sock");
let running = is_daemon_running(&socket_path).await;
assert!(!running);
}
#[test]
fn test_ipc_error_display() {
let err = IpcError::ConnectionFailed("test error".to_string());
assert_eq!(err.to_string(), "Connection failed: test error");
let err = IpcError::DaemonNotRunning("/tmp/test.sock".to_string());
assert_eq!(err.to_string(), "Daemon not running at /tmp/test.sock");
let err = IpcError::Timeout(Duration::from_secs(5));
assert_eq!(err.to_string(), "Operation timed out after 5s");
}
}