1use std::io::{Read, Write};
17use std::time::Duration;
18
19use interprocess::local_socket::Stream as LegacyStream;
20use prost::Message;
21use running_process_platform_internal::{into_legacy_ipc_stream, platform::ipc};
22
23pub const DEFAULT_HELLO_DEADLINE: Duration = Duration::from_secs(3);
32
33use crate::broker::adopt::{IntoBackendIoError, OwnedBackendIo};
34use crate::broker::client::connect_ipc_stream;
35use crate::broker::connect_watchdog::{capture_connect_dump, ConnectWatchdog, WATCHDOG_GRACE};
36use crate::broker::lifecycle::names::PipePathError;
37use crate::broker::lifecycle::names_v2::{
38 broker_path_scope_hash, v2_program_pipe, BrokerPathIdentityError,
39};
40use crate::broker::lifecycle::sid::{user_sid_hash, SidError};
41use crate::broker::protocol::{
42 hello_reply, read_frame, validate_frame_envelope, write_frame, Frame, FrameKind,
43 FrameValidationError, FramingError, Hello, HelloReply, Negotiated, PayloadEncoding, Refused,
44 CONTROL_PAYLOAD_PROTOCOL, ENVELOPE_VERSION, PROTOCOL_VERSION,
45};
46
47#[derive(Debug, thiserror::Error)]
49pub enum BrokerV2Error {
50 #[error(transparent)]
52 Sid(#[from] SidError),
53
54 #[error(transparent)]
56 PipeName(#[from] PipePathError),
57
58 #[error("dial v2 broker pipe at {socket_path:?}: {source}")]
60 Dial {
61 socket_path: String,
63 #[source]
65 source: std::io::Error,
66 },
67
68 #[error(transparent)]
71 Framing(#[from] FramingError),
72
73 #[error("Hello round-trip io: {0}")]
75 Io(#[from] std::io::Error),
76
77 #[error("HelloReply decode: {0}")]
79 Decode(#[from] prost::DecodeError),
80
81 #[error("HelloReply.result missing")]
83 MissingResult,
84
85 #[error("broker refused Hello: {reason}")]
94 Refused {
95 reason: String,
97 retry_after_ms: u64,
100 details: Box<Refused>,
102 },
103
104 #[error("Hello encode: {0}")]
106 Encode(#[from] prost::EncodeError),
107}
108
109#[derive(Debug, thiserror::Error)]
115pub enum BrokerPathConnectError {
116 #[error(transparent)]
118 Identity(#[from] BrokerPathIdentityError),
119
120 #[error(transparent)]
122 Connect(#[from] BrokerV2Error),
123}
124
125#[derive(Debug, thiserror::Error)]
129pub(crate) enum ExplicitHelloError {
130 #[error(transparent)]
132 Broker(#[from] BrokerV2Error),
133
134 #[error("response Frame decode: {0}")]
136 DecodeFrame(prost::DecodeError),
137
138 #[error("unexpected broker response frame: {0}")]
141 UnexpectedResponseFrame(&'static str),
142}
143
144impl ExplicitHelloError {
145 fn into_broker_v2(self) -> BrokerV2Error {
146 match self {
147 Self::Broker(error) => error,
148 Self::DecodeFrame(error) => BrokerV2Error::Decode(error),
149 Self::UnexpectedResponseFrame(reason) => {
150 BrokerV2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, reason))
151 }
152 }
153 }
154}
155
156#[cfg(feature = "client-async")]
171#[derive(Debug)]
172pub struct AsyncClientSession {
173 inner: ClientSession,
174}
175
176#[cfg(feature = "client-async")]
177impl AsyncClientSession {
178 pub async fn connect(program: &str, version_hint: &str) -> Result<Self, AsyncConnectError> {
183 Self::connect_with_deadline(program, version_hint, DEFAULT_HELLO_DEADLINE).await
184 }
185
186 pub async fn connect_with_deadline(
188 program: &str,
189 version_hint: &str,
190 deadline: Duration,
191 ) -> Result<Self, AsyncConnectError> {
192 let program = program.to_owned();
193 let version_hint = version_hint.to_owned();
194 let joined = tokio::task::spawn_blocking(move || {
195 super::client_v2::connect_with_deadline(&program, &version_hint, deadline)
196 })
197 .await
198 .map_err(|err| AsyncConnectError::Join(err.to_string()))?;
199 Ok(Self { inner: joined? })
200 }
201
202 pub fn negotiated(&self) -> &Negotiated {
204 self.inner.negotiated()
205 }
206
207 pub async fn connect_backend(self) -> Result<LegacyStream, AsyncConnectError> {
214 let inner = self.inner;
215 tokio::task::spawn_blocking(move || inner.connect_backend())
216 .await
217 .map_err(|err| AsyncConnectError::Join(err.to_string()))?
218 .map_err(AsyncConnectError::Dial)
219 }
220
221 pub async fn into_backend_io(self) -> Result<OwnedBackendIo, AsyncConnectError> {
224 let inner = self.inner;
225 tokio::task::spawn_blocking(move || inner.into_backend_io())
226 .await
227 .map_err(|err| AsyncConnectError::Join(err.to_string()))?
228 .map_err(AsyncConnectError::Dial)
229 }
230
231 pub fn into_blocking(self) -> ClientSession {
233 self.inner
234 }
235}
236
237#[cfg(feature = "client-async")]
244#[derive(Debug, thiserror::Error)]
245pub enum AsyncConnectError {
246 #[error(transparent)]
248 Broker(#[from] BrokerV2Error),
249
250 #[error(transparent)]
252 Dial(#[from] BackendDialError),
253
254 #[error("the blocking worker did not complete: {0}")]
258 Join(String),
259}
260
261#[derive(Debug)]
268pub struct ClientSession {
269 stream: ipc::Stream,
270 negotiated: Negotiated,
271}
272
273impl ClientSession {
274 pub fn negotiated(&self) -> &Negotiated {
276 &self.negotiated
277 }
278
279 pub fn into_inner(self) -> (LegacyStream, Negotiated) {
284 (into_legacy_ipc_stream(self.stream), self.negotiated)
285 }
286
287 pub fn connect_backend(self) -> Result<LegacyStream, BackendDialError> {
304 self.connect_backend_ipc().map(into_legacy_ipc_stream)
305 }
306
307 pub(crate) fn connect_backend_ipc(self) -> Result<ipc::Stream, BackendDialError> {
314 if self.negotiated.backend_pipe.is_empty() {
315 return Err(BackendDialError::EmptyBackendPipe);
316 }
317 connect_ipc_stream(&self.negotiated.backend_pipe).map_err(BackendDialError::Connect)
318 }
319
320 pub fn into_backend_io(self) -> Result<OwnedBackendIo, BackendDialError> {
337 let stream = self.connect_backend_ipc()?;
338 OwnedBackendIo::from_local_socket_stream(stream).map_err(BackendDialError::IntoBackendIo)
339 }
340}
341
342#[derive(Debug, thiserror::Error)]
349pub enum BackendDialError {
350 #[error("broker negotiated but named no backend pipe")]
357 EmptyBackendPipe,
358
359 #[error("could not connect to the negotiated backend: {0}")]
361 Connect(#[source] std::io::Error),
362
363 #[error("could not take ownership of the backend socket: {0}")]
366 IntoBackendIo(#[source] IntoBackendIoError),
367}
368
369pub fn connect(program: &str, version_hint: &str) -> Result<ClientSession, BrokerV2Error> {
383 connect_service(program, program, version_hint)
384}
385
386pub fn connect_service(
393 program: &str,
394 service_name: &str,
395 version_hint: &str,
396) -> Result<ClientSession, BrokerV2Error> {
397 connect_service_with_deadline(program, service_name, version_hint, DEFAULT_HELLO_DEADLINE)
398}
399
400pub fn connect_or_die(program: &str, version_hint: &str, deadline: Duration) -> ClientSession {
416 let watchdog = ConnectWatchdog::arm(deadline + WATCHDOG_GRACE);
420
421 match connect_with_deadline(program, version_hint, deadline) {
422 Ok(session) => {
423 drop(watchdog);
424 session
425 }
426 Err(err) => {
427 let error = err.to_string();
428 eprintln!(
429 "running-process: v2 broker for '{program}' unreachable within \
430 {deadline:?}: {error} — capturing a stack dump and exiting (no retry)"
431 );
432 if let Some(path) = capture_connect_dump(program, deadline, &error) {
433 eprintln!(
434 "running-process: all-thread stack dump written to {}",
435 path.display()
436 );
437 }
438 std::process::exit(1);
439 }
440 }
441}
442
443pub fn connect_with_deadline(
452 program: &str,
453 version_hint: &str,
454 deadline: Duration,
455) -> Result<ClientSession, BrokerV2Error> {
456 connect_service_with_deadline(program, program, version_hint, deadline)
457}
458
459pub fn connect_service_with_deadline(
461 program: &str,
462 service_name: &str,
463 version_hint: &str,
464 deadline: Duration,
465) -> Result<ClientSession, BrokerV2Error> {
466 let scope_hash = user_sid_hash()?;
467 connect_service_with_scope_hash_and_deadline(
468 program,
469 &scope_hash,
470 service_name,
471 version_hint,
472 deadline,
473 )
474}
475
476pub fn connect_service_for_broker_path_with_deadline(
482 program: &str,
483 broker_path: impl AsRef<std::path::Path>,
484 service_name: &str,
485 version_hint: &str,
486 deadline: Duration,
487) -> Result<ClientSession, BrokerPathConnectError> {
488 let scope_hash = broker_path_scope_hash(broker_path)?;
489 let pipe_name = v2_program_pipe(program, &scope_hash, 0).map_err(BrokerV2Error::from)?;
490 let socket_path =
491 crate::broker::server::singleton_bind::resolve_path_scoped_socket_path(&pipe_name)
492 .map_err(|err| {
493 BrokerV2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, err))
494 })?;
495 Ok(connect_service_at_socket_with_deadline(
496 program,
497 socket_path,
498 service_name,
499 version_hint,
500 deadline,
501 )?)
502}
503
504pub fn connect_service_with_scope_hash_and_deadline(
511 program: &str,
512 scope_hash: &str,
513 service_name: &str,
514 version_hint: &str,
515 deadline: Duration,
516) -> Result<ClientSession, BrokerV2Error> {
517 let pipe_name = v2_program_pipe(program, scope_hash, 0)?;
518 let socket_path = resolve_socket_path(&pipe_name)?;
519 connect_service_at_socket_with_deadline(
520 program,
521 socket_path,
522 service_name,
523 version_hint,
524 deadline,
525 )
526}
527
528#[cfg(feature = "client-async")]
535pub(crate) fn connect_hello_at_endpoint_with_deadline(
536 broker_endpoint: impl Into<String>,
537 hello: Hello,
538 deadline: Duration,
539) -> Result<ClientSession, ExplicitHelloError> {
540 let socket_path = broker_endpoint.into();
541 let (tx, rx) = std::sync::mpsc::channel();
542 std::thread::spawn(move || {
543 let _ = tx.send(connect_unbounded_with_hello(&socket_path, hello));
544 });
545 match rx.recv_timeout(deadline) {
546 Ok(result) => result,
547 Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
548 std::io::ErrorKind::TimedOut,
549 format!("v2 broker Hello did not complete within {deadline:?}"),
550 ))
551 .into()),
552 }
553}
554
555fn connect_service_at_socket_with_deadline(
556 program: &str,
557 socket_path: String,
558 service_name: &str,
559 version_hint: &str,
560 deadline: Duration,
561) -> Result<ClientSession, BrokerV2Error> {
562 let program = program.to_owned();
563 let service_name = service_name.to_owned();
564 let version_hint = version_hint.to_owned();
565 let (tx, rx) = std::sync::mpsc::channel();
566 std::thread::spawn(move || {
567 let _ = tx.send(connect_unbounded(
568 &program,
569 &socket_path,
570 &service_name,
571 &version_hint,
572 ));
573 });
574 match rx.recv_timeout(deadline) {
575 Ok(result) => result,
576 Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
577 std::io::ErrorKind::TimedOut,
578 format!("v2 broker Hello did not complete within {deadline:?}"),
579 ))),
580 }
581}
582
583fn connect_unbounded(
586 program: &str,
587 socket_path: &str,
588 service_name: &str,
589 version_hint: &str,
590) -> Result<ClientSession, BrokerV2Error> {
591 let mut stream = connect_ipc_stream(socket_path).map_err(|source| BrokerV2Error::Dial {
592 socket_path: socket_path.to_string(),
593 source,
594 })?;
595 let hello = default_hello(program, service_name, version_hint);
596 let negotiated =
597 hello_round_trip(&mut stream, hello).map_err(ExplicitHelloError::into_broker_v2)?;
598 Ok(ClientSession { stream, negotiated })
599}
600
601#[cfg(feature = "client-async")]
602fn connect_unbounded_with_hello(
603 socket_path: &str,
604 hello: Hello,
605) -> Result<ClientSession, ExplicitHelloError> {
606 let mut stream = connect_ipc_stream(socket_path).map_err(|source| BrokerV2Error::Dial {
607 socket_path: socket_path.to_string(),
608 source,
609 })?;
610 let negotiated = hello_round_trip(&mut stream, hello)?;
611 Ok(ClientSession { stream, negotiated })
612}
613
614fn default_hello(program: &str, service_name: &str, version_hint: &str) -> Hello {
615 Hello {
616 client_min_protocol: ENVELOPE_VERSION as u32,
617 client_max_protocol: ENVELOPE_VERSION as u32,
618 service_name: service_name.to_string(),
619 wanted_version: version_hint.to_string(),
620 client_version: env!("CARGO_PKG_VERSION").to_string(),
621 client_capabilities: 0,
622 auth_token: Vec::new(),
623 request_id: format!("client_v2-{program}-{}", std::process::id()),
624 connection_id: 0,
625 peer_pid: std::process::id(),
626 client_lib_name: "running-process broker::client_v2".to_string(),
627 client_lib_version: env!("CARGO_PKG_VERSION").to_string(),
628 peer_attestation_nonce: Vec::new(),
629 capability_token: Vec::new(),
630 client_keepalive_secs: 0,
631 }
632}
633
634fn hello_round_trip<S: Read + Write>(
635 stream: &mut S,
636 hello: Hello,
637) -> Result<Negotiated, ExplicitHelloError> {
638 let hello_bytes = hello.encode_to_vec();
654 let request_frame = Frame {
655 envelope_version: PROTOCOL_VERSION,
656 kind: FrameKind::Request as i32,
657 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
658 payload: hello_bytes,
659 request_id: 1,
660 payload_encoding: PayloadEncoding::None as i32,
661 deadline_unix_ms: 0,
662 traceparent: String::new(),
663 tracestate: String::new(),
664 };
665 let body = request_frame.encode_to_vec();
666 write_frame(stream, &body).map_err(BrokerV2Error::from)?;
667
668 let reply_frame_bytes = read_frame(stream).map_err(BrokerV2Error::from)?;
669 let reply_frame =
670 Frame::decode(reply_frame_bytes.as_slice()).map_err(ExplicitHelloError::DecodeFrame)?;
671 validate_frame_envelope(&reply_frame, FrameKind::Response, CONTROL_PAYLOAD_PROTOCOL)
672 .map_err(map_response_frame_validation)?;
673 if reply_frame.request_id != request_frame.request_id {
674 return Err(ExplicitHelloError::UnexpectedResponseFrame(
675 "request_id does not match the Hello request",
676 ));
677 }
678 let reply =
679 HelloReply::decode(reply_frame.payload.as_slice()).map_err(BrokerV2Error::Decode)?;
680 match reply.result {
681 Some(hello_reply::Result::Negotiated(n)) => Ok(n),
682 Some(hello_reply::Result::Refused(r)) => Err(BrokerV2Error::Refused {
683 reason: r.reason.clone(),
684 retry_after_ms: r.retry_after_ms,
685 details: Box::new(r),
686 }
687 .into()),
688 None => Err(BrokerV2Error::MissingResult.into()),
689 }
690}
691
692fn map_response_frame_validation(error: FrameValidationError) -> ExplicitHelloError {
693 ExplicitHelloError::UnexpectedResponseFrame(match error {
694 FrameValidationError::EnvelopeVersion { .. } => "envelope_version is not v1",
695 FrameValidationError::Kind { .. } => "kind is not RESPONSE",
696 FrameValidationError::PayloadProtocol { .. } => "payload_protocol is not control-plane",
697 FrameValidationError::PayloadEncoding { .. } => "payload is compressed",
698 })
699}
700
701fn resolve_socket_path(bare_name: &str) -> Result<String, BrokerV2Error> {
720 crate::platform::ipc::broker_endpoint_name(bare_name, false).map_err(BrokerV2Error::Io)
721}
722
723#[cfg(test)]
729fn test_endpoint(socket_path: &str) -> ipc::Endpoint {
730 ipc::Endpoint::new(socket_path.to_owned()).expect("test endpoint")
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use std::sync::mpsc;
737 use std::thread;
738 use std::time::{Duration, Instant};
739
740 struct ScriptedHelloIo {
741 response: std::io::Cursor<Vec<u8>>,
742 request: Vec<u8>,
743 }
744
745 impl Read for ScriptedHelloIo {
746 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
747 self.response.read(buf)
748 }
749 }
750
751 impl Write for ScriptedHelloIo {
752 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
753 self.request.extend_from_slice(buf);
754 Ok(buf.len())
755 }
756
757 fn flush(&mut self) -> std::io::Result<()> {
758 Ok(())
759 }
760 }
761
762 fn scripted_response(body: &[u8]) -> ScriptedHelloIo {
763 let mut response = Vec::new();
764 write_frame(&mut response, body).expect("frame scripted response");
765 ScriptedHelloIo {
766 response: std::io::Cursor::new(response),
767 request: Vec::new(),
768 }
769 }
770
771 fn valid_negotiated_response() -> Frame {
772 let reply = HelloReply {
773 result: Some(hello_reply::Result::Negotiated(Negotiated {
774 backend_pipe: "backend".into(),
775 ..Default::default()
776 })),
777 };
778 Frame {
779 envelope_version: PROTOCOL_VERSION,
780 kind: FrameKind::Response as i32,
781 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
782 payload: reply.encode_to_vec(),
783 request_id: 1,
784 payload_encoding: PayloadEncoding::None as i32,
785 deadline_unix_ms: 0,
786 traceparent: String::new(),
787 tracestate: String::new(),
788 }
789 }
790
791 #[test]
792 fn hello_rejects_invalid_response_envelopes_and_correlation() {
793 let mut invalid = Vec::new();
794 let mut frame = valid_negotiated_response();
795 frame.envelope_version += 1;
796 invalid.push(frame);
797 let mut frame = valid_negotiated_response();
798 frame.kind = FrameKind::Event as i32;
799 invalid.push(frame);
800 let mut frame = valid_negotiated_response();
801 frame.payload_protocol += 1;
802 invalid.push(frame);
803 let mut frame = valid_negotiated_response();
804 frame.payload_encoding = PayloadEncoding::Zstd as i32;
805 invalid.push(frame);
806 let mut frame = valid_negotiated_response();
807 frame.request_id = 0;
808 invalid.push(frame);
809
810 for frame in invalid {
811 let mut io = scripted_response(&frame.encode_to_vec());
812 assert!(matches!(
813 hello_round_trip(&mut io, default_hello("test", "service", "1")),
814 Err(ExplicitHelloError::UnexpectedResponseFrame(_))
815 ));
816 }
817 }
818
819 #[test]
820 fn hello_distinguishes_outer_frame_and_inner_reply_decode_errors() {
821 let mut bad_frame = scripted_response(&[0xff, 0xff, 0xff]);
822 assert!(matches!(
823 hello_round_trip(&mut bad_frame, default_hello("test", "service", "1")),
824 Err(ExplicitHelloError::DecodeFrame(_))
825 ));
826
827 let mut frame = valid_negotiated_response();
828 frame.payload = vec![0xff, 0xff, 0xff];
829 let mut bad_reply = scripted_response(&frame.encode_to_vec());
830 assert!(matches!(
831 hello_round_trip(&mut bad_reply, default_hello("test", "service", "1")),
832 Err(ExplicitHelloError::Broker(BrokerV2Error::Decode(_)))
833 ));
834 }
835
836 fn read_hello_frame(stream: &mut impl Read) -> (Hello, u64) {
844 let bytes = read_frame(stream).expect("read Hello frame");
845 let frame = Frame::decode(bytes.as_slice()).expect("decode Frame");
846 (
847 Hello::decode(frame.payload.as_slice()).expect("decode Hello"),
848 frame.request_id,
849 )
850 }
851
852 fn write_hello_reply_frame(stream: &mut impl Write, request_id: u64, reply: &HelloReply) {
855 let reply_frame = Frame {
856 envelope_version: PROTOCOL_VERSION,
857 kind: FrameKind::Response as i32,
858 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
859 payload: reply.encode_to_vec(),
860 request_id,
861 payload_encoding: PayloadEncoding::None as i32,
862 deadline_unix_ms: 0,
863 traceparent: String::new(),
864 tracestate: String::new(),
865 };
866 write_frame(stream, &reply_frame.encode_to_vec()).expect("write HelloReply frame");
867 }
868
869 struct SocketCleanup(Option<std::path::PathBuf>);
879
880 impl Drop for SocketCleanup {
881 fn drop(&mut self) {
882 if let Some(path) = &self.0 {
883 let _ = std::fs::remove_file(path);
884 }
885 }
886 }
887
888 fn reserve_endpoint_path(socket_path: &str) -> SocketCleanup {
901 if !crate::platform::ipc::endpoint_is_filesystem_backed() {
902 return SocketCleanup(None);
903 }
904 let path = std::path::Path::new(socket_path);
905 if let Some(parent) = path.parent() {
906 let _ = std::fs::create_dir_all(parent);
907 }
908 let _ = std::fs::remove_file(path);
909 SocketCleanup(Some(path.to_path_buf()))
910 }
911
912 fn spawn_stub_broker(socket_path: String) -> mpsc::Receiver<()> {
917 let (tx, rx) = mpsc::channel();
918 thread::spawn(move || {
919 let endpoint = test_endpoint(&socket_path);
920 let _cleanup = reserve_endpoint_path(&socket_path);
921 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
922 tx.send(()).expect("send listener-ready signal");
923 let mut stream = listener.accept().expect("accept");
924 let (hello, request_id) = read_hello_frame(&mut stream);
925 let reply = HelloReply {
926 result: Some(hello_reply::Result::Negotiated(Negotiated {
927 negotiated_protocol: ENVELOPE_VERSION as u32,
928 daemon_version: "stub-1.2.3".to_string(),
929 backend_pipe: String::new(),
930 warnings: Vec::new(),
931 server_capabilities: 0,
932 keepalive_interval_secs: 0,
933 handle_passed_token: Vec::new(),
934 connection_id: 0x00C0_FFEE,
935 })),
936 };
937 write_hello_reply_frame(&mut stream, request_id, &reply);
938 let _ = hello.service_name;
941 });
942 rx
943 }
944
945 #[test]
946 fn connect_completes_hello_round_trip_against_stub_broker() {
947 let program = "client-v2-stub";
949 let sid = user_sid_hash().expect("user_sid_hash");
950 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
951 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
952
953 let ready = spawn_stub_broker(socket_path.clone());
954 ready
955 .recv_timeout(Duration::from_secs(2))
956 .expect("stub broker listening");
957
958 let start = Instant::now();
962 let session = loop {
963 match connect(program, "0.0.0") {
964 Ok(s) => break s,
965 Err(err) if start.elapsed() < Duration::from_secs(2) => {
966 eprintln!("connect retry after error: {err}");
967 std::thread::sleep(Duration::from_millis(50));
968 continue;
969 }
970 Err(err) => panic!("connect failed after retries: {err}"),
971 }
972 };
973
974 let neg = session.negotiated();
975 assert_eq!(neg.negotiated_protocol, ENVELOPE_VERSION as u32);
976 assert_eq!(neg.connection_id, 0x00C0_FFEE);
977 assert_eq!(neg.daemon_version, "stub-1.2.3");
978 }
979
980 #[test]
981 fn connect_service_dials_broker_program_but_routes_named_service() {
982 let program = "client-v2-router";
983 let service_name = "soldr-daemon-root-version-hash";
984 let sid = user_sid_hash().expect("user_sid_hash");
985 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
986 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
987
988 let (ready_tx, ready_rx) = mpsc::channel();
989 let (hello_tx, hello_rx) = mpsc::channel();
990 thread::spawn(move || {
991 let endpoint = test_endpoint(&socket_path);
992 let _cleanup = reserve_endpoint_path(&socket_path);
993 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
994 ready_tx.send(()).expect("ready");
995 let mut stream = listener.accept().expect("accept");
996 let (hello, request_id) = read_hello_frame(&mut stream);
997 hello_tx
998 .send(hello.service_name.clone())
999 .expect("observed service name");
1000 write_hello_reply_frame(
1001 &mut stream,
1002 request_id,
1003 &HelloReply {
1004 result: Some(hello_reply::Result::Negotiated(Negotiated {
1005 backend_pipe: "route-endpoint".into(),
1006 ..Default::default()
1007 })),
1008 },
1009 );
1010 });
1011
1012 ready_rx
1013 .recv_timeout(Duration::from_secs(2))
1014 .expect("stub broker listening");
1015 let session = connect_service(program, service_name, "0.8.0")
1016 .expect("independent service route connects");
1017 assert_eq!(session.negotiated().backend_pipe, "route-endpoint");
1018 assert_eq!(
1019 hello_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1020 service_name
1021 );
1022 }
1023
1024 #[test]
1025 fn connect_with_no_broker_returns_dial_error() {
1026 let err =
1027 connect("client-v2-no-broker-ever", "0.0.0").expect_err("no broker => Dial error");
1028 match err {
1029 BrokerV2Error::Dial { .. } => {}
1030 other => panic!("expected Dial, got: {other:?}"),
1031 }
1032 }
1033
1034 fn spawn_stall_broker(socket_path: String) -> mpsc::Receiver<()> {
1038 let (tx, rx) = mpsc::channel();
1039 thread::spawn(move || {
1040 let endpoint = test_endpoint(&socket_path);
1041 let _cleanup = reserve_endpoint_path(&socket_path);
1042 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
1043 tx.send(()).expect("send listener-ready signal");
1044 let _stream = listener.accept().expect("accept");
1045 thread::sleep(Duration::from_secs(60));
1048 });
1049 rx
1050 }
1051
1052 #[test]
1055 fn connect_with_deadline_fires_on_stalling_broker() {
1056 let program = "client-v2-stall-deadline";
1057 let sid = user_sid_hash().expect("user_sid_hash");
1058 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1059 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1060 let ready = spawn_stall_broker(socket_path);
1061 ready
1062 .recv_timeout(Duration::from_secs(2))
1063 .expect("stall broker listening");
1064 let start = Instant::now();
1065 let err = connect_with_deadline(program, "0.0.0", Duration::from_millis(200))
1066 .expect_err("stall broker => deadline TimedOut");
1067 let elapsed = start.elapsed();
1068 match err {
1069 BrokerV2Error::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::TimedOut),
1070 other => panic!("expected Io(TimedOut), got: {other:?}"),
1071 }
1072 assert!(
1073 elapsed < Duration::from_secs(2),
1074 "deadline should fire within budget; took {elapsed:?}"
1075 );
1076 }
1077
1078 fn spawn_refusing_broker(socket_path: String, retry_after_ms: u64) -> mpsc::Receiver<()> {
1083 let (tx, rx) = mpsc::channel();
1084 thread::spawn(move || {
1085 let endpoint = test_endpoint(&socket_path);
1086 let _cleanup = reserve_endpoint_path(&socket_path);
1087 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
1088 tx.send(()).expect("send listener-ready signal");
1089 let mut stream = listener.accept().expect("accept");
1090 let (_hello, request_id) = read_hello_frame(&mut stream);
1091 let reply = HelloReply {
1092 result: Some(hello_reply::Result::Refused(Refused {
1093 code: 0,
1094 reason: "stub refusal".to_string(),
1095 retry_after_ms,
1096 ..Refused::default()
1097 })),
1098 };
1099 write_hello_reply_frame(&mut stream, request_id, &reply);
1100 });
1101 rx
1102 }
1103
1104 fn spawn_multi_accept_stub_broker(socket_path: String, count: usize) -> mpsc::Receiver<()> {
1109 let (tx, rx) = mpsc::channel();
1110 thread::spawn(move || {
1111 let endpoint = test_endpoint(&socket_path);
1112 let _cleanup = reserve_endpoint_path(&socket_path);
1113 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
1114 tx.send(()).expect("send listener-ready signal");
1115 for _ in 0..count {
1116 let mut stream = match listener.accept() {
1117 Ok(s) => s,
1118 Err(_) => break,
1119 };
1120 let (_hello, request_id) = read_hello_frame(&mut stream);
1121 let reply = HelloReply {
1122 result: Some(hello_reply::Result::Negotiated(Negotiated {
1123 negotiated_protocol: ENVELOPE_VERSION as u32,
1124 daemon_version: "stub-multi-1".to_string(),
1125 backend_pipe: String::new(),
1126 warnings: Vec::new(),
1127 server_capabilities: 0,
1128 keepalive_interval_secs: 0,
1129 handle_passed_token: Vec::new(),
1130 connection_id: 0x0FFF_F1EE,
1131 })),
1132 };
1133 write_hello_reply_frame(&mut stream, request_id, &reply);
1134 }
1135 });
1136 rx
1137 }
1138
1139 #[test]
1145 fn concurrent_connects_against_multi_accept_broker() {
1146 let program = "client-v2-concurrent-multi";
1147 let sid = user_sid_hash().expect("user_sid_hash");
1148 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1149 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1150 const N: usize = 8;
1151 let ready = spawn_multi_accept_stub_broker(socket_path, N);
1152 ready
1153 .recv_timeout(Duration::from_secs(2))
1154 .expect("multi-accept broker listening");
1155
1156 let start = Instant::now();
1157 let handles: Vec<_> = (0..N)
1158 .map(|_| {
1159 let p = program.to_string();
1160 thread::spawn(move || connect_with_deadline(&p, "0.0.0", Duration::from_secs(2)))
1161 })
1162 .collect();
1163 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1164 let elapsed = start.elapsed();
1165
1166 let ok = results.iter().filter(|r| r.is_ok()).count();
1167 assert_eq!(
1168 ok, N,
1169 "all {N} concurrent connects must succeed; got {ok} ok, full results: {results:?}"
1170 );
1171 assert!(
1172 elapsed < Duration::from_secs(5),
1173 "concurrent connect took {elapsed:?}; expected < 5s"
1174 );
1175 for session in results.iter().flatten() {
1176 assert_eq!(session.negotiated().connection_id, 0x0FFF_F1EE);
1177 assert_eq!(session.negotiated().daemon_version, "stub-multi-1");
1178 }
1179 }
1180
1181 fn spawn_missing_result_broker(socket_path: String) -> mpsc::Receiver<()> {
1186 let (tx, rx) = mpsc::channel();
1187 thread::spawn(move || {
1188 let endpoint = test_endpoint(&socket_path);
1189 let _cleanup = reserve_endpoint_path(&socket_path);
1190 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
1191 tx.send(()).expect("send listener-ready signal");
1192 let mut stream = listener.accept().expect("accept");
1193 let (_hello, request_id) = read_hello_frame(&mut stream);
1194 let reply = HelloReply { result: None };
1195 write_hello_reply_frame(&mut stream, request_id, &reply);
1196 });
1197 rx
1198 }
1199
1200 #[test]
1201 fn connect_rejects_hello_reply_with_missing_result_oneof() {
1202 let program = "client-v2-missing-result";
1203 let sid = user_sid_hash().expect("user_sid_hash");
1204 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1205 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1206 let ready = spawn_missing_result_broker(socket_path);
1207 ready
1208 .recv_timeout(Duration::from_secs(2))
1209 .expect("missing-result broker listening");
1210 let start = Instant::now();
1211 let err = loop {
1212 match connect(program, "0.0.0") {
1213 Err(e) => break e,
1214 Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1215 thread::sleep(Duration::from_millis(50));
1216 continue;
1217 }
1218 Ok(_) => panic!("expected MissingResult, got Ok"),
1219 }
1220 };
1221 assert!(
1222 matches!(err, BrokerV2Error::MissingResult),
1223 "expected MissingResult, got: {err:?}"
1224 );
1225 }
1226
1227 fn spawn_drop_on_accept_broker(socket_path: String) -> mpsc::Receiver<()> {
1232 let (tx, rx) = mpsc::channel();
1233 thread::spawn(move || {
1234 let endpoint = test_endpoint(&socket_path);
1235 let _cleanup = reserve_endpoint_path(&socket_path);
1236 let listener = ipc::Listener::bind(&endpoint).expect("bind test listener");
1237 tx.send(()).expect("send listener-ready signal");
1238 let stream = listener.accept().expect("accept");
1239 drop(stream); });
1241 rx
1242 }
1243
1244 #[test]
1245 fn connect_returns_err_on_premature_disconnect() {
1246 let program = "client-v2-prem-disconnect";
1247 let sid = user_sid_hash().expect("user_sid_hash");
1248 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1249 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1250 let ready = spawn_drop_on_accept_broker(socket_path);
1251 ready
1252 .recv_timeout(Duration::from_secs(2))
1253 .expect("drop-on-accept broker listening");
1254 let start = Instant::now();
1255 let err = loop {
1256 match connect_with_deadline(program, "0.0.0", Duration::from_millis(500)) {
1257 Err(e) => break e,
1258 Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1259 thread::sleep(Duration::from_millis(50));
1260 continue;
1261 }
1262 Ok(_) => panic!("expected transport error, got Ok"),
1263 }
1264 };
1265 match err {
1269 BrokerV2Error::Framing(_) | BrokerV2Error::Io(_) | BrokerV2Error::Dial { .. } => {}
1270 other => panic!("expected transport variant, got: {other:?}"),
1271 }
1272 assert!(
1273 start.elapsed() < Duration::from_secs(2),
1274 "must not hang past deadline; took {:?}",
1275 start.elapsed()
1276 );
1277 }
1278
1279 #[test]
1286 fn connect_rejects_invalid_program_names_before_dial() {
1287 let too_long = "a".repeat(65);
1288 for bad in [
1289 "zccache\0evil",
1290 "../etc/passwd",
1291 r"a\b",
1292 "Zccache",
1293 "a b",
1294 too_long.as_str(),
1295 "",
1296 ] {
1297 let err = connect(bad, "0.0.0")
1298 .expect_err(&format!("invalid program name {bad:?} must be rejected"));
1299 assert!(
1300 matches!(err, BrokerV2Error::PipeName(_)),
1301 "expected PipeName for {bad:?}, got: {err:?}"
1302 );
1303 }
1304 }
1305
1306 #[test]
1310 fn refused_with_u64_max_retry_after_ms_round_trips() {
1311 let program = "client-v2-refused-u64-max";
1312 let sid = user_sid_hash().expect("user_sid_hash");
1313 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1314 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1315 let ready = spawn_refusing_broker(socket_path, u64::MAX);
1316 ready
1317 .recv_timeout(Duration::from_secs(2))
1318 .expect("refusing broker listening");
1319 let start = Instant::now();
1320 let err = loop {
1321 match connect(program, "0.0.0") {
1322 Err(e) => break e,
1323 Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1324 thread::sleep(Duration::from_millis(50));
1325 continue;
1326 }
1327 Ok(_) => panic!("expected Refused, got Ok"),
1328 }
1329 };
1330 match err {
1331 BrokerV2Error::Refused {
1332 retry_after_ms,
1333 details,
1334 ..
1335 } => {
1336 assert_eq!(retry_after_ms, u64::MAX);
1337 assert_eq!(details.retry_after_ms, u64::MAX);
1338 let _safe_duration = Duration::from_millis(retry_after_ms);
1340 }
1341 other => panic!("expected Refused, got: {other:?}"),
1342 }
1343 }
1344
1345 #[test]
1346 fn refused_exposes_retry_after_ms_top_level() {
1347 let program = "client-v2-refused-retry";
1348 let sid = user_sid_hash().expect("user_sid_hash");
1349 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1350 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1351 let ready = spawn_refusing_broker(socket_path, 1234);
1352 ready
1353 .recv_timeout(Duration::from_secs(2))
1354 .expect("refusing broker listening");
1355 let start = Instant::now();
1356 let err = loop {
1357 match connect(program, "0.0.0") {
1358 Err(e) => break e,
1359 Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1360 thread::sleep(Duration::from_millis(50));
1361 continue;
1362 }
1363 Ok(_) => panic!("expected Refused"),
1364 }
1365 };
1366 match err {
1367 BrokerV2Error::Refused {
1368 retry_after_ms,
1369 reason,
1370 details,
1371 } => {
1372 assert_eq!(
1373 retry_after_ms, 1234,
1374 "retry hint must surface top-level (was: {retry_after_ms})"
1375 );
1376 assert_eq!(reason, "stub refusal");
1377 assert_eq!(
1378 details.retry_after_ms, 1234,
1379 "details payload still carries the field for full diagnostics"
1380 );
1381 }
1382 other => panic!("expected Refused, got: {other:?}"),
1383 }
1384 }
1385
1386 #[cfg(feature = "client-async")]
1399 #[test]
1400 fn the_hello_does_not_occupy_the_runtime_worker() {
1401 use std::sync::atomic::{AtomicBool, Ordering};
1402 use std::sync::Arc;
1403
1404 let program = "client-v2-async-nonblocking";
1405 let sid = user_sid_hash().expect("user_sid_hash");
1406 let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1407 let socket_path = resolve_socket_path(&pipe_name).expect("resolve endpoint");
1408 let ready = spawn_stall_broker(socket_path);
1409 ready
1410 .recv_timeout(Duration::from_secs(2))
1411 .expect("stall broker listening");
1412
1413 let rt = tokio::runtime::Builder::new_current_thread()
1414 .build()
1415 .expect("current-thread runtime");
1416 rt.block_on(async {
1417 let progressed = Arc::new(AtomicBool::new(false));
1418 let flag = Arc::clone(&progressed);
1419 let other = tokio::spawn(async move {
1420 flag.store(true, Ordering::SeqCst);
1421 });
1422
1423 let _ = AsyncClientSession::connect_with_deadline(
1424 program,
1425 "0.0.0",
1426 Duration::from_millis(200),
1427 )
1428 .await;
1429
1430 assert!(
1431 progressed.load(Ordering::SeqCst),
1432 "the runtime made no progress during the Hello — it ran on the worker"
1433 );
1434 let _ = other.await;
1435 });
1436 }
1437}
1438
1439#[cfg(test)]
1446mod backend_dial_tests {
1447 use super::*;
1448
1449 fn session_with(broker_endpoint: &str, backend_pipe: &str) -> ClientSession {
1455 let endpoint = ipc::Endpoint::new(broker_endpoint.to_owned()).expect("broker endpoint");
1456 let stream = ipc::Stream::connect(&endpoint).expect("dial broker");
1457 ClientSession {
1458 stream,
1459 negotiated: Negotiated {
1460 backend_pipe: backend_pipe.to_string(),
1461 ..Default::default()
1462 },
1463 }
1464 }
1465
1466 fn temp_endpoint(tag: &str) -> (tempfile::TempDir, String) {
1467 let dir = tempfile::tempdir().expect("tempdir");
1468 let path = if cfg!(windows) {
1469 format!(r"\.\pipe\rp-v2-dial-{tag}-{}", std::process::id())
1470 } else {
1471 dir.path().join(format!("{tag}.sock")).display().to_string()
1472 };
1473 (dir, path)
1474 }
1475
1476 #[test]
1484 fn a_negotiated_reply_with_no_backend_pipe_is_its_own_error() {
1485 let (_dir, path) = temp_endpoint("empty");
1486 let listener = ipc::Listener::bind(&test_endpoint(&path)).expect("bind");
1487 let session = session_with(&path, "");
1488 let _accepted = listener.accept().expect("accept");
1489
1490 let err = session
1491 .connect_backend()
1492 .expect_err("an empty backend pipe must not be dialed");
1493 assert!(
1494 matches!(err, BackendDialError::EmptyBackendPipe),
1495 "expected EmptyBackendPipe, got {err:?}"
1496 );
1497 }
1498
1499 #[test]
1506 fn the_dial_connects_to_the_backend_and_carries_traffic() {
1507 let (_bdir, broker_path) = temp_endpoint("broker");
1508 let broker_listener =
1509 ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
1510 let (_kdir, backend_path) = temp_endpoint("backend");
1511 let backend_listener =
1512 ipc::Listener::bind(&test_endpoint(&backend_path)).expect("bind backend");
1513 let session = session_with(&broker_path, &backend_path);
1514 let _broker_accepted = broker_listener.accept().expect("accept broker");
1515
1516 let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
1523 std::thread::spawn(move || {
1524 let _ = accepted_tx.send(backend_listener.accept());
1525 });
1526
1527 let mut data = session
1528 .connect_backend()
1529 .expect("dial the negotiated backend");
1530
1531 let mut served = accepted_rx
1533 .recv_timeout(std::time::Duration::from_secs(10))
1534 .expect("nothing connected to the backend within 10s")
1535 .expect("backend accept");
1536 data.write_all(b"ping").expect("write to backend");
1537 data.flush().expect("flush");
1538 let mut got = [0u8; 4];
1539 served.read_exact(&mut got).expect("backend read");
1540 assert_eq!(&got, b"ping", "bytes did not reach the backend");
1541 }
1542
1543 #[test]
1545 fn a_backend_that_is_not_listening_reports_a_connect_error() {
1546 let (_bdir, broker_path) = temp_endpoint("broker2");
1547 let broker_listener =
1548 ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
1549 let (_kdir, dead_path) = temp_endpoint("nobody-home");
1550 let session = session_with(&broker_path, &dead_path);
1551 let _broker_accepted = broker_listener.accept().expect("accept broker");
1552 let err = session
1553 .connect_backend()
1554 .expect_err("nothing is listening there");
1555 assert!(
1556 matches!(err, BackendDialError::Connect(_)),
1557 "expected Connect, got {err:?}"
1558 );
1559 }
1560
1561 #[cfg(feature = "client-async")]
1565 fn runtime() -> tokio::runtime::Runtime {
1566 tokio::runtime::Builder::new_current_thread()
1567 .build()
1568 .expect("current-thread runtime")
1569 }
1570
1571 #[cfg(feature = "client-async")]
1578 #[test]
1579 fn the_async_dial_reaches_the_backend() {
1580 let (_bdir, broker_path) = temp_endpoint("abroker");
1581 let broker_listener =
1582 ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
1583 let (_kdir, backend_path) = temp_endpoint("abackend");
1584 let backend_listener =
1585 ipc::Listener::bind(&test_endpoint(&backend_path)).expect("bind backend");
1586 let inner = session_with(&broker_path, &backend_path);
1587 let _broker_accepted = broker_listener.accept().expect("accept broker");
1588
1589 let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
1590 std::thread::spawn(move || {
1591 let _ = accepted_tx.send(backend_listener.accept());
1592 });
1593
1594 let session = AsyncClientSession { inner };
1595 let mut data = runtime()
1596 .block_on(session.connect_backend())
1597 .expect("async dial");
1598
1599 let mut served = accepted_rx
1600 .recv_timeout(std::time::Duration::from_secs(10))
1601 .expect("nothing connected to the backend within 10s")
1602 .expect("backend accept");
1603 data.write_all(b"pong").expect("write");
1604 data.flush().expect("flush");
1605 let mut got = [0u8; 4];
1606 served.read_exact(&mut got).expect("read");
1607 assert_eq!(&got, b"pong", "bytes did not reach the backend");
1608 }
1609
1610 #[cfg(feature = "client-async")]
1618 #[test]
1619 fn a_dial_failure_is_not_reported_as_a_runtime_failure() {
1620 let (_bdir, broker_path) = temp_endpoint("abroker2");
1621 let broker_listener =
1622 ipc::Listener::bind(&test_endpoint(&broker_path)).expect("bind broker");
1623 let (_kdir, dead_path) = temp_endpoint("anobody");
1624 let inner = session_with(&broker_path, &dead_path);
1625 let _broker_accepted = broker_listener.accept().expect("accept broker");
1626 let session = AsyncClientSession { inner };
1627 let err = runtime()
1628 .block_on(session.connect_backend())
1629 .expect_err("nothing is listening there");
1630 assert!(
1631 matches!(err, AsyncConnectError::Dial(BackendDialError::Connect(_))),
1632 "expected Dial(Connect), got {err:?}"
1633 );
1634 }
1635
1636 #[cfg(feature = "client-async")]
1638 #[test]
1639 fn an_empty_backend_pipe_survives_the_async_hop() {
1640 let (_dir, path) = temp_endpoint("aempty");
1641 let listener = ipc::Listener::bind(&test_endpoint(&path)).expect("bind");
1642 let inner = session_with(&path, "");
1643 let _accepted = listener.accept().expect("accept");
1644
1645 let session = AsyncClientSession { inner };
1646 let err = runtime()
1647 .block_on(session.connect_backend())
1648 .expect_err("an empty pipe must not be dialed");
1649 assert!(
1650 matches!(
1651 err,
1652 AsyncConnectError::Dial(BackendDialError::EmptyBackendPipe)
1653 ),
1654 "expected Dial(EmptyBackendPipe), got {err:?}"
1655 );
1656 }
1657}