use std::io::{Read, Write};
use std::time::Duration;
use interprocess::local_socket::Stream as LegacyStream;
use prost::Message;
use running_process_platform_internal::{into_legacy_ipc_stream, platform::ipc};
pub const DEFAULT_HELLO_DEADLINE: Duration = Duration::from_secs(3);
use crate::broker::adopt::{IntoBackendIoError, OwnedBackendIo};
use crate::broker::client::connect_ipc_stream;
use crate::broker::connect_watchdog::{capture_connect_dump, ConnectWatchdog, WATCHDOG_GRACE};
use crate::broker::lifecycle::names::PipePathError;
use crate::broker::lifecycle::names_v2::{
broker_path_scope_hash, v2_program_pipe, BrokerPathIdentityError,
};
use crate::broker::lifecycle::sid::{user_sid_hash, SidError};
use crate::broker::protocol::{
hello_reply, read_frame, validate_frame_envelope, write_frame, Frame, FrameKind,
FrameValidationError, FramingError, Hello, HelloReply, Negotiated, PayloadEncoding, Refused,
CONTROL_PAYLOAD_PROTOCOL, ENVELOPE_VERSION, PROTOCOL_VERSION,
};
#[derive(Debug, thiserror::Error)]
pub enum BrokerV2Error {
#[error(transparent)]
Sid(#[from] SidError),
#[error(transparent)]
PipeName(#[from] PipePathError),
#[error("dial v2 broker pipe at {socket_path:?}: {source}")]
Dial {
socket_path: String,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Framing(#[from] FramingError),
#[error("Hello round-trip io: {0}")]
Io(#[from] std::io::Error),
#[error("HelloReply decode: {0}")]
Decode(#[from] prost::DecodeError),
#[error("HelloReply.result missing")]
MissingResult,
#[error("broker refused Hello: {reason}")]
Refused {
reason: String,
retry_after_ms: u64,
details: Box<Refused>,
},
#[error("Hello encode: {0}")]
Encode(#[from] prost::EncodeError),
}
#[derive(Debug, thiserror::Error)]
pub enum BrokerPathConnectError {
#[error(transparent)]
Identity(#[from] BrokerPathIdentityError),
#[error(transparent)]
Connect(#[from] BrokerV2Error),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ExplicitHelloError {
#[error(transparent)]
Broker(#[from] BrokerV2Error),
#[error("response Frame decode: {0}")]
DecodeFrame(prost::DecodeError),
#[error("unexpected broker response frame: {0}")]
UnexpectedResponseFrame(&'static str),
}
impl ExplicitHelloError {
fn into_broker_v2(self) -> BrokerV2Error {
match self {
Self::Broker(error) => error,
Self::DecodeFrame(error) => BrokerV2Error::Decode(error),
Self::UnexpectedResponseFrame(reason) => {
BrokerV2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, reason))
}
}
}
}
#[cfg(feature = "client-async")]
#[derive(Debug)]
pub struct AsyncClientSession {
inner: ClientSession,
}
#[cfg(feature = "client-async")]
impl AsyncClientSession {
pub async fn connect(program: &str, version_hint: &str) -> Result<Self, AsyncConnectError> {
Self::connect_with_deadline(program, version_hint, DEFAULT_HELLO_DEADLINE).await
}
pub async fn connect_with_deadline(
program: &str,
version_hint: &str,
deadline: Duration,
) -> Result<Self, AsyncConnectError> {
let program = program.to_owned();
let version_hint = version_hint.to_owned();
let joined = tokio::task::spawn_blocking(move || {
super::client_v2::connect_with_deadline(&program, &version_hint, deadline)
})
.await
.map_err(|err| AsyncConnectError::Join(err.to_string()))?;
Ok(Self { inner: joined? })
}
pub fn negotiated(&self) -> &Negotiated {
self.inner.negotiated()
}
pub async fn connect_backend(self) -> Result<LegacyStream, AsyncConnectError> {
let inner = self.inner;
tokio::task::spawn_blocking(move || inner.connect_backend())
.await
.map_err(|err| AsyncConnectError::Join(err.to_string()))?
.map_err(AsyncConnectError::Dial)
}
pub async fn into_backend_io(self) -> Result<OwnedBackendIo, AsyncConnectError> {
let inner = self.inner;
tokio::task::spawn_blocking(move || inner.into_backend_io())
.await
.map_err(|err| AsyncConnectError::Join(err.to_string()))?
.map_err(AsyncConnectError::Dial)
}
pub fn into_blocking(self) -> ClientSession {
self.inner
}
}
#[cfg(feature = "client-async")]
#[derive(Debug, thiserror::Error)]
pub enum AsyncConnectError {
#[error(transparent)]
Broker(#[from] BrokerV2Error),
#[error(transparent)]
Dial(#[from] BackendDialError),
#[error("the blocking worker did not complete: {0}")]
Join(String),
}
#[derive(Debug)]
pub struct ClientSession {
stream: ipc::Stream,
negotiated: Negotiated,
}
impl ClientSession {
pub fn negotiated(&self) -> &Negotiated {
&self.negotiated
}
pub fn into_inner(self) -> (LegacyStream, Negotiated) {
(into_legacy_ipc_stream(self.stream), self.negotiated)
}
pub fn connect_backend(self) -> Result<LegacyStream, BackendDialError> {
self.connect_backend_ipc().map(into_legacy_ipc_stream)
}
pub(crate) fn connect_backend_ipc(self) -> Result<ipc::Stream, BackendDialError> {
if self.negotiated.backend_pipe.is_empty() {
return Err(BackendDialError::EmptyBackendPipe);
}
connect_ipc_stream(&self.negotiated.backend_pipe).map_err(BackendDialError::Connect)
}
pub fn into_backend_io(self) -> Result<OwnedBackendIo, BackendDialError> {
let stream = self.connect_backend_ipc()?;
OwnedBackendIo::from_local_socket_stream(stream).map_err(BackendDialError::IntoBackendIo)
}
}
#[derive(Debug, thiserror::Error)]
pub enum BackendDialError {
#[error("broker negotiated but named no backend pipe")]
EmptyBackendPipe,
#[error("could not connect to the negotiated backend: {0}")]
Connect(#[source] std::io::Error),
#[error("could not take ownership of the backend socket: {0}")]
IntoBackendIo(#[source] IntoBackendIoError),
}
pub fn connect(program: &str, version_hint: &str) -> Result<ClientSession, BrokerV2Error> {
connect_service(program, program, version_hint)
}
pub fn connect_service(
program: &str,
service_name: &str,
version_hint: &str,
) -> Result<ClientSession, BrokerV2Error> {
connect_service_with_deadline(program, service_name, version_hint, DEFAULT_HELLO_DEADLINE)
}
pub fn connect_or_die(program: &str, version_hint: &str, deadline: Duration) -> ClientSession {
let watchdog = ConnectWatchdog::arm(deadline + WATCHDOG_GRACE);
match connect_with_deadline(program, version_hint, deadline) {
Ok(session) => {
drop(watchdog);
session
}
Err(err) => {
let error = err.to_string();
eprintln!(
"running-process: v2 broker for '{program}' unreachable within \
{deadline:?}: {error} — capturing a stack dump and exiting (no retry)"
);
if let Some(path) = capture_connect_dump(program, deadline, &error) {
eprintln!(
"running-process: all-thread stack dump written to {}",
path.display()
);
}
std::process::exit(1);
}
}
}
pub fn connect_with_deadline(
program: &str,
version_hint: &str,
deadline: Duration,
) -> Result<ClientSession, BrokerV2Error> {
connect_service_with_deadline(program, program, version_hint, deadline)
}
pub fn connect_service_with_deadline(
program: &str,
service_name: &str,
version_hint: &str,
deadline: Duration,
) -> Result<ClientSession, BrokerV2Error> {
let scope_hash = user_sid_hash()?;
connect_service_with_scope_hash_and_deadline(
program,
&scope_hash,
service_name,
version_hint,
deadline,
)
}
pub fn connect_service_for_broker_path_with_deadline(
program: &str,
broker_path: impl AsRef<std::path::Path>,
service_name: &str,
version_hint: &str,
deadline: Duration,
) -> Result<ClientSession, BrokerPathConnectError> {
let scope_hash = broker_path_scope_hash(broker_path)?;
let pipe_name = v2_program_pipe(program, &scope_hash, 0).map_err(BrokerV2Error::from)?;
let socket_path =
crate::broker::server::singleton_bind::resolve_path_scoped_socket_path(&pipe_name)
.map_err(|err| {
BrokerV2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, err))
})?;
Ok(connect_service_at_socket_with_deadline(
program,
socket_path,
service_name,
version_hint,
deadline,
)?)
}
pub fn connect_service_with_scope_hash_and_deadline(
program: &str,
scope_hash: &str,
service_name: &str,
version_hint: &str,
deadline: Duration,
) -> Result<ClientSession, BrokerV2Error> {
let pipe_name = v2_program_pipe(program, scope_hash, 0)?;
let socket_path = resolve_socket_path(&pipe_name)?;
connect_service_at_socket_with_deadline(
program,
socket_path,
service_name,
version_hint,
deadline,
)
}
#[cfg(feature = "client-async")]
pub(crate) fn connect_hello_at_endpoint_with_deadline(
broker_endpoint: impl Into<String>,
hello: Hello,
deadline: Duration,
) -> Result<ClientSession, ExplicitHelloError> {
let socket_path = broker_endpoint.into();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(connect_unbounded_with_hello(&socket_path, hello));
});
match rx.recv_timeout(deadline) {
Ok(result) => result,
Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("v2 broker Hello did not complete within {deadline:?}"),
))
.into()),
}
}
fn connect_service_at_socket_with_deadline(
program: &str,
socket_path: String,
service_name: &str,
version_hint: &str,
deadline: Duration,
) -> Result<ClientSession, BrokerV2Error> {
let program = program.to_owned();
let service_name = service_name.to_owned();
let version_hint = version_hint.to_owned();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(connect_unbounded(
&program,
&socket_path,
&service_name,
&version_hint,
));
});
match rx.recv_timeout(deadline) {
Ok(result) => result,
Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("v2 broker Hello did not complete within {deadline:?}"),
))),
}
}
fn connect_unbounded(
program: &str,
socket_path: &str,
service_name: &str,
version_hint: &str,
) -> Result<ClientSession, BrokerV2Error> {
let mut stream = connect_ipc_stream(socket_path).map_err(|source| BrokerV2Error::Dial {
socket_path: socket_path.to_string(),
source,
})?;
let hello = default_hello(program, service_name, version_hint);
let negotiated =
hello_round_trip(&mut stream, hello).map_err(ExplicitHelloError::into_broker_v2)?;
Ok(ClientSession { stream, negotiated })
}
#[cfg(feature = "client-async")]
fn connect_unbounded_with_hello(
socket_path: &str,
hello: Hello,
) -> Result<ClientSession, ExplicitHelloError> {
let mut stream = connect_ipc_stream(socket_path).map_err(|source| BrokerV2Error::Dial {
socket_path: socket_path.to_string(),
source,
})?;
let negotiated = hello_round_trip(&mut stream, hello)?;
Ok(ClientSession { stream, negotiated })
}
fn default_hello(program: &str, service_name: &str, version_hint: &str) -> Hello {
Hello {
client_min_protocol: ENVELOPE_VERSION as u32,
client_max_protocol: ENVELOPE_VERSION as u32,
service_name: service_name.to_string(),
wanted_version: version_hint.to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
client_capabilities: 0,
auth_token: Vec::new(),
request_id: format!("client_v2-{program}-{}", std::process::id()),
connection_id: 0,
peer_pid: std::process::id(),
client_lib_name: "running-process broker::client_v2".to_string(),
client_lib_version: env!("CARGO_PKG_VERSION").to_string(),
peer_attestation_nonce: Vec::new(),
capability_token: Vec::new(),
client_keepalive_secs: 0,
}
}
fn hello_round_trip<S: Read + Write>(
stream: &mut S,
hello: Hello,
) -> Result<Negotiated, ExplicitHelloError> {
let hello_bytes = hello.encode_to_vec();
let request_frame = Frame {
envelope_version: PROTOCOL_VERSION,
kind: FrameKind::Request as i32,
payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
payload: hello_bytes,
request_id: 1,
payload_encoding: PayloadEncoding::None as i32,
deadline_unix_ms: 0,
traceparent: String::new(),
tracestate: String::new(),
};
let body = request_frame.encode_to_vec();
write_frame(stream, &body).map_err(BrokerV2Error::from)?;
let reply_frame_bytes = read_frame(stream).map_err(BrokerV2Error::from)?;
let reply_frame =
Frame::decode(reply_frame_bytes.as_slice()).map_err(ExplicitHelloError::DecodeFrame)?;
validate_frame_envelope(&reply_frame, FrameKind::Response, CONTROL_PAYLOAD_PROTOCOL)
.map_err(map_response_frame_validation)?;
if reply_frame.request_id != request_frame.request_id {
return Err(ExplicitHelloError::UnexpectedResponseFrame(
"request_id does not match the Hello request",
));
}
let reply =
HelloReply::decode(reply_frame.payload.as_slice()).map_err(BrokerV2Error::Decode)?;
match reply.result {
Some(hello_reply::Result::Negotiated(n)) => Ok(n),
Some(hello_reply::Result::Refused(r)) => Err(BrokerV2Error::Refused {
reason: r.reason.clone(),
retry_after_ms: r.retry_after_ms,
details: Box::new(r),
}
.into()),
None => Err(BrokerV2Error::MissingResult.into()),
}
}
fn map_response_frame_validation(error: FrameValidationError) -> ExplicitHelloError {
ExplicitHelloError::UnexpectedResponseFrame(match error {
FrameValidationError::EnvelopeVersion { .. } => "envelope_version is not v1",
FrameValidationError::Kind { .. } => "kind is not RESPONSE",
FrameValidationError::PayloadProtocol { .. } => "payload_protocol is not control-plane",
FrameValidationError::PayloadEncoding { .. } => "payload is compressed",
})
}
fn resolve_socket_path(bare_name: &str) -> Result<String, BrokerV2Error> {
crate::platform::ipc::broker_endpoint_name(bare_name, false).map_err(BrokerV2Error::Io)
}
#[cfg(test)]
fn test_endpoint(socket_path: &str) -> ipc::Endpoint {
ipc::Endpoint::new(socket_path.to_owned()).expect("test endpoint")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
struct ScriptedHelloIo {
response: std::io::Cursor<Vec<u8>>,
request: Vec<u8>,
}
impl Read for ScriptedHelloIo {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.response.read(buf)
}
}
impl Write for ScriptedHelloIo {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.request.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn scripted_response(body: &[u8]) -> ScriptedHelloIo {
let mut response = Vec::new();
write_frame(&mut response, body).expect("frame scripted response");
ScriptedHelloIo {
response: std::io::Cursor::new(response),
request: Vec::new(),
}
}
fn valid_negotiated_response() -> Frame {
let reply = HelloReply {
result: Some(hello_reply::Result::Negotiated(Negotiated {
backend_pipe: "backend".into(),
..Default::default()
})),
};
Frame {
envelope_version: PROTOCOL_VERSION,
kind: FrameKind::Response as i32,
payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
payload: reply.encode_to_vec(),
request_id: 1,
payload_encoding: PayloadEncoding::None as i32,
deadline_unix_ms: 0,
traceparent: String::new(),
tracestate: String::new(),
}
}
#[test]
fn hello_rejects_invalid_response_envelopes_and_correlation() {
let mut invalid = Vec::new();
let mut frame = valid_negotiated_response();
frame.envelope_version += 1;
invalid.push(frame);
let mut frame = valid_negotiated_response();
frame.kind = FrameKind::Event as i32;
invalid.push(frame);
let mut frame = valid_negotiated_response();
frame.payload_protocol += 1;
invalid.push(frame);
let mut frame = valid_negotiated_response();
frame.payload_encoding = PayloadEncoding::Zstd as i32;
invalid.push(frame);
let mut frame = valid_negotiated_response();
frame.request_id = 0;
invalid.push(frame);
for frame in invalid {
let mut io = scripted_response(&frame.encode_to_vec());
assert!(matches!(
hello_round_trip(&mut io, default_hello("test", "service", "1")),
Err(ExplicitHelloError::UnexpectedResponseFrame(_))
));
}
}
#[test]
fn hello_distinguishes_outer_frame_and_inner_reply_decode_errors() {
let mut bad_frame = scripted_response(&[0xff, 0xff, 0xff]);
assert!(matches!(
hello_round_trip(&mut bad_frame, default_hello("test", "service", "1")),
Err(ExplicitHelloError::DecodeFrame(_))
));
let mut frame = valid_negotiated_response();
frame.payload = vec![0xff, 0xff, 0xff];
let mut bad_reply = scripted_response(&frame.encode_to_vec());
assert!(matches!(
hello_round_trip(&mut bad_reply, default_hello("test", "service", "1")),
Err(ExplicitHelloError::Broker(BrokerV2Error::Decode(_)))
));
}
fn read_hello_frame(stream: &mut impl Read) -> (Hello, u64) {
let bytes = read_frame(stream).expect("read Hello frame");
let frame = Frame::decode(bytes.as_slice()).expect("decode Frame");
(
Hello::decode(frame.payload.as_slice()).expect("decode Hello"),
frame.request_id,
)
}
fn write_hello_reply_frame(stream: &mut impl Write, request_id: u64, reply: &HelloReply) {
let reply_frame = Frame {
envelope_version: PROTOCOL_VERSION,
kind: FrameKind::Response as i32,
payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
payload: reply.encode_to_vec(),
request_id,
payload_encoding: PayloadEncoding::None as i32,
deadline_unix_ms: 0,
traceparent: String::new(),
tracestate: String::new(),
};
write_frame(stream, &reply_frame.encode_to_vec()).expect("write HelloReply frame");
}
struct SocketCleanup(Option<std::path::PathBuf>);
impl Drop for SocketCleanup {
fn drop(&mut self) {
if let Some(path) = &self.0 {
let _ = std::fs::remove_file(path);
}
}
}
fn reserve_endpoint_path(socket_path: &str) -> SocketCleanup {
if !crate::platform::ipc::endpoint_is_filesystem_backed() {
return SocketCleanup(None);
}
let path = std::path::Path::new(socket_path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::remove_file(path);
SocketCleanup(Some(path.to_path_buf()))
}
fn spawn_stub_broker(socket_path: String) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
let mut stream = listener.accept().expect("accept");
let (hello, request_id) = read_hello_frame(&mut stream);
let reply = HelloReply {
result: Some(hello_reply::Result::Negotiated(Negotiated {
negotiated_protocol: ENVELOPE_VERSION as u32,
daemon_version: "stub-1.2.3".to_string(),
backend_pipe: String::new(),
warnings: Vec::new(),
server_capabilities: 0,
keepalive_interval_secs: 0,
handle_passed_token: Vec::new(),
connection_id: 0x00C0_FFEE,
})),
};
write_hello_reply_frame(&mut stream, request_id, &reply);
let _ = hello.service_name;
});
rx
}
#[test]
fn connect_completes_hello_round_trip_against_stub_broker() {
let program = "client-v2-stub";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_stub_broker(socket_path.clone());
ready
.recv_timeout(Duration::from_secs(2))
.expect("stub broker listening");
let start = Instant::now();
let session = loop {
match connect(program, "0.0.0") {
Ok(s) => break s,
Err(err) if start.elapsed() < Duration::from_secs(2) => {
eprintln!("connect retry after error: {err}");
std::thread::sleep(Duration::from_millis(50));
continue;
}
Err(err) => panic!("connect failed after retries: {err}"),
}
};
let neg = session.negotiated();
assert_eq!(neg.negotiated_protocol, ENVELOPE_VERSION as u32);
assert_eq!(neg.connection_id, 0x00C0_FFEE);
assert_eq!(neg.daemon_version, "stub-1.2.3");
}
#[test]
fn connect_service_dials_broker_program_but_routes_named_service() {
let program = "client-v2-router";
let service_name = "soldr-daemon-root-version-hash";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let (ready_tx, ready_rx) = mpsc::channel();
let (hello_tx, hello_rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
ready_tx.send(()).expect("ready");
let mut stream = listener.accept().expect("accept");
let (hello, request_id) = read_hello_frame(&mut stream);
hello_tx
.send(hello.service_name.clone())
.expect("observed service name");
write_hello_reply_frame(
&mut stream,
request_id,
&HelloReply {
result: Some(hello_reply::Result::Negotiated(Negotiated {
backend_pipe: "route-endpoint".into(),
..Default::default()
})),
},
);
});
ready_rx
.recv_timeout(Duration::from_secs(2))
.expect("stub broker listening");
let session = connect_service(program, service_name, "0.8.0")
.expect("independent service route connects");
assert_eq!(session.negotiated().backend_pipe, "route-endpoint");
assert_eq!(
hello_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
service_name
);
}
#[test]
fn connect_with_no_broker_returns_dial_error() {
let err =
connect("client-v2-no-broker-ever", "0.0.0").expect_err("no broker => Dial error");
match err {
BrokerV2Error::Dial { .. } => {}
other => panic!("expected Dial, got: {other:?}"),
}
}
fn spawn_stall_broker(socket_path: String) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
let _stream = listener.accept().expect("accept");
thread::sleep(Duration::from_secs(60));
});
rx
}
#[test]
fn connect_with_deadline_fires_on_stalling_broker() {
let program = "client-v2-stall-deadline";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_stall_broker(socket_path);
ready
.recv_timeout(Duration::from_secs(2))
.expect("stall broker listening");
let start = Instant::now();
let err = connect_with_deadline(program, "0.0.0", Duration::from_millis(200))
.expect_err("stall broker => deadline TimedOut");
let elapsed = start.elapsed();
match err {
BrokerV2Error::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::TimedOut),
other => panic!("expected Io(TimedOut), got: {other:?}"),
}
assert!(
elapsed < Duration::from_secs(2),
"deadline should fire within budget; took {elapsed:?}"
);
}
fn spawn_refusing_broker(socket_path: String, retry_after_ms: u64) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
let mut stream = listener.accept().expect("accept");
let (_hello, request_id) = read_hello_frame(&mut stream);
let reply = HelloReply {
result: Some(hello_reply::Result::Refused(Refused {
code: 0,
reason: "stub refusal".to_string(),
retry_after_ms,
..Refused::default()
})),
};
write_hello_reply_frame(&mut stream, request_id, &reply);
});
rx
}
fn spawn_multi_accept_stub_broker(socket_path: String, count: usize) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
for _ in 0..count {
let mut stream = match listener.accept() {
Ok(s) => s,
Err(_) => break,
};
let (_hello, request_id) = read_hello_frame(&mut stream);
let reply = HelloReply {
result: Some(hello_reply::Result::Negotiated(Negotiated {
negotiated_protocol: ENVELOPE_VERSION as u32,
daemon_version: "stub-multi-1".to_string(),
backend_pipe: String::new(),
warnings: Vec::new(),
server_capabilities: 0,
keepalive_interval_secs: 0,
handle_passed_token: Vec::new(),
connection_id: 0x0FFF_F1EE,
})),
};
write_hello_reply_frame(&mut stream, request_id, &reply);
}
});
rx
}
#[test]
fn concurrent_connects_against_multi_accept_broker() {
let program = "client-v2-concurrent-multi";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
const N: usize = 8;
let ready = spawn_multi_accept_stub_broker(socket_path, N);
ready
.recv_timeout(Duration::from_secs(2))
.expect("multi-accept broker listening");
let start = Instant::now();
let handles: Vec<_> = (0..N)
.map(|_| {
let p = program.to_string();
thread::spawn(move || connect_with_deadline(&p, "0.0.0", Duration::from_secs(2)))
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let elapsed = start.elapsed();
let ok = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(
ok, N,
"all {N} concurrent connects must succeed; got {ok} ok, full results: {results:?}"
);
assert!(
elapsed < Duration::from_secs(5),
"concurrent connect took {elapsed:?}; expected < 5s"
);
for session in results.iter().flatten() {
assert_eq!(session.negotiated().connection_id, 0x0FFF_F1EE);
assert_eq!(session.negotiated().daemon_version, "stub-multi-1");
}
}
fn spawn_missing_result_broker(socket_path: String) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
let mut stream = listener.accept().expect("accept");
let (_hello, request_id) = read_hello_frame(&mut stream);
let reply = HelloReply { result: None };
write_hello_reply_frame(&mut stream, request_id, &reply);
});
rx
}
#[test]
fn connect_rejects_hello_reply_with_missing_result_oneof() {
let program = "client-v2-missing-result";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_missing_result_broker(socket_path);
ready
.recv_timeout(Duration::from_secs(2))
.expect("missing-result broker listening");
let start = Instant::now();
let err = loop {
match connect(program, "0.0.0") {
Err(e) => break e,
Ok(_) if start.elapsed() < Duration::from_secs(2) => {
thread::sleep(Duration::from_millis(50));
continue;
}
Ok(_) => panic!("expected MissingResult, got Ok"),
}
};
assert!(
matches!(err, BrokerV2Error::MissingResult),
"expected MissingResult, got: {err:?}"
);
}
fn spawn_drop_on_accept_broker(socket_path: String) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let endpoint = test_endpoint(&socket_path);
let _cleanup = reserve_endpoint_path(&socket_path);
let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
tx.send(()).expect("send listener-ready signal");
let stream = listener.accept().expect("accept");
drop(stream); });
rx
}
#[test]
fn connect_returns_err_on_premature_disconnect() {
let program = "client-v2-prem-disconnect";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_drop_on_accept_broker(socket_path);
ready
.recv_timeout(Duration::from_secs(2))
.expect("drop-on-accept broker listening");
let start = Instant::now();
let err = loop {
match connect_with_deadline(program, "0.0.0", Duration::from_millis(500)) {
Err(e) => break e,
Ok(_) if start.elapsed() < Duration::from_secs(2) => {
thread::sleep(Duration::from_millis(50));
continue;
}
Ok(_) => panic!("expected transport error, got Ok"),
}
};
match err {
BrokerV2Error::Framing(_) | BrokerV2Error::Io(_) | BrokerV2Error::Dial { .. } => {}
other => panic!("expected transport variant, got: {other:?}"),
}
assert!(
start.elapsed() < Duration::from_secs(2),
"must not hang past deadline; took {:?}",
start.elapsed()
);
}
#[test]
fn connect_rejects_invalid_program_names_before_dial() {
let too_long = "a".repeat(65);
for bad in [
"zccache\0evil",
"../etc/passwd",
r"a\b",
"Zccache",
"a b",
too_long.as_str(),
"",
] {
let err = connect(bad, "0.0.0")
.expect_err(&format!("invalid program name {bad:?} must be rejected"));
assert!(
matches!(err, BrokerV2Error::PipeName(_)),
"expected PipeName for {bad:?}, got: {err:?}"
);
}
}
#[test]
fn refused_with_u64_max_retry_after_ms_round_trips() {
let program = "client-v2-refused-u64-max";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_refusing_broker(socket_path, u64::MAX);
ready
.recv_timeout(Duration::from_secs(2))
.expect("refusing broker listening");
let start = Instant::now();
let err = loop {
match connect(program, "0.0.0") {
Err(e) => break e,
Ok(_) if start.elapsed() < Duration::from_secs(2) => {
thread::sleep(Duration::from_millis(50));
continue;
}
Ok(_) => panic!("expected Refused, got Ok"),
}
};
match err {
BrokerV2Error::Refused {
retry_after_ms,
details,
..
} => {
assert_eq!(retry_after_ms, u64::MAX);
assert_eq!(details.retry_after_ms, u64::MAX);
let _safe_duration = Duration::from_millis(retry_after_ms);
}
other => panic!("expected Refused, got: {other:?}"),
}
}
#[test]
fn refused_exposes_retry_after_ms_top_level() {
let program = "client-v2-refused-retry";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_refusing_broker(socket_path, 1234);
ready
.recv_timeout(Duration::from_secs(2))
.expect("refusing broker listening");
let start = Instant::now();
let err = loop {
match connect(program, "0.0.0") {
Err(e) => break e,
Ok(_) if start.elapsed() < Duration::from_secs(2) => {
thread::sleep(Duration::from_millis(50));
continue;
}
Ok(_) => panic!("expected Refused"),
}
};
match err {
BrokerV2Error::Refused {
retry_after_ms,
reason,
details,
} => {
assert_eq!(
retry_after_ms, 1234,
"retry hint must surface top-level (was: {retry_after_ms})"
);
assert_eq!(reason, "stub refusal");
assert_eq!(
details.retry_after_ms, 1234,
"details payload still carries the field for full diagnostics"
);
}
other => panic!("expected Refused, got: {other:?}"),
}
}
#[cfg(feature = "client-async")]
#[test]
fn the_hello_does_not_occupy_the_runtime_worker() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let program = "client-v2-async-nonblocking";
let sid = user_sid_hash().expect("user_sid_hash");
let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
let ready = spawn_stall_broker(socket_path);
ready
.recv_timeout(Duration::from_secs(2))
.expect("stall broker listening");
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("current-thread runtime");
rt.block_on(async {
let progressed = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&progressed);
let other = tokio::spawn(async move {
flag.store(true, Ordering::SeqCst);
});
let _ = AsyncClientSession::connect_with_deadline(
program,
"0.0.0",
Duration::from_millis(200),
)
.await;
assert!(
progressed.load(Ordering::SeqCst),
"the runtime made no progress during the Hello — it ran on the worker"
);
let _ = other.await;
});
}
}
#[cfg(test)]
mod backend_dial_tests {
use super::*;
fn session_with(broker_endpoint: &str, backend_pipe: &str) -> ClientSession {
let endpoint = ipc::Endpoint::new(broker_endpoint.to_owned()).expect("broker endpoint");
let stream = ipc::Stream::connect(&endpoint).expect("dial broker");
ClientSession {
stream,
negotiated: Negotiated {
backend_pipe: backend_pipe.to_string(),
..Default::default()
},
}
}
fn temp_endpoint(tag: &str) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().expect("tempdir");
let path = if cfg!(windows) {
format!(r"\.\pipe\rp-v2-dial-{tag}-{}", std::process::id())
} else {
dir.path().join(format!("{tag}.sock")).display().to_string()
};
(dir, path)
}
#[test]
fn a_negotiated_reply_with_no_backend_pipe_is_its_own_error() {
let (_dir, path) = temp_endpoint("empty");
let listener = ipc::Listener::bind(&test_endpoint(&path)).expect("bind");
let session = session_with(&path, "");
let _accepted = listener.accept().expect("accept");
let err = session
.connect_backend()
.expect_err("an empty backend pipe must not be dialed");
assert!(
matches!(err, BackendDialError::EmptyBackendPipe),
"expected EmptyBackendPipe, got {err:?}"
);
}
#[test]
fn the_dial_connects_to_the_backend_and_carries_traffic() {
let (_bdir, broker_path) = temp_endpoint("broker");
let broker_listener =
ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
let (_kdir, backend_path) = temp_endpoint("backend");
let backend_listener =
ipc::Listener::bind(&test_endpoint(&backend_path)).expect("bind backend");
let session = session_with(&broker_path, &backend_path);
let _broker_accepted = broker_listener.accept().expect("accept broker");
let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = accepted_tx.send(backend_listener.accept());
});
let mut data = session
.connect_backend()
.expect("dial the negotiated backend");
let mut served = accepted_rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("nothing connected to the backend within 10s")
.expect("backend accept");
data.write_all(b"ping").expect("write to backend");
data.flush().expect("flush");
let mut got = [0u8; 4];
served.read_exact(&mut got).expect("backend read");
assert_eq!(&got, b"ping", "bytes did not reach the backend");
}
#[test]
fn a_backend_that_is_not_listening_reports_a_connect_error() {
let (_bdir, broker_path) = temp_endpoint("broker2");
let broker_listener =
ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
let (_kdir, dead_path) = temp_endpoint("nobody-home");
let session = session_with(&broker_path, &dead_path);
let _broker_accepted = broker_listener.accept().expect("accept broker");
let err = session
.connect_backend()
.expect_err("nothing is listening there");
assert!(
matches!(err, BackendDialError::Connect(_)),
"expected Connect, got {err:?}"
);
}
#[cfg(feature = "client-async")]
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.build()
.expect("current-thread runtime")
}
#[cfg(feature = "client-async")]
#[test]
fn the_async_dial_reaches_the_backend() {
let (_bdir, broker_path) = temp_endpoint("abroker");
let broker_listener =
ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
let (_kdir, backend_path) = temp_endpoint("abackend");
let backend_listener =
ipc::Listener::bind(&test_endpoint(&backend_path)).expect("bind backend");
let inner = session_with(&broker_path, &backend_path);
let _broker_accepted = broker_listener.accept().expect("accept broker");
let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = accepted_tx.send(backend_listener.accept());
});
let session = AsyncClientSession { inner };
let mut data = runtime()
.block_on(session.connect_backend())
.expect("async dial");
let mut served = accepted_rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("nothing connected to the backend within 10s")
.expect("backend accept");
data.write_all(b"pong").expect("write");
data.flush().expect("flush");
let mut got = [0u8; 4];
served.read_exact(&mut got).expect("read");
assert_eq!(&got, b"pong", "bytes did not reach the backend");
}
#[cfg(feature = "client-async")]
#[test]
fn a_dial_failure_is_not_reported_as_a_runtime_failure() {
let (_bdir, broker_path) = temp_endpoint("abroker2");
let broker_listener =
ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
let (_kdir, dead_path) = temp_endpoint("anobody");
let inner = session_with(&broker_path, &dead_path);
let _broker_accepted = broker_listener.accept().expect("accept broker");
let session = AsyncClientSession { inner };
let err = runtime()
.block_on(session.connect_backend())
.expect_err("nothing is listening there");
assert!(
matches!(err, AsyncConnectError::Dial(BackendDialError::Connect(_))),
"expected Dial(Connect), got {err:?}"
);
}
#[cfg(feature = "client-async")]
#[test]
fn an_empty_backend_pipe_survives_the_async_hop() {
let (_dir, path) = temp_endpoint("aempty");
let listener = ipc::Listener::bind(&test_endpoint(&path)).expect("bind");
let inner = session_with(&path, "");
let _accepted = listener.accept().expect("accept");
let session = AsyncClientSession { inner };
let err = runtime()
.block_on(session.connect_backend())
.expect_err("an empty pipe must not be dialed");
assert!(
matches!(
err,
AsyncConnectError::Dial(BackendDialError::EmptyBackendPipe)
),
"expected Dial(EmptyBackendPipe), got {err:?}"
);
}
}