#[cfg(unix)]
use std::time::Duration;
use plana::jsonrpc::{JsonRpcRequest, JsonRpcResponse};
#[cfg(unix)]
use serde_json::Value;
const EVERNIGHT_DEFAULT_EXEC_TIMEOUT_SECS: u64 = 60;
const IO_TIMEOUT_MARGIN_SECS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TransportError {
#[error("evernight IPC connect failed: {0}")]
Connect(String),
#[error("evernight IPC write failed: {0}")]
Write(String),
#[error("evernight IPC read timed out after {0}s")]
ReadTimeout(u64),
#[error("evernight IPC read failed: {0}")]
Read(String),
#[error("evernight IPC parse failed: {0}")]
Parse(String),
#[error("evernight IPC: connection closed before a response arrived")]
Closed,
}
#[async_trait::async_trait]
pub trait Transport: Send + Sync {
async fn call(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError>;
}
#[derive(Debug, Clone)]
pub struct IpcSocketTransport {
socket_path: String,
connect_timeout_secs: u64,
io_timeout_secs: u64,
}
impl IpcSocketTransport {
pub const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_IO_TIMEOUT_SECS: u64 = 30;
pub fn new(socket_path: impl Into<String>) -> Self {
Self::with_timeouts(
socket_path,
Self::DEFAULT_CONNECT_TIMEOUT_SECS,
Self::DEFAULT_IO_TIMEOUT_SECS,
)
}
pub fn with_timeouts(
socket_path: impl Into<String>,
connect_timeout_secs: u64,
io_timeout_secs: u64,
) -> Self {
Self {
socket_path: socket_path.into(),
connect_timeout_secs,
io_timeout_secs,
}
}
pub fn socket_path(&self) -> &str {
&self.socket_path
}
pub fn connect_timeout_secs(&self) -> u64 {
self.connect_timeout_secs
}
pub fn io_timeout_secs(&self) -> u64 {
self.io_timeout_secs
}
pub fn effective_io_timeout_secs(&self, wire_timeout: Option<u64>) -> u64 {
let server_timeout = wire_timeout.unwrap_or(EVERNIGHT_DEFAULT_EXEC_TIMEOUT_SECS);
self.io_timeout_secs
.max(server_timeout.saturating_add(IO_TIMEOUT_MARGIN_SECS))
}
#[cfg(unix)]
fn wire_timeout(req: &JsonRpcRequest) -> Option<u64> {
req.params
.as_ref()
.and_then(|params| params.get("timeout"))
.and_then(Value::as_u64)
}
}
#[cfg(unix)]
#[async_trait::async_trait]
impl Transport for IpcSocketTransport {
async fn call(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mut framed = serde_json::to_string(&req)
.map_err(|e| TransportError::Write(format!("serialize request: {e}")))?;
framed.push('\n');
let stream = match tokio::time::timeout(
Duration::from_secs(self.connect_timeout_secs),
tokio::net::UnixStream::connect(&self.socket_path),
)
.await
{
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
return Err(TransportError::Connect(format!(
"{} (path {})",
e, self.socket_path
)));
}
Err(_) => {
return Err(TransportError::Connect(format!(
"connect timeout after {}s (path {})",
self.connect_timeout_secs, self.socket_path
)));
}
};
let (read_half, mut write_half) = tokio::io::split(stream);
write_half
.write_all(framed.as_bytes())
.await
.map_err(|e| TransportError::Write(e.to_string()))?;
drop(write_half);
let io_timeout = self.effective_io_timeout_secs(Self::wire_timeout(&req));
let mut reader = BufReader::new(read_half);
let mut line = String::with_capacity(64 * 1024);
match tokio::time::timeout(Duration::from_secs(io_timeout), reader.read_line(&mut line))
.await
{
Ok(Ok(0)) => return Err(TransportError::Closed),
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(TransportError::Read(e.to_string())),
Err(_) => return Err(TransportError::ReadTimeout(io_timeout)),
}
serde_json::from_str::<JsonRpcResponse>(line.trim())
.map_err(|e| TransportError::Parse(e.to_string()))
}
}
#[cfg(not(unix))]
#[async_trait::async_trait]
impl Transport for IpcSocketTransport {
async fn call(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
let _ = &self.socket_path;
Err(TransportError::Connect(format!(
"evernight IPC (method={}) only available on Unix",
req.method
)))
}
}
#[cfg(feature = "ws")]
#[derive(Debug, Clone)]
pub struct WsTransport {
pub endpoint: String,
}
#[cfg(feature = "ws")]
#[async_trait::async_trait]
impl Transport for WsTransport {
async fn call(&self, _req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
Err(TransportError::Connect(
"evernight ws transport is a deferred skeleton; the remote wss form is postponed \
by the routing contract until broker-side dispatch authentication lands"
.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_io_timeout_exceeds_server_timeout() {
let transport = IpcSocketTransport::new("/tmp/broker.sock");
assert_eq!(transport.effective_io_timeout_secs(Some(60)), 90);
assert_eq!(transport.effective_io_timeout_secs(Some(300)), 330);
assert_eq!(transport.effective_io_timeout_secs(Some(5)), 35);
assert_eq!(transport.effective_io_timeout_secs(None), 90);
}
#[test]
fn configured_io_timeout_acts_as_a_floor() {
let transport = IpcSocketTransport::with_timeouts("/tmp/broker.sock", 30, 120);
assert_eq!(transport.io_timeout_secs(), 120);
assert_eq!(transport.effective_io_timeout_secs(Some(60)), 120);
assert_eq!(transport.effective_io_timeout_secs(Some(300)), 330);
}
#[test]
fn socket_path_is_oversize_safe_and_reported() {
let transport = IpcSocketTransport::with_timeouts("/tmp/broker.sock", 11, 22);
assert_eq!(transport.socket_path(), "/tmp/broker.sock");
assert_eq!(transport.connect_timeout_secs(), 11);
assert_eq!(transport.io_timeout_secs(), 22);
}
#[test]
fn transport_error_display_carries_source_message() {
let error = TransportError::Connect("connection refused".to_string());
assert!(error.to_string().contains("connection refused"));
let error = TransportError::ReadTimeout(90);
assert!(error.to_string().contains("90s"));
}
#[cfg(unix)]
#[tokio::test]
async fn connect_to_missing_socket_yields_connect_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing.sock");
let transport =
IpcSocketTransport::with_timeouts(missing.to_string_lossy().to_string(), 2, 2);
let request = JsonRpcRequest::new_raw("System.Ping", None);
let error = transport.call(request).await.expect_err("must fail");
assert!(
matches!(error, TransportError::Connect(_)),
"unexpected error: {error:?}"
);
}
}