use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use super::retry::ConnectRetry;
use super::{UdsSecurityError, connect_hardened};
pub const MAX_FRAME_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum UdsRpcError {
#[error("dial {path}: {source}")]
Dial {
path: PathBuf,
#[source]
source: UdsSecurityError,
},
#[error("serialize request frame for {path}: {source}")]
Encode {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("write request frame to {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("read response frame from {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} closed the connection without sending a response frame")]
NoResponse {
path: PathBuf,
},
#[error("response frame from {path} exceeded {limit} bytes without a newline")]
FrameTooLarge {
path: PathBuf,
limit: u64,
},
#[error("decode response frame from {path}: {source}")]
Decode {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("{path} did not complete the exchange within {timeout:?}")]
Timeout {
path: PathBuf,
timeout: Duration,
},
#[error("{path} ended the stream with {error}")]
Stream {
path: PathBuf,
error: crate::uds::server::RpcError,
},
#[error("{path} answered with a single response frame rather than a stream")]
NotAStream {
path: PathBuf,
response: Box<crate::uds::server::RpcResponse>,
},
#[error("{path}: {attempts} connect attempts failed; last error: {source}")]
ConnectRetriesExhausted {
path: PathBuf,
attempts: u32,
#[source]
source: Box<UdsRpcError>,
},
#[error("half-close {path} after writing the request frame: {source}")]
HalfClose {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl UdsRpcError {
#[must_use]
pub fn is_dial_failure(&self) -> bool {
match self {
Self::Dial { .. } => true,
Self::ConnectRetriesExhausted { source, .. } => source.is_dial_failure(),
_ => false,
}
}
}
pub async fn send_framed_request<Req, Resp>(
path: &Path,
request: &Req,
timeout: Duration,
) -> Result<Resp, UdsRpcError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
send_framed_request_capped(path, request, timeout, MAX_FRAME_BYTES).await
}
pub async fn send_framed_request_capped<Req, Resp>(
path: &Path,
request: &Req,
timeout: Duration,
max_frame_bytes: u64,
) -> Result<Resp, UdsRpcError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
send_framed_request_retrying(
path,
request,
timeout,
max_frame_bytes,
ConnectRetry::per_request(),
)
.await
}
pub async fn send_framed_request_retrying<Req, Resp>(
path: &Path,
request: &Req,
timeout: Duration,
max_frame_bytes: u64,
retry: ConnectRetry,
) -> Result<Resp, UdsRpcError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
send_framed_request_with_sleeper(path, request, timeout, max_frame_bytes, retry, tokio_sleep)
.await
}
pub(super) async fn send_framed_request_with_sleeper<Req, Resp, S, SFut>(
path: &Path,
request: &Req,
timeout: Duration,
max_frame_bytes: u64,
retry: ConnectRetry,
sleep: S,
) -> Result<Resp, UdsRpcError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
S: Fn(Duration) -> SFut,
SFut: std::future::Future<Output = ()>,
{
match tokio::time::timeout(
timeout,
exchange::<Req, Resp, S, SFut>(path, request, max_frame_bytes, retry, sleep),
)
.await
{
Ok(result) => result,
Err(_) => Err(UdsRpcError::Timeout {
path: path.to_path_buf(),
timeout,
}),
}
}
fn tokio_sleep(delay: Duration) -> tokio::time::Sleep {
tokio::time::sleep(delay)
}
pub async fn send_framed_notification<Req>(
path: &Path,
request: &Req,
timeout: Duration,
) -> Result<(), UdsRpcError>
where
Req: Serialize + ?Sized,
{
match tokio::time::timeout(timeout, dial_and_send(path, request)).await {
Ok(result) => result.map(|_stream| ()),
Err(_) => Err(UdsRpcError::Timeout {
path: path.to_path_buf(),
timeout,
}),
}
}
pub fn encode_frame<T>(value: &T) -> serde_json::Result<Vec<u8>>
where
T: Serialize + ?Sized,
{
let mut frame = serde_json::to_vec(value)?;
frame.push(b'\n');
Ok(frame)
}
pub async fn write_frame<W, T>(writer: &mut W, value: &T) -> std::io::Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize + ?Sized,
{
let frame =
encode_frame(value).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
writer.write_all(&frame).await?;
writer.flush().await
}
pub(super) async fn dial_and_send<Req>(
path: &Path,
request: &Req,
) -> Result<UnixStream, UdsRpcError>
where
Req: Serialize + ?Sized,
{
dial_and_send_retrying(path, request, ConnectRetry::per_request(), tokio_sleep).await
}
pub(super) async fn dial_and_send_retrying<Req, S, SFut>(
path: &Path,
request: &Req,
retry: ConnectRetry,
sleep: S,
) -> Result<UnixStream, UdsRpcError>
where
Req: Serialize + ?Sized,
S: Fn(Duration) -> SFut,
SFut: std::future::Future<Output = ()>,
{
super::retry::with_connect_retry(path, retry, sleep, |_attempt| {
dial_and_send_once(path, request)
})
.await
}
async fn dial_and_send_once<Req>(path: &Path, request: &Req) -> Result<UnixStream, UdsRpcError>
where
Req: Serialize + ?Sized,
{
let frame = encode_frame(request).map_err(|source| UdsRpcError::Encode {
path: path.to_path_buf(),
source,
})?;
let mut stream = connect_hardened(path)
.await
.map_err(|source| UdsRpcError::Dial {
path: path.to_path_buf(),
source,
})?;
let send = async {
stream.write_all(&frame).await?;
stream.flush().await
};
send.await.map_err(|source| UdsRpcError::Write {
path: path.to_path_buf(),
source,
})?;
stream
.shutdown()
.await
.map_err(|source| UdsRpcError::HalfClose {
path: path.to_path_buf(),
source,
})?;
Ok(stream)
}
async fn exchange<Req, Resp, S, SFut>(
path: &Path,
request: &Req,
max_frame_bytes: u64,
retry: ConnectRetry,
sleep: S,
) -> Result<Resp, UdsRpcError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
S: Fn(Duration) -> SFut,
SFut: std::future::Future<Output = ()>,
{
let stream = dial_and_send_retrying(path, request, retry, sleep).await?;
read_one_frame(stream, path, max_frame_bytes).await
}
async fn read_one_frame<R, Resp>(
source: R,
path: &Path,
max_frame_bytes: u64,
) -> Result<Resp, UdsRpcError>
where
R: AsyncRead + Unpin,
Resp: DeserializeOwned,
{
let mut reader = BufReader::new(source.take(max_frame_bytes));
let mut line: Vec<u8> = Vec::new();
let read = match reader.read_until(b'\n', &mut line).await {
Ok(read) => read,
Err(source) => return Err(classify_read_failure(path, source, line.is_empty())),
};
if read == 0 && line.is_empty() {
return Err(UdsRpcError::NoResponse {
path: path.to_path_buf(),
});
}
if !line.ends_with(b"\n") && line.len() as u64 >= max_frame_bytes {
return Err(UdsRpcError::FrameTooLarge {
path: path.to_path_buf(),
limit: max_frame_bytes,
});
}
serde_json::from_slice(&line).map_err(|source| UdsRpcError::Decode {
path: path.to_path_buf(),
source,
})
}
pub(super) fn classify_read_failure(
path: &Path,
source: std::io::Error,
nothing_buffered: bool,
) -> UdsRpcError {
let hung_up = matches!(
source.kind(),
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted
);
if hung_up && nothing_buffered {
return UdsRpcError::NoResponse {
path: path.to_path_buf(),
};
}
UdsRpcError::Read {
path: path.to_path_buf(),
source,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uds::bind_hardened;
use serde::Deserialize;
use std::path::PathBuf;
use tokio::net::UnixListener;
#[derive(Debug, Serialize)]
struct Ping {
method: &'static str,
n: u32,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Pong {
echoed: u32,
}
enum StubReply {
Bytes(Vec<u8>),
HangUp,
Silence,
}
fn spawn_stub(dir: &Path, replies: Vec<StubReply>) -> PathBuf {
let sock = dir.join("sockets").join("stub.sock");
let listener: UnixListener = bind_hardened(&sock).expect("bind stub socket");
tokio::spawn(async move {
for reply in replies {
let Ok((mut conn, _)) = listener.accept().await else {
return;
};
let mut sink = Vec::new();
let _ = conn.read_to_end(&mut sink).await;
match reply {
StubReply::Bytes(bytes) => {
let _ = conn.write_all(&bytes).await;
let _ = conn.flush().await;
}
StubReply::HangUp => {}
StubReply::Silence => {
tokio::time::sleep(Duration::from_secs(300)).await;
}
}
}
});
sock
}
#[tokio::test]
async fn send_framed_request_round_trips_a_typed_value() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = spawn_stub(
tmp.path(),
vec![StubReply::Bytes(b"{\"echoed\":41}\n".to_vec())],
);
let got: Pong = send_framed_request(
&sock,
&Ping {
method: "ping",
n: 41,
},
Duration::from_secs(5),
)
.await
.expect("round trip");
assert_eq!(got, Pong { echoed: 41 });
}
#[tokio::test]
async fn send_framed_request_accepts_a_frame_without_a_trailing_newline() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = spawn_stub(
tmp.path(),
vec![StubReply::Bytes(b"{\"echoed\":7}".to_vec())],
);
let got: Pong = send_framed_request(
&sock,
&Ping {
method: "ping",
n: 7,
},
Duration::from_secs(5),
)
.await
.expect("round trip");
assert_eq!(got, Pong { echoed: 7 });
}
#[tokio::test]
async fn send_framed_request_reports_no_response_when_peer_hangs_up() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = spawn_stub(tmp.path(), vec![StubReply::HangUp]);
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
)
.await
.expect_err("a silent hang-up is not a response");
assert!(
matches!(err, UdsRpcError::NoResponse { .. }),
"expected NoResponse, got {err:?}"
);
}
#[tokio::test]
async fn send_framed_request_rejects_an_over_long_frame() {
let tmp = tempfile::tempdir().expect("tempdir");
let flood = vec![b'x'; (MAX_FRAME_BYTES + 1) as usize];
let sock = spawn_stub(tmp.path(), vec![StubReply::Bytes(flood)]);
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(30),
)
.await
.expect_err("an unterminated flood must not be buffered without bound");
assert!(
matches!(err, UdsRpcError::FrameTooLarge { limit, .. } if limit == MAX_FRAME_BYTES),
"expected FrameTooLarge, got {err:?}"
);
}
#[tokio::test]
async fn send_framed_request_reports_a_decode_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = spawn_stub(tmp.path(), vec![StubReply::Bytes(b"not json\n".to_vec())]);
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
)
.await
.expect_err("garbage is not a response");
assert!(
matches!(err, UdsRpcError::Decode { .. }),
"expected Decode, got {err:?}"
);
}
#[tokio::test]
async fn send_framed_request_reports_dial_failure_for_a_missing_socket() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("absent.sock");
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
)
.await
.expect_err("no listener means no delivery");
assert!(
err.is_dial_failure(),
"expected a dial failure, got {err:?}"
);
}
#[tokio::test]
async fn send_framed_request_capped_honours_a_caller_supplied_budget() {
let tmp = tempfile::tempdir().expect("tempdir");
let flood = vec![b'x'; 4096];
let sock = spawn_stub(tmp.path(), vec![StubReply::Bytes(flood)]);
let err = send_framed_request_capped::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
1024,
)
.await
.expect_err("an unterminated flood past the caller's budget must be refused");
assert!(
matches!(err, UdsRpcError::FrameTooLarge { limit, .. } if limit == 1024),
"expected FrameTooLarge at the caller's 1024-byte budget, got {err:?}"
);
}
#[tokio::test]
async fn send_framed_notification_delivers_exactly_one_frame() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("notify.sock");
let listener: UnixListener = bind_hardened(&sock).expect("bind");
let served = tokio::spawn(async move {
let (mut conn, _) = listener.accept().await.expect("accept");
let mut got = Vec::new();
conn.read_to_end(&mut got).await.expect("drain");
got
});
send_framed_notification(
&sock,
&Ping {
method: "ping",
n: 9,
},
Duration::from_secs(5),
)
.await
.expect("a peer that never replies is still a successful delivery");
let bytes = served.await.expect("join");
let text = String::from_utf8(bytes).expect("utf8");
assert_eq!(
text, "{\"method\":\"ping\",\"n\":9}\n",
"one frame, one trailing newline, nothing else"
);
}
#[tokio::test]
async fn send_framed_notification_reports_dial_failure_for_a_missing_socket() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("absent.sock");
let err = send_framed_notification(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
)
.await
.expect_err("no listener means no delivery");
assert!(
err.is_dial_failure(),
"expected a dial failure, got {err:?}"
);
}
#[test]
fn encode_frame_appends_exactly_one_newline() {
let frame = encode_frame(&Ping {
method: "ping",
n: 3,
})
.expect("encode");
assert_eq!(frame, b"{\"method\":\"ping\",\"n\":3}\n");
assert_eq!(
frame.iter().filter(|b| **b == b'\n').count(),
1,
"a frame carries exactly one newline, and it is the terminator"
);
}
#[tokio::test]
async fn write_frame_terminates_each_value_with_one_newline() {
let mut buf: Vec<u8> = Vec::new();
write_frame(&mut buf, &Ping { method: "a", n: 1 })
.await
.expect("first frame");
write_frame(&mut buf, &Ping { method: "b", n: 2 })
.await
.expect("second frame");
assert_eq!(
String::from_utf8(buf).expect("utf8"),
"{\"method\":\"a\",\"n\":1}\n{\"method\":\"b\",\"n\":2}\n"
);
}
#[tokio::test]
async fn send_framed_request_times_out_on_a_silent_peer() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = spawn_stub(tmp.path(), vec![StubReply::Silence]);
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_millis(150),
)
.await
.expect_err("a peer that never answers must not hold the caller open");
assert!(
matches!(err, UdsRpcError::Timeout { .. }),
"expected Timeout, got {err:?}"
);
}
#[test]
fn read_failure_from_an_abortive_close_reads_as_a_hang_up() {
for kind in [
std::io::ErrorKind::ConnectionReset,
std::io::ErrorKind::ConnectionAborted,
] {
let err = classify_read_failure(
Path::new("/tmp/relay.sock"),
std::io::Error::new(kind, "peer went away"),
true,
);
assert!(
matches!(err, UdsRpcError::NoResponse { .. }),
"expected NoResponse for {kind:?}, got {err:?}"
);
}
}
#[test]
fn read_failure_after_partial_bytes_stays_a_read_error() {
let err = classify_read_failure(
Path::new("/tmp/relay.sock"),
std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer went away"),
false,
);
assert!(
matches!(err, UdsRpcError::Read { .. }),
"expected Read, got {err:?}"
);
}
#[test]
fn read_failure_from_an_unrelated_errno_stays_a_read_error() {
let err = classify_read_failure(
Path::new("/tmp/relay.sock"),
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
true,
);
assert!(
matches!(err, UdsRpcError::Read { .. }),
"expected Read, got {err:?}"
);
}
#[tokio::test]
async fn uds_client_retries_transient_connect_refusal() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("stub.sock");
let sleeps = std::cell::Cell::new(0u32);
let got: Pong = send_framed_request_with_sleeper(
&sock,
&Ping {
method: "ping",
n: 5,
},
Duration::from_secs(5),
MAX_FRAME_BYTES,
ConnectRetry::per_request(),
|_delay| {
let n = sleeps.get() + 1;
sleeps.set(n);
if n == 1 {
spawn_stub(
tmp.path(),
vec![StubReply::Bytes(b"{\"echoed\":5}\n".to_vec())],
);
}
async {}
},
)
.await
.expect("a socket that appears after the first attempt must still be reached");
assert_eq!(got, Pong { echoed: 5 });
assert_eq!(
sleeps.get(),
1,
"one backoff, then the second attempt succeeds"
);
}
#[tokio::test]
async fn uds_client_reports_the_attempt_count_after_the_retry_bound() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("absent.sock");
let policy = ConnectRetry::per_request();
let slept: std::cell::RefCell<Vec<Duration>> = std::cell::RefCell::new(Vec::new());
let err = send_framed_request_with_sleeper::<_, Pong, _, _>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(30),
MAX_FRAME_BYTES,
policy,
|delay| {
slept.borrow_mut().push(delay);
async {}
},
)
.await
.expect_err("a socket nothing ever binds must fail, not hang");
let slept = slept.into_inner();
assert_eq!(
slept,
vec![Duration::from_millis(20), Duration::from_millis(40)],
"the schedule doubles, and there is one fewer sleep than attempts"
);
assert!(
slept.iter().sum::<Duration>() >= policy.backoff_floor(),
"elapsed backoff must reach the policy's floor"
);
let UdsRpcError::ConnectRetriesExhausted {
path,
attempts,
source,
} = &err
else {
panic!("expected ConnectRetriesExhausted, got {err:?}");
};
assert_eq!(*attempts, policy.attempts, "exactly N attempts, no more");
assert_eq!(path, &sock, "the error names the socket it could not reach");
assert!(
source.is_dial_failure(),
"the last attempt's own error is carried, got {source:?}"
);
let text = err.to_string();
assert!(text.contains(&sock.display().to_string()), "got {text}");
assert!(text.contains("3 connect attempts failed"), "got {text}");
assert!(
text.contains("No such file or directory"),
"the last OS error must survive: got {text}"
);
}
#[tokio::test]
async fn send_framed_request_retrying_honours_a_caller_supplied_bound() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("absent.sock");
let err = send_framed_request_with_sleeper::<_, Pong, _, _>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
MAX_FRAME_BYTES,
ConnectRetry::single_attempt(),
|_delay| async { panic!("a single-attempt policy must never sleep") },
)
.await
.expect_err("one attempt, one failure");
assert!(
matches!(err, UdsRpcError::Dial { .. }),
"a single attempt reports its own error unwrapped, got {err:?}"
);
}
#[tokio::test]
async fn a_peer_that_reads_one_frame_and_closes_is_never_sent_a_second_copy() {
let tmp = tempfile::tempdir().expect("tempdir");
let sock = tmp.path().join("sockets").join("once.sock");
let listener: UnixListener = bind_hardened(&sock).expect("bind");
let dispatched = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = std::sync::Arc::clone(&dispatched);
tokio::spawn(async move {
for _ in 0..2 {
let Ok((conn, _)) = listener.accept().await else {
return;
};
let mut reader = BufReader::new(conn);
let mut line = Vec::new();
if reader.read_until(b'\n', &mut line).await.is_ok() && !line.is_empty() {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
drop(reader);
}
});
let err = send_framed_request::<_, Pong>(
&sock,
&Ping {
method: "ping",
n: 1,
},
Duration::from_secs(5),
)
.await
.expect_err("the peer never replies");
assert!(
!matches!(err, UdsRpcError::ConnectRetriesExhausted { .. }),
"a frame that reached the peer must never be redialled, got {err:?}"
);
assert_eq!(
dispatched.load(std::sync::atomic::Ordering::SeqCst),
1,
"the peer must see exactly one copy of the request"
);
}
#[test]
fn is_dial_failure_sees_through_the_retry_wrapper() {
let sock = PathBuf::from("/tmp/absent.sock");
let inner = UdsRpcError::Dial {
path: sock.clone(),
source: UdsSecurityError::Connect {
path: sock.clone(),
source: std::io::Error::from(std::io::ErrorKind::ConnectionRefused),
},
};
assert!(inner.is_dial_failure());
assert!(
UdsRpcError::ConnectRetriesExhausted {
path: sock.clone(),
attempts: 3,
source: Box::new(inner),
}
.is_dial_failure()
);
assert!(!UdsRpcError::NoResponse { path: sock }.is_dial_failure());
}
}