1use std::io;
4use std::sync::mpsc;
5use std::thread;
6use std::time::Duration;
7
8use prost::Message;
9use running_process_platform_internal::platform::ipc;
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;
19
20pub const DEFAULT_HANDOFF_READY_TIMEOUT: Duration = Duration::from_secs(2);
23
24pub const RUNNING_PROCESS_DISABLE_ENV: &str = "RUNNING_PROCESS_DISABLE";
26pub const RUNNING_PROCESS_DISABLE_VALUE: &str = "1";
28pub const RUNNING_PROCESS_FAKE_BACKEND_ENV: &str = "RUNNING_PROCESS_FAKE_BACKEND";
41
42pub fn broker_disabled_by_env() -> Result<bool, BrokerDisableEnvError> {
47 let Some(value) = std::env::var_os(RUNNING_PROCESS_DISABLE_ENV) else {
48 return Ok(false);
49 };
50 let value = value.to_string_lossy();
51 if value == RUNNING_PROCESS_DISABLE_VALUE {
52 Ok(true)
53 } else {
54 Err(BrokerDisableEnvError {
55 value: value.into_owned(),
56 })
57 }
58}
59
60#[derive(Clone, Debug)]
62pub struct ConnectBackendRequest<'a> {
63 pub broker_endpoint: &'a str,
65 pub service_name: &'a str,
67 pub wanted_version: &'a str,
69 pub self_version: &'a str,
71 pub cached_backend_endpoint: Option<&'a str>,
73 pub client_version: &'a str,
75 pub client_lib_name: &'a str,
77 pub client_lib_version: &'a str,
79 pub client_keepalive_secs: u64,
81 pub adopt_handed_off_connection: bool,
97 pub handoff_ready_timeout: Duration,
100}
101
102impl<'a> ConnectBackendRequest<'a> {
103 pub fn new(
105 broker_endpoint: &'a str,
106 service_name: &'a str,
107 wanted_version: &'a str,
108 self_version: &'a str,
109 ) -> Self {
110 Self {
111 broker_endpoint,
112 service_name,
113 wanted_version,
114 self_version,
115 cached_backend_endpoint: None,
116 client_version: "",
117 client_lib_name: "running-process",
118 client_lib_version: env!("CARGO_PKG_VERSION"),
119 client_keepalive_secs: 0,
120 adopt_handed_off_connection: false,
121 handoff_ready_timeout: DEFAULT_HANDOFF_READY_TIMEOUT,
122 }
123 }
124
125 fn can_hello_skip(&self) -> bool {
126 self.cached_backend_endpoint.is_some() && self.wanted_version == self.self_version
127 }
128
129 pub(crate) fn hello(&self) -> Hello {
130 Hello {
131 client_min_protocol: PROTOCOL_VERSION,
132 client_max_protocol: PROTOCOL_VERSION,
133 service_name: self.service_name.into(),
134 wanted_version: self.wanted_version.into(),
135 client_version: self.client_version.into(),
136 client_capabilities: client_capabilities(),
137 auth_token: Vec::new(),
138 request_id: "hello".into(),
139 connection_id: 0,
140 peer_pid: std::process::id(),
141 client_lib_name: self.client_lib_name.into(),
142 client_lib_version: self.client_lib_version.into(),
143 peer_attestation_nonce: Vec::new(),
144 capability_token: Vec::new(),
145 client_keepalive_secs: self.client_keepalive_secs,
146 }
147 }
148}
149
150fn client_capabilities() -> u64 {
157 if handoff_transport_available() {
158 CAP_HANDLE_PASSING
159 } else {
160 0
161 }
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum BackendConnectionRoute {
167 HelloSkip,
175 BrokerNegotiated,
177 HandlePassed,
186}
187
188#[derive(Debug)]
190pub struct BackendConnection {
191 pub stream: ipc::Stream,
193 pub endpoint: String,
200 pub route: BackendConnectionRoute,
202 pub negotiated: Option<Negotiated>,
204}
205
206impl BackendConnection {
207 pub fn handoff_token(&self) -> Option<&[u8]> {
218 self.negotiated
219 .as_ref()
220 .map(|negotiated| negotiated.handle_passed_token.as_slice())
221 .filter(|token| !token.is_empty())
222 }
223}
224
225pub fn connect_to_backend(
248 request: ConnectBackendRequest<'_>,
249) -> Result<BackendConnection, BrokerClientError> {
250 #[cfg(feature = "test-seams")]
251 if let Some(endpoint) = fake_backend_endpoint_from_env() {
252 let stream = connect_ipc_stream(&endpoint).map_err(BrokerClientError::BackendConnect)?;
253 return Ok(BackendConnection {
254 stream,
255 endpoint,
256 route: BackendConnectionRoute::HelloSkip,
257 negotiated: None,
258 });
259 }
260
261 if request.can_hello_skip() {
262 if let Some(endpoint) = request.cached_backend_endpoint {
263 if let Ok(stream) = connect_ipc_stream(endpoint) {
264 return Ok(BackendConnection {
265 stream,
266 endpoint: endpoint.into(),
267 route: BackendConnectionRoute::HelloSkip,
268 negotiated: None,
269 });
270 }
271 }
272 }
273
274 let (broker_stream, negotiated) = broker_hello(&request)?;
275 if request.adopt_handed_off_connection && handoff_negotiated(&negotiated) {
276 if let Some(adopted) = await_handoff_ready(
277 broker_stream,
278 negotiated.handle_passed_token.clone(),
279 request.handoff_ready_timeout,
280 ) {
281 return Ok(BackendConnection {
282 endpoint: negotiated.backend_pipe.clone(),
283 stream: adopted,
284 route: BackendConnectionRoute::HandlePassed,
285 negotiated: Some(negotiated),
286 });
287 }
288 }
289
290 if negotiated.backend_pipe.is_empty() {
291 return Err(BrokerClientError::EmptyBackendPipe);
292 }
293 let stream =
294 connect_ipc_stream(&negotiated.backend_pipe).map_err(BrokerClientError::BackendConnect)?;
295 Ok(BackendConnection {
296 endpoint: negotiated.backend_pipe.clone(),
297 stream,
298 route: BackendConnectionRoute::BrokerNegotiated,
299 negotiated: Some(negotiated),
300 })
301}
302
303#[cfg(feature = "test-seams")]
318fn fake_backend_endpoint_from_env() -> Option<String> {
319 let value = std::env::var_os(RUNNING_PROCESS_FAKE_BACKEND_ENV)?;
320 let value = value.to_string_lossy();
321 if value.is_empty() {
322 return None;
323 }
324 if matches!(broker_disabled_by_env(), Ok(true)) {
325 return None;
326 }
327 Some(value.into_owned())
328}
329
330fn handoff_negotiated(negotiated: &Negotiated) -> bool {
333 negotiated.server_capabilities & CAP_HANDLE_PASSING == CAP_HANDLE_PASSING
334 && !negotiated.handle_passed_token.is_empty()
335}
336
337fn await_handoff_ready(
350 stream: ipc::Stream,
351 expected_token: Vec<u8>,
352 timeout: Duration,
353) -> Option<ipc::Stream> {
354 let (result_tx, result_rx) = mpsc::channel();
355 thread::spawn(move || {
356 let mut stream = stream;
357 let outcome = read_handoff_ready(&mut stream, &expected_token).map(|()| stream);
358 let _ = result_tx.send(outcome);
359 });
360 match result_rx.recv_timeout(timeout) {
361 Ok(Ok(stream)) => Some(stream),
362 Ok(Err(_)) | Err(_) => None,
363 }
364}
365
366fn read_handoff_ready(stream: &mut ipc::Stream, expected_token: &[u8]) -> Result<(), &'static str> {
371 let bytes = read_frame(stream).map_err(|_| "failed to read handoff-ready frame")?;
372 let frame =
373 Frame::decode(bytes.as_slice()).map_err(|_| "failed to decode handoff-ready Frame")?;
374 validate_handoff_frame(&frame, FrameKind::Event)?;
375 let ack = HandoffAck::decode(frame.payload.as_slice())
376 .map_err(|_| "failed to decode handoff-ready HandoffAck payload")?;
377 if ack.token != expected_token {
378 return Err("handoff-ready token echo does not match the negotiated token");
379 }
380 if !ack.accepted {
381 return Err("broker relayed a refused handoff");
382 }
383 Ok(())
384}
385
386const DEFAULT_BROKER_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
392pub(crate) fn broker_client_deadline() -> Duration {
393 crate::env_vars::BROKER_CLIENT_TIMEOUT_MS.millis_or(DEFAULT_BROKER_CLIENT_TIMEOUT)
394}
395
396fn broker_client_timeout_err() -> BrokerClientError {
397 BrokerClientError::BrokerConnect(io::Error::new(
398 io::ErrorKind::TimedOut,
399 "broker client round-trip did not complete within the deadline",
400 ))
401}
402
403pub fn send_admin_request(
410 broker_endpoint: &str,
411 request: AdminRequest,
412) -> Result<AdminReply, BrokerClientError> {
413 let endpoint = broker_endpoint.to_string();
414 let (tx, rx) = mpsc::channel();
415 thread::spawn(move || {
418 let _ = tx.send(send_admin_request_unbounded(&endpoint, request));
419 });
420 match rx.recv_timeout(broker_client_deadline()) {
421 Ok(result) => result,
422 Err(_) => Err(broker_client_timeout_err()),
423 }
424}
425
426fn send_admin_request_unbounded(
427 broker_endpoint: &str,
428 request: AdminRequest,
429) -> Result<AdminReply, BrokerClientError> {
430 let mut stream =
431 connect_ipc_stream(broker_endpoint).map_err(BrokerClientError::BrokerConnect)?;
432 let request_frame = Frame {
433 envelope_version: PROTOCOL_VERSION,
434 kind: FrameKind::Request as i32,
435 payload_protocol: ADMIN_PAYLOAD_PROTOCOL,
436 payload: request.encode_to_vec(),
437 request_id: 1,
438 payload_encoding: PayloadEncoding::None as i32,
439 deadline_unix_ms: 0,
440 traceparent: String::new(),
441 tracestate: String::new(),
442 };
443 write_frame(&mut stream, &request_frame.encode_to_vec())?;
444
445 let response_bytes = read_frame(&mut stream)?;
446 let response_frame =
447 Frame::decode(response_bytes.as_slice()).map_err(BrokerClientError::DecodeFrame)?;
448 validate_response_frame(
449 &response_frame,
450 ADMIN_PAYLOAD_PROTOCOL,
451 "payload_protocol is not admin",
452 )?;
453 AdminReply::decode(response_frame.payload.as_slice())
454 .map_err(BrokerClientError::DecodeAdminReply)
455}
456
457pub fn connect_local_socket(endpoint: &str) -> io::Result<ipc::Stream> {
459 connect_ipc_stream(endpoint)
460}
461
462pub(crate) fn connect_ipc_stream(endpoint: &str) -> io::Result<ipc::Stream> {
463 let endpoint = ipc::Endpoint::new(endpoint.to_owned())?;
464 ipc::Stream::connect(&endpoint)
465}
466
467fn broker_hello(
468 request: &ConnectBackendRequest<'_>,
469) -> Result<(ipc::Stream, Negotiated), BrokerClientError> {
470 let endpoint = request.broker_endpoint.to_string();
475 let hello_bytes = request.hello().encode_to_vec();
476 let (tx, rx) = mpsc::channel();
477 thread::spawn(move || {
478 let _ = tx.send(broker_hello_unbounded(&endpoint, hello_bytes));
479 });
480 match rx.recv_timeout(broker_client_deadline()) {
481 Ok(result) => result,
482 Err(_) => Err(broker_client_timeout_err()),
483 }
484}
485
486fn broker_hello_unbounded(
487 broker_endpoint: &str,
488 hello_bytes: Vec<u8>,
489) -> Result<(ipc::Stream, Negotiated), BrokerClientError> {
490 let mut stream =
491 connect_ipc_stream(broker_endpoint).map_err(BrokerClientError::BrokerConnect)?;
492 let request_frame = Frame {
493 envelope_version: PROTOCOL_VERSION,
494 kind: FrameKind::Request as i32,
495 payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
496 payload: hello_bytes,
497 request_id: 1,
498 payload_encoding: PayloadEncoding::None as i32,
499 deadline_unix_ms: 0,
500 traceparent: String::new(),
501 tracestate: String::new(),
502 };
503 write_frame(&mut stream, &request_frame.encode_to_vec())?;
504
505 let response_bytes = read_frame(&mut stream)?;
506 let response_frame =
507 Frame::decode(response_bytes.as_slice()).map_err(BrokerClientError::DecodeFrame)?;
508 validate_response_frame(
509 &response_frame,
510 CONTROL_PAYLOAD_PROTOCOL,
511 "payload_protocol is not control-plane",
512 )?;
513 let reply = HelloReply::decode(response_frame.payload.as_slice())
514 .map_err(BrokerClientError::DecodeHelloReply)?;
515 match reply
516 .result
517 .ok_or(BrokerClientError::MissingHelloReplyResult)?
518 {
519 HelloReplyResult::Negotiated(negotiated) => Ok((stream, negotiated)),
520 HelloReplyResult::Refused(refused) => Err(BrokerClientError::Refused {
521 code: ErrorCode::try_from(refused.code).unwrap_or(ErrorCode::Unspecified),
522 reason: refused.reason,
523 retry_after_ms: refused.retry_after_ms,
524 }),
525 }
526}
527
528fn validate_response_frame(
529 frame: &Frame,
530 expected_payload_protocol: u32,
531 payload_protocol_error: &'static str,
532) -> Result<(), BrokerClientError> {
533 validate_frame_envelope(frame, FrameKind::Response, expected_payload_protocol).map_err(
534 |error| {
535 BrokerClientError::UnexpectedResponseFrame(match error {
536 FrameValidationError::EnvelopeVersion { .. } => "envelope_version is not v1",
537 FrameValidationError::Kind { .. } => "kind is not RESPONSE",
538 FrameValidationError::PayloadProtocol { .. } => payload_protocol_error,
539 FrameValidationError::PayloadEncoding { .. } => "payload is compressed",
540 })
541 },
542 )
543}
544
545#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
547#[error("RUNNING_PROCESS_DISABLE must be unset or 1, got {value:?}")]
548pub struct BrokerDisableEnvError {
549 pub value: String,
551}
552
553#[derive(Debug, thiserror::Error)]
555pub enum BrokerClientError {
556 #[error("failed to connect to broker: {0}")]
558 BrokerConnect(io::Error),
559 #[error("failed to connect to negotiated backend: {0}")]
561 BackendConnect(io::Error),
562 #[error(transparent)]
564 Framing(#[from] FramingError),
565 #[error("failed to decode broker response Frame: {0}")]
567 DecodeFrame(prost::DecodeError),
568 #[error("failed to decode broker HelloReply: {0}")]
570 DecodeHelloReply(prost::DecodeError),
571 #[error("failed to decode broker AdminReply: {0}")]
573 DecodeAdminReply(prost::DecodeError),
574 #[error("unexpected broker response frame: {0}")]
576 UnexpectedResponseFrame(&'static str),
577 #[error("broker HelloReply did not contain a result")]
579 MissingHelloReplyResult,
580 #[error("broker refused Hello: {reason} ({code:?}, retry_after_ms={retry_after_ms})")]
582 Refused {
583 code: ErrorCode,
585 reason: String,
587 retry_after_ms: u64,
589 },
590 #[error("broker negotiated an empty backend endpoint")]
592 EmptyBackendPipe,
593}
594
595impl BrokerClientError {
596 pub fn refusal_kind(&self) -> Option<RefusalKind> {
604 match self {
605 BrokerClientError::Refused { code, .. } => Some(RefusalKind::from_code(*code)),
606 _ => None,
607 }
608 }
609}
610
611#[derive(Clone, Copy, Debug, PartialEq, Eq)]
619pub enum RefusalKind {
620 VersionUnsupported,
623 VersionBlocked,
626 ServiceUnknown,
629 RateLimited,
631 ShuttingDown,
633 Other(ErrorCode),
636}
637
638impl RefusalKind {
639 pub fn from_code(code: ErrorCode) -> Self {
641 match code {
642 ErrorCode::ErrorVersionUnsupported => RefusalKind::VersionUnsupported,
643 ErrorCode::ErrorVersionBlocked => RefusalKind::VersionBlocked,
644 ErrorCode::ErrorServiceUnknown => RefusalKind::ServiceUnknown,
645 ErrorCode::ErrorRateLimited => RefusalKind::RateLimited,
646 ErrorCode::ErrorShuttingDown => RefusalKind::ShuttingDown,
647 other => RefusalKind::Other(other),
648 }
649 }
650}
651
652#[cfg(test)]
653mod cluster_h_tests {
654 use super::*;
655 use std::time::Instant;
656
657 fn with_timeout_env<T>(value: Option<&str>, body: impl FnOnce() -> T) -> T {
662 let name = crate::env_vars::BROKER_CLIENT_TIMEOUT_MS.name;
663 let previous = std::env::var_os(name);
664 match value {
665 Some(value) => std::env::set_var(name, value),
666 None => std::env::remove_var(name),
667 }
668 let outcome = body();
669 match previous {
670 Some(previous) => std::env::set_var(name, previous),
671 None => std::env::remove_var(name),
672 }
673 outcome
674 }
675
676 #[test]
677 fn broker_client_timeout_defaults_when_unset_or_invalid() {
678 for value in [None, Some("nope"), Some("0")] {
679 assert_eq!(
680 with_timeout_env(value, broker_client_deadline),
681 DEFAULT_BROKER_CLIENT_TIMEOUT,
682 "{value:?} must leave the default in place"
683 );
684 }
685 }
686
687 #[test]
688 fn broker_client_timeout_honors_valid_override() {
689 assert_eq!(
690 with_timeout_env(Some("750"), broker_client_deadline),
691 Duration::from_millis(750)
692 );
693 }
694
695 #[test]
696 fn send_admin_request_to_missing_broker_errors_promptly() {
697 let bogus = if cfg!(windows) {
701 r"\.\pipe\running-process-broker-nonexistent-cluster-h-test"
702 } else {
703 "/tmp/running-process-broker-nonexistent-cluster-h-test.sock"
704 };
705 let start = Instant::now();
706 let result = send_admin_request(bogus, AdminRequest::default());
707 assert!(result.is_err());
708 assert!(
709 start.elapsed() < Duration::from_secs(5),
710 "send_admin_request to a missing broker took {:?}; should fail fast",
711 start.elapsed()
712 );
713 }
714}