1use std::io;
4use std::sync::mpsc;
5use std::thread;
6use std::time::Duration;
7
8use interprocess::local_socket::prelude::*;
9use prost::Message;
10
11use crate::broker::capabilities::{handoff_transport_available, CAP_HANDLE_PASSING};
12use crate::broker::protocol::{
13 hello_reply::Result as HelloReplyResult, read_frame, validate_frame_envelope, write_frame,
14 AdminReply, AdminRequest, ErrorCode, Frame, FrameKind, FrameValidationError, FramingError,
15 HandoffAck, Hello, HelloReply, Negotiated, PayloadEncoding, ADMIN_PAYLOAD_PROTOCOL,
16 CONTROL_PAYLOAD_PROTOCOL, PROTOCOL_VERSION,
17};
18use crate::broker::server::handoff::validate_handoff_frame;
19use crate::broker::server::local_socket_name;
20
21pub const DEFAULT_HANDOFF_READY_TIMEOUT: Duration = Duration::from_secs(2);
24
25pub const RUNNING_PROCESS_DISABLE_ENV: &str = "RUNNING_PROCESS_DISABLE";
27pub const RUNNING_PROCESS_DISABLE_VALUE: &str = "1";
29pub const RUNNING_PROCESS_FAKE_BACKEND_ENV: &str = "RUNNING_PROCESS_FAKE_BACKEND";
42
43pub fn broker_disabled_by_env() -> Result<bool, BrokerDisableEnvError> {
48 let Some(value) = std::env::var_os(RUNNING_PROCESS_DISABLE_ENV) else {
49 return Ok(false);
50 };
51 let value = value.to_string_lossy();
52 if value == RUNNING_PROCESS_DISABLE_VALUE {
53 Ok(true)
54 } else {
55 Err(BrokerDisableEnvError {
56 value: value.into_owned(),
57 })
58 }
59}
60
61#[derive(Clone, Debug)]
63pub struct ConnectBackendRequest<'a> {
64 pub broker_endpoint: &'a str,
66 pub service_name: &'a str,
68 pub wanted_version: &'a str,
70 pub self_version: &'a str,
72 pub cached_backend_endpoint: Option<&'a str>,
74 pub client_version: &'a str,
76 pub client_lib_name: &'a str,
78 pub client_lib_version: &'a str,
80 pub client_keepalive_secs: u64,
82 pub adopt_handed_off_connection: bool,
98 pub handoff_ready_timeout: Duration,
101}
102
103impl<'a> ConnectBackendRequest<'a> {
104 pub fn new(
106 broker_endpoint: &'a str,
107 service_name: &'a str,
108 wanted_version: &'a str,
109 self_version: &'a str,
110 ) -> Self {
111 Self {
112 broker_endpoint,
113 service_name,
114 wanted_version,
115 self_version,
116 cached_backend_endpoint: None,
117 client_version: "",
118 client_lib_name: "running-process",
119 client_lib_version: env!("CARGO_PKG_VERSION"),
120 client_keepalive_secs: 0,
121 adopt_handed_off_connection: false,
122 handoff_ready_timeout: DEFAULT_HANDOFF_READY_TIMEOUT,
123 }
124 }
125
126 fn can_hello_skip(&self) -> bool {
127 self.cached_backend_endpoint.is_some() && self.wanted_version == self.self_version
128 }
129
130 fn hello(&self) -> Hello {
131 Hello {
132 client_min_protocol: PROTOCOL_VERSION,
133 client_max_protocol: PROTOCOL_VERSION,
134 service_name: self.service_name.into(),
135 wanted_version: self.wanted_version.into(),
136 client_version: self.client_version.into(),
137 client_capabilities: client_capabilities(),
138 auth_token: Vec::new(),
139 request_id: "hello".into(),
140 connection_id: 0,
141 peer_pid: std::process::id(),
142 client_lib_name: self.client_lib_name.into(),
143 client_lib_version: self.client_lib_version.into(),
144 peer_attestation_nonce: Vec::new(),
145 capability_token: Vec::new(),
146 client_keepalive_secs: self.client_keepalive_secs,
147 }
148 }
149}
150
151fn client_capabilities() -> u64 {
158 if handoff_transport_available() {
159 CAP_HANDLE_PASSING
160 } else {
161 0
162 }
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum BackendConnectionRoute {
168 HelloSkip,
176 BrokerNegotiated,
178 HandlePassed,
187}
188
189#[derive(Debug)]
191pub struct BackendConnection {
192 pub stream: interprocess::local_socket::Stream,
194 pub endpoint: String,
201 pub route: BackendConnectionRoute,
203 pub negotiated: Option<Negotiated>,
205}
206
207impl BackendConnection {
208 pub fn handoff_token(&self) -> Option<&[u8]> {
219 self.negotiated
220 .as_ref()
221 .map(|negotiated| negotiated.handle_passed_token.as_slice())
222 .filter(|token| !token.is_empty())
223 }
224}
225
226pub fn connect_to_backend(
249 request: ConnectBackendRequest<'_>,
250) -> Result<BackendConnection, BrokerClientError> {
251 #[cfg(feature = "test-seams")]
252 if let Some(endpoint) = fake_backend_endpoint_from_env() {
253 let stream = connect_local_socket(&endpoint).map_err(BrokerClientError::BackendConnect)?;
254 return Ok(BackendConnection {
255 stream,
256 endpoint,
257 route: BackendConnectionRoute::HelloSkip,
258 negotiated: None,
259 });
260 }
261
262 if request.can_hello_skip() {
263 if let Some(endpoint) = request.cached_backend_endpoint {
264 if let Ok(stream) = connect_local_socket(endpoint) {
265 return Ok(BackendConnection {
266 stream,
267 endpoint: endpoint.into(),
268 route: BackendConnectionRoute::HelloSkip,
269 negotiated: None,
270 });
271 }
272 }
273 }
274
275 let (broker_stream, negotiated) = broker_hello(&request)?;
276 if request.adopt_handed_off_connection && handoff_negotiated(&negotiated) {
277 if let Some(adopted) = await_handoff_ready(
278 broker_stream,
279 negotiated.handle_passed_token.clone(),
280 request.handoff_ready_timeout,
281 ) {
282 return Ok(BackendConnection {
283 endpoint: negotiated.backend_pipe.clone(),
284 stream: adopted,
285 route: BackendConnectionRoute::HandlePassed,
286 negotiated: Some(negotiated),
287 });
288 }
289 }
290
291 if negotiated.backend_pipe.is_empty() {
292 return Err(BrokerClientError::EmptyBackendPipe);
293 }
294 let stream = connect_local_socket(&negotiated.backend_pipe)
295 .map_err(BrokerClientError::BackendConnect)?;
296 Ok(BackendConnection {
297 endpoint: negotiated.backend_pipe.clone(),
298 stream,
299 route: BackendConnectionRoute::BrokerNegotiated,
300 negotiated: Some(negotiated),
301 })
302}
303
304#[cfg(feature = "test-seams")]
319fn fake_backend_endpoint_from_env() -> Option<String> {
320 let value = std::env::var_os(RUNNING_PROCESS_FAKE_BACKEND_ENV)?;
321 let value = value.to_string_lossy();
322 if value.is_empty() {
323 return None;
324 }
325 if matches!(broker_disabled_by_env(), Ok(true)) {
326 return None;
327 }
328 Some(value.into_owned())
329}
330
331fn handoff_negotiated(negotiated: &Negotiated) -> bool {
334 negotiated.server_capabilities & CAP_HANDLE_PASSING == CAP_HANDLE_PASSING
335 && !negotiated.handle_passed_token.is_empty()
336}
337
338fn await_handoff_ready(
351 stream: interprocess::local_socket::Stream,
352 expected_token: Vec<u8>,
353 timeout: Duration,
354) -> Option<interprocess::local_socket::Stream> {
355 let (result_tx, result_rx) = mpsc::channel();
356 thread::spawn(move || {
357 let mut stream = stream;
358 let outcome = read_handoff_ready(&mut stream, &expected_token).map(|()| stream);
359 let _ = result_tx.send(outcome);
360 });
361 match result_rx.recv_timeout(timeout) {
362 Ok(Ok(stream)) => Some(stream),
363 Ok(Err(_)) | Err(_) => None,
364 }
365}
366
367fn read_handoff_ready(
372 stream: &mut interprocess::local_socket::Stream,
373 expected_token: &[u8],
374) -> Result<(), &'static str> {
375 let bytes = read_frame(stream).map_err(|_| "failed to read handoff-ready frame")?;
376 let frame =
377 Frame::decode(bytes.as_slice()).map_err(|_| "failed to decode handoff-ready Frame")?;
378 validate_handoff_frame(&frame, FrameKind::Event)?;
379 let ack = HandoffAck::decode(frame.payload.as_slice())
380 .map_err(|_| "failed to decode handoff-ready HandoffAck payload")?;
381 if ack.token != expected_token {
382 return Err("handoff-ready token echo does not match the negotiated token");
383 }
384 if !ack.accepted {
385 return Err("broker relayed a refused handoff");
386 }
387 Ok(())
388}
389
390const DEFAULT_BROKER_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
396const BROKER_CLIENT_TIMEOUT_ENV: &str = "RUNNING_PROCESS_BROKER_CLIENT_TIMEOUT_MS";
397
398fn parse_broker_client_timeout(raw: Option<&str>) -> Duration {
399 raw.and_then(|raw| raw.trim().parse::<u64>().ok())
400 .filter(|&ms| ms > 0)
401 .map(Duration::from_millis)
402 .unwrap_or(DEFAULT_BROKER_CLIENT_TIMEOUT)
403}
404
405fn broker_client_deadline() -> Duration {
406 parse_broker_client_timeout(std::env::var(BROKER_CLIENT_TIMEOUT_ENV).ok().as_deref())
407}
408
409fn broker_client_timeout_err() -> BrokerClientError {
410 BrokerClientError::BrokerConnect(io::Error::new(
411 io::ErrorKind::TimedOut,
412 "broker client round-trip did not complete within the deadline",
413 ))
414}
415
416pub fn send_admin_request(
423 broker_endpoint: &str,
424 request: AdminRequest,
425) -> Result<AdminReply, BrokerClientError> {
426 let endpoint = broker_endpoint.to_string();
427 let (tx, rx) = mpsc::channel();
428 thread::spawn(move || {
431 let _ = tx.send(send_admin_request_unbounded(&endpoint, request));
432 });
433 match rx.recv_timeout(broker_client_deadline()) {
434 Ok(result) => result,
435 Err(_) => Err(broker_client_timeout_err()),
436 }
437}
438
439fn send_admin_request_unbounded(
440 broker_endpoint: &str,
441 request: AdminRequest,
442) -> Result<AdminReply, BrokerClientError> {
443 let mut stream =
444 connect_local_socket(broker_endpoint).map_err(BrokerClientError::BrokerConnect)?;
445 let request_frame = Frame {
446 envelope_version: PROTOCOL_VERSION,
447 kind: FrameKind::Request as i32,
448 payload_protocol: ADMIN_PAYLOAD_PROTOCOL,
449 payload: request.encode_to_vec(),
450 request_id: 1,
451 payload_encoding: PayloadEncoding::None as i32,
452 deadline_unix_ms: 0,
453 traceparent: String::new(),
454 tracestate: String::new(),
455 };
456 write_frame(&mut stream, &request_frame.encode_to_vec())?;
457
458 let response_bytes = read_frame(&mut stream)?;
459 let response_frame =
460 Frame::decode(response_bytes.as_slice()).map_err(BrokerClientError::DecodeFrame)?;
461 validate_response_frame(
462 &response_frame,
463 ADMIN_PAYLOAD_PROTOCOL,
464 "payload_protocol is not admin",
465 )?;
466 AdminReply::decode(response_frame.payload.as_slice())
467 .map_err(BrokerClientError::DecodeAdminReply)
468}
469
470pub fn connect_local_socket(endpoint: &str) -> io::Result<interprocess::local_socket::Stream> {
472 let name = local_socket_name(endpoint)?;
473 LocalSocketStream::connect(name)
474}
475
476fn broker_hello(
477 request: &ConnectBackendRequest<'_>,
478) -> Result<(interprocess::local_socket::Stream, Negotiated), BrokerClientError> {
479 let endpoint = request.broker_endpoint.to_string();
484 let hello_bytes = request.hello().encode_to_vec();
485 let (tx, rx) = mpsc::channel();
486 thread::spawn(move || {
487 let _ = tx.send(broker_hello_unbounded(&endpoint, hello_bytes));
488 });
489 match rx.recv_timeout(broker_client_deadline()) {
490 Ok(result) => result,
491 Err(_) => Err(broker_client_timeout_err()),
492 }
493}
494
495fn broker_hello_unbounded(
496 broker_endpoint: &str,
497 hello_bytes: Vec<u8>,
498) -> Result<(interprocess::local_socket::Stream, Negotiated), BrokerClientError> {
499 let mut stream =
500 connect_local_socket(broker_endpoint).map_err(BrokerClientError::BrokerConnect)?;
501 let request_frame = Frame {
502 envelope_version: PROTOCOL_VERSION,
503 kind: FrameKind::Request as i32,
504 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
505 payload: hello_bytes,
506 request_id: 1,
507 payload_encoding: PayloadEncoding::None as i32,
508 deadline_unix_ms: 0,
509 traceparent: String::new(),
510 tracestate: String::new(),
511 };
512 write_frame(&mut stream, &request_frame.encode_to_vec())?;
513
514 let response_bytes = read_frame(&mut stream)?;
515 let response_frame =
516 Frame::decode(response_bytes.as_slice()).map_err(BrokerClientError::DecodeFrame)?;
517 validate_response_frame(
518 &response_frame,
519 CONTROL_PAYLOAD_PROTOCOL,
520 "payload_protocol is not control-plane",
521 )?;
522 let reply = HelloReply::decode(response_frame.payload.as_slice())
523 .map_err(BrokerClientError::DecodeHelloReply)?;
524 match reply
525 .result
526 .ok_or(BrokerClientError::MissingHelloReplyResult)?
527 {
528 HelloReplyResult::Negotiated(negotiated) => Ok((stream, negotiated)),
529 HelloReplyResult::Refused(refused) => Err(BrokerClientError::Refused {
530 code: ErrorCode::try_from(refused.code).unwrap_or(ErrorCode::Unspecified),
531 reason: refused.reason,
532 retry_after_ms: refused.retry_after_ms,
533 }),
534 }
535}
536
537fn validate_response_frame(
538 frame: &Frame,
539 expected_payload_protocol: u32,
540 payload_protocol_error: &'static str,
541) -> Result<(), BrokerClientError> {
542 validate_frame_envelope(frame, FrameKind::Response, expected_payload_protocol).map_err(
543 |error| {
544 BrokerClientError::UnexpectedResponseFrame(match error {
545 FrameValidationError::EnvelopeVersion { .. } => "envelope_version is not v1",
546 FrameValidationError::Kind { .. } => "kind is not RESPONSE",
547 FrameValidationError::PayloadProtocol { .. } => payload_protocol_error,
548 FrameValidationError::PayloadEncoding { .. } => "payload is compressed",
549 })
550 },
551 )
552}
553
554#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
556#[error("RUNNING_PROCESS_DISABLE must be unset or 1, got {value:?}")]
557pub struct BrokerDisableEnvError {
558 pub value: String,
560}
561
562#[derive(Debug, thiserror::Error)]
564pub enum BrokerClientError {
565 #[error("failed to connect to broker: {0}")]
567 BrokerConnect(io::Error),
568 #[error("failed to connect to negotiated backend: {0}")]
570 BackendConnect(io::Error),
571 #[error(transparent)]
573 Framing(#[from] FramingError),
574 #[error("failed to decode broker response Frame: {0}")]
576 DecodeFrame(prost::DecodeError),
577 #[error("failed to decode broker HelloReply: {0}")]
579 DecodeHelloReply(prost::DecodeError),
580 #[error("failed to decode broker AdminReply: {0}")]
582 DecodeAdminReply(prost::DecodeError),
583 #[error("unexpected broker response frame: {0}")]
585 UnexpectedResponseFrame(&'static str),
586 #[error("broker HelloReply did not contain a result")]
588 MissingHelloReplyResult,
589 #[error("broker refused Hello: {reason} ({code:?}, retry_after_ms={retry_after_ms})")]
591 Refused {
592 code: ErrorCode,
594 reason: String,
596 retry_after_ms: u64,
598 },
599 #[error("broker negotiated an empty backend endpoint")]
601 EmptyBackendPipe,
602}
603
604impl BrokerClientError {
605 pub fn refusal_kind(&self) -> Option<RefusalKind> {
613 match self {
614 BrokerClientError::Refused { code, .. } => Some(RefusalKind::from_code(*code)),
615 _ => None,
616 }
617 }
618}
619
620#[derive(Clone, Copy, Debug, PartialEq, Eq)]
628pub enum RefusalKind {
629 VersionUnsupported,
632 VersionBlocked,
635 ServiceUnknown,
638 RateLimited,
640 ShuttingDown,
642 Other(ErrorCode),
645}
646
647impl RefusalKind {
648 pub fn from_code(code: ErrorCode) -> Self {
650 match code {
651 ErrorCode::ErrorVersionUnsupported => RefusalKind::VersionUnsupported,
652 ErrorCode::ErrorVersionBlocked => RefusalKind::VersionBlocked,
653 ErrorCode::ErrorServiceUnknown => RefusalKind::ServiceUnknown,
654 ErrorCode::ErrorRateLimited => RefusalKind::RateLimited,
655 ErrorCode::ErrorShuttingDown => RefusalKind::ShuttingDown,
656 other => RefusalKind::Other(other),
657 }
658 }
659}
660
661#[cfg(test)]
662mod cluster_h_tests {
663 use super::*;
664 use std::time::Instant;
665
666 #[test]
667 fn broker_client_timeout_defaults_when_unset_or_invalid() {
668 assert_eq!(
669 parse_broker_client_timeout(None),
670 DEFAULT_BROKER_CLIENT_TIMEOUT
671 );
672 assert_eq!(
673 parse_broker_client_timeout(Some("nope")),
674 DEFAULT_BROKER_CLIENT_TIMEOUT
675 );
676 assert_eq!(
677 parse_broker_client_timeout(Some("0")),
678 DEFAULT_BROKER_CLIENT_TIMEOUT
679 );
680 }
681
682 #[test]
683 fn broker_client_timeout_honors_valid_override() {
684 assert_eq!(
685 parse_broker_client_timeout(Some("750")),
686 Duration::from_millis(750)
687 );
688 }
689
690 #[test]
691 fn send_admin_request_to_missing_broker_errors_promptly() {
692 let bogus = if cfg!(windows) {
696 r"\.\pipe\running-process-broker-nonexistent-cluster-h-test"
697 } else {
698 "/tmp/running-process-broker-nonexistent-cluster-h-test.sock"
699 };
700 let start = Instant::now();
701 let result = send_admin_request(bogus, AdminRequest::default());
702 assert!(result.is_err());
703 assert!(
704 start.elapsed() < Duration::from_secs(5),
705 "send_admin_request to a missing broker took {:?}; should fail fast",
706 start.elapsed()
707 );
708 }
709}