Skip to main content

running_process/broker/
client.rs

1//! Client-side helpers for the v1 broker Hello path.
2
3use 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
20/// Default wall-clock bound on waiting for the broker's handoff-ready relay
21/// before silently downgrading to the `backend_pipe` reconnect path.
22pub const DEFAULT_HANDOFF_READY_TIMEOUT: Duration = Duration::from_secs(2);
23
24/// Canonical emergency escape hatch for participating broker consumers.
25pub const RUNNING_PROCESS_DISABLE_ENV: &str = "RUNNING_PROCESS_DISABLE";
26/// Value that disables broker usage and keeps the consumer on its direct path.
27pub const RUNNING_PROCESS_DISABLE_VALUE: &str = "1";
28/// TEST-ONLY seam that points the client at a fake backend endpoint (#354).
29///
30/// When set and non-empty, [`connect_to_backend`] connects directly to the
31/// given endpoint (same local-socket transport as the Hello-skip cache path)
32/// and skips broker discovery, Hello negotiation, and version checks
33/// entirely. A connect failure is returned as-is — there is no fallback to
34/// the real broker path, so tests that set this seam stay deterministic.
35///
36/// **Never set this in production.** It bypasses every broker safety check.
37/// The canonical escape hatch [`RUNNING_PROCESS_DISABLE_ENV`]`=1` takes
38/// precedence: when the broker is disabled, the fake-backend seam is ignored
39/// too.
40pub const RUNNING_PROCESS_FAKE_BACKEND_ENV: &str = "RUNNING_PROCESS_FAKE_BACKEND";
41
42/// Return whether the canonical broker escape hatch is enabled.
43///
44/// This helper only parses the shared environment contract. Consumers still
45/// own the direct fallback path they should use when this returns `true`.
46pub 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/// Inputs for [`connect_to_backend`].
61#[derive(Clone, Debug)]
62pub struct ConnectBackendRequest<'a> {
63    /// Broker pipe/socket endpoint.
64    pub broker_endpoint: &'a str,
65    /// Logical service name, such as `zccache`.
66    pub service_name: &'a str,
67    /// Backend version the caller wants.
68    pub wanted_version: &'a str,
69    /// Version of the caller's own service binary.
70    pub self_version: &'a str,
71    /// Previously negotiated backend endpoint, if the caller has one.
72    pub cached_backend_endpoint: Option<&'a str>,
73    /// Informational client version.
74    pub client_version: &'a str,
75    /// Client library name for diagnostics.
76    pub client_lib_name: &'a str,
77    /// Client library version for diagnostics.
78    pub client_lib_version: &'a str,
79    /// Proposed keepalive interval.
80    pub client_keepalive_secs: u64,
81    /// Opt in to adopting a handed-off backend connection (#354, slice 7).
82    ///
83    /// Default `false`: the client always reconnects to
84    /// `Negotiated.backend_pipe`, exactly as before. When `true` AND the
85    /// broker negotiated [`CAP_HANDLE_PASSING`] AND issued a non-empty
86    /// `Negotiated.handle_passed_token`, the client waits up to
87    /// [`Self::handoff_ready_timeout`] for the broker's handoff-ready relay
88    /// (an EVENT frame under the `0xD0FF` handoff payload protocol carrying
89    /// the backend's `HandoffAck`) on the SAME broker connection. On a valid
90    /// accepted relay with a matching token echo, the client keeps that
91    /// connection as the backend connection
92    /// ([`BackendConnectionRoute::HandlePassed`]). Any failure — missing
93    /// relay, timeout, refused or malformed ACK, token mismatch — silently
94    /// downgrades to the `backend_pipe` reconnect; adoption failure is never
95    /// an error by itself.
96    pub adopt_handed_off_connection: bool,
97    /// Deadline for the handoff-ready relay when
98    /// [`Self::adopt_handed_off_connection`] is set.
99    pub handoff_ready_timeout: Duration,
100}
101
102impl<'a> ConnectBackendRequest<'a> {
103    /// Build a request with running-process defaults.
104    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
150/// Capability bitmap this client advertises in `Hello.client_capabilities`.
151///
152/// [`CAP_HANDLE_PASSING`] is advertised only when the build carries a
153/// platform handoff transport (Windows `DuplicateHandle`, Unix
154/// `SCM_RIGHTS`) — currently both, but kept explicit so an exotic target
155/// degrades cleanly to the reconnect path.
156fn client_capabilities() -> u64 {
157    if handoff_transport_available() {
158        CAP_HANDLE_PASSING
159    } else {
160        0
161    }
162}
163
164/// How [`connect_to_backend`] reached the returned backend endpoint.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum BackendConnectionRoute {
167    /// Connected directly to a known backend endpoint, skipping Hello.
168    ///
169    /// Used for the cached-endpoint fast path and, deliberately reused to
170    /// avoid a new enum variant (a semver hazard for exhaustive matches),
171    /// for the [`RUNNING_PROCESS_FAKE_BACKEND_ENV`] test seam. A
172    /// fake-backend connection is distinguishable because the caller set the
173    /// env var and [`BackendConnection::endpoint`] equals its value.
174    HelloSkip,
175    /// Asked the broker via Hello, then connected to the negotiated endpoint.
176    BrokerNegotiated,
177    /// Adopted the existing broker connection after a confirmed handoff.
178    ///
179    /// The broker handed the client's connection to the backend
180    /// (`DuplicateHandle`/`SCM_RIGHTS`) and relayed the backend's accepted
181    /// `HandoffAck` back to the client, so the socket that carried Hello is
182    /// now served by the backend. No connection to `backend_pipe` was
183    /// opened; [`BackendConnection::endpoint`] still reports the negotiated
184    /// `backend_pipe` so callers can cache it for future Hello-skip.
185    HandlePassed,
186}
187
188/// Open backend connection returned by [`connect_to_backend`].
189#[derive(Debug)]
190pub struct BackendConnection {
191    /// Connected local socket stream.
192    pub stream: ipc::Stream,
193    /// Endpoint that was connected.
194    ///
195    /// For [`BackendConnectionRoute::HandlePassed`] this is the negotiated
196    /// `backend_pipe` — useful as the Hello-skip cache key — even though the
197    /// stream is the original broker connection rather than a fresh connect
198    /// to that endpoint.
199    pub endpoint: String,
200    /// Route used to establish the connection.
201    pub route: BackendConnectionRoute,
202    /// Broker negotiation metadata when the broker path was used.
203    pub negotiated: Option<Negotiated>,
204}
205
206impl BackendConnection {
207    /// Pending one-time handoff token issued by the broker, if any.
208    ///
209    /// Non-empty only when both sides negotiated `CAP_HANDLE_PASSING`. By
210    /// default the client still connects via `Negotiated.backend_pipe` and
211    /// the route stays [`BackendConnectionRoute::BrokerNegotiated`]; when the
212    /// caller opted in via
213    /// [`ConnectBackendRequest::adopt_handed_off_connection`] and the broker
214    /// confirmed the handoff, the route is
215    /// [`BackendConnectionRoute::HandlePassed`] and this token is the one the
216    /// confirmation echoed (#354).
217    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
225/// Connect to a backend with the v1 Hello-skip fast path.
226///
227/// TEST seam: when [`RUNNING_PROCESS_FAKE_BACKEND_ENV`] is set to a
228/// non-empty endpoint (and `RUNNING_PROCESS_DISABLE=1` is not engaged), the
229/// client connects directly to that endpoint and returns
230/// [`BackendConnectionRoute::HelloSkip`] with no negotiation. A connect
231/// failure is returned ([`BrokerClientError::BackendConnect`]) without
232/// falling back to the broker path. Never set the seam in production.
233///
234/// When `cached_backend_endpoint` is present and `wanted_version ==
235/// self_version`, this tries the cached backend endpoint first. On miss,
236/// or when the versions differ, it sends a broker `Hello`, reads the
237/// broker `HelloReply`, and connects to `Negotiated.backend_pipe`.
238///
239/// With [`ConnectBackendRequest::adopt_handed_off_connection`] set and a
240/// negotiated handoff (capability bit + non-empty token), the client first
241/// waits — bounded by [`ConnectBackendRequest::handoff_ready_timeout`] — for
242/// the broker's handoff-ready relay on the same connection and, when the
243/// relay confirms the backend accepted, keeps that connection as the backend
244/// connection. Any adoption failure silently falls back to the
245/// `backend_pipe` reconnect below; reconnect remains the authoritative
246/// correctness path.
247pub 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/// Read the [`RUNNING_PROCESS_FAKE_BACKEND_ENV`] test seam, if active.
304///
305/// Returns `Some(endpoint)` only when the variable is set to a non-empty
306/// value AND the canonical disable hatch is not engaged
307/// (`RUNNING_PROCESS_DISABLE=1` takes precedence — a disabled broker ignores
308/// the fake seam too, mirroring the consumer-side disable contract). An
309/// invalid `RUNNING_PROCESS_DISABLE` value is a configuration error that
310/// [`broker_disabled_by_env`] surfaces to consumers before they reach
311/// `connect_to_backend`; it does not suppress the seam here.
312///
313/// Gated behind the off-by-default `test-seams` feature (#433 R4) so the test
314/// backdoor is physically absent from every production build of
315/// [`connect_to_backend`]. Consumers depend on `running-process` with
316/// `features = ["client", ...]`; `test-seams` is never in that set.
317#[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
330/// True when the broker negotiated handle passing for this connection: the
331/// server capability bit is set AND a one-time token was issued.
332fn handoff_negotiated(negotiated: &Negotiated) -> bool {
333    negotiated.server_capabilities & CAP_HANDLE_PASSING == CAP_HANDLE_PASSING
334        && !negotiated.handle_passed_token.is_empty()
335}
336
337/// Wait (bounded) for the broker's handoff-ready relay on the Hello
338/// connection and return the stream when adoption is confirmed.
339///
340/// The relay is an EVENT frame under the handoff payload protocol
341/// (`0xD0FF`) whose payload is the backend's `HandoffAck`; the client
342/// requires the token echo to match its negotiated one-time token and
343/// `accepted = true`. The blocking framed read runs on a helper thread so
344/// the wait is strictly deadline-bounded even though local-socket streams
345/// have no portable read timeout; on timeout the stream stays with the
346/// helper thread (which exits as soon as the abandoned read resolves) and
347/// the caller falls back to reconnect. Every failure returns `None` —
348/// adoption is best-effort by contract.
349fn 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
366/// Read and validate one handoff-ready relay frame.
367///
368/// Errors carry a static description for diagnostics, but the adoption
369/// contract maps every failure to the silent reconnect downgrade.
370fn 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
386/// Default deadline for a broker client round-trip (Hello / admin
387/// request). Bounds the blocking connect + write + read so a broker that
388/// accepts the connection then stalls before replying can't wedge the
389/// caller forever (issue #590, cluster H). Override with
390/// `RUNNING_PROCESS_BROKER_CLIENT_TIMEOUT_MS` (milliseconds).
391const 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
403/// Send one typed admin request to a broker endpoint and return its reply.
404///
405/// The blocking connect + write + read round-trip runs on a helper thread
406/// bounded by `broker_client_deadline` (issue #590, cluster H); on
407/// timeout the helper thread owns and drops the abandoned stream so a
408/// stalled broker never wedges the caller.
409pub 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    // Free-function `thread::spawn` (a thread, not a process spawn), so the
416    // spawn-path guard leaves it alone — same as `broker::client_v2`.
417    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
457/// Open a platform local socket by broker endpoint string.
458pub 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    // Bound the Hello handshake round-trip on a helper thread (issue #590,
471    // cluster H). `request` is borrowed, so capture the owned endpoint +
472    // pre-encoded Hello payload before moving into the thread; the
473    // negotiated stream (Send) is handed back through the channel.
474    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/// Invalid value for the canonical broker disable variable.
546#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
547#[error("RUNNING_PROCESS_DISABLE must be unset or 1, got {value:?}")]
548pub struct BrokerDisableEnvError {
549    /// Value read from `RUNNING_PROCESS_DISABLE`.
550    pub value: String,
551}
552
553/// Errors produced by broker client helpers.
554#[derive(Debug, thiserror::Error)]
555pub enum BrokerClientError {
556    /// Could not connect to the broker.
557    #[error("failed to connect to broker: {0}")]
558    BrokerConnect(io::Error),
559    /// Broker negotiation succeeded but the returned backend endpoint failed.
560    #[error("failed to connect to negotiated backend: {0}")]
561    BackendConnect(io::Error),
562    /// Frame read/write failed.
563    #[error(transparent)]
564    Framing(#[from] FramingError),
565    /// Broker response frame was malformed.
566    #[error("failed to decode broker response Frame: {0}")]
567    DecodeFrame(prost::DecodeError),
568    /// Broker response payload was not a valid `HelloReply`.
569    #[error("failed to decode broker HelloReply: {0}")]
570    DecodeHelloReply(prost::DecodeError),
571    /// Broker response payload was not a valid `AdminReply`.
572    #[error("failed to decode broker AdminReply: {0}")]
573    DecodeAdminReply(prost::DecodeError),
574    /// Broker returned an unexpected response envelope.
575    #[error("unexpected broker response frame: {0}")]
576    UnexpectedResponseFrame(&'static str),
577    /// Broker returned `HelloReply` without a result.
578    #[error("broker HelloReply did not contain a result")]
579    MissingHelloReplyResult,
580    /// Broker refused the Hello request.
581    #[error("broker refused Hello: {reason} ({code:?}, retry_after_ms={retry_after_ms})")]
582    Refused {
583        /// Stable refusal code.
584        code: ErrorCode,
585        /// Human-readable reason.
586        reason: String,
587        /// Retry hint.
588        retry_after_ms: u64,
589    },
590    /// Broker returned an empty backend endpoint.
591    #[error("broker negotiated an empty backend endpoint")]
592    EmptyBackendPipe,
593}
594
595impl BrokerClientError {
596    /// Classify a broker refusal into a stable, matchable kind (#433 R7).
597    ///
598    /// Returns `Some` only for [`BrokerClientError::Refused`]; every other
599    /// (transport/decoding) error returns `None`. Consumers branch on
600    /// [`RefusalKind`] instead of pattern-matching the raw `i32`
601    /// [`ErrorCode`], so retry/escalate decisions stay readable and survive
602    /// the addition of future codes (mapped to [`RefusalKind::Other`]).
603    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/// Stable, matchable classification of a broker `HelloReply::Refused` code.
612///
613/// This is the consumer-facing decision surface for the broker's refusal
614/// codes: the wire carries an [`ErrorCode`] `i32`, but a future broker may add
615/// codes a consumer's build predates. Matching on `RefusalKind` keeps consumer
616/// retry logic exhaustive and forward-compatible — any unrecognized code lands
617/// in [`RefusalKind::Other`] rather than silently mismatching.
618#[derive(Clone, Copy, Debug, PartialEq, Eq)]
619pub enum RefusalKind {
620    /// The requested version is below the backend's `min_version` or otherwise
621    /// not offered. Caller should upgrade/downgrade, not blindly retry.
622    VersionUnsupported,
623    /// The requested version is explicitly blocked (e.g. yanked). Do not retry
624    /// with the same version.
625    VersionBlocked,
626    /// The service name is unknown to this broker. A configuration error;
627    /// retrying will not help.
628    ServiceUnknown,
629    /// The broker is rate-limiting this peer. Honour `retry_after_ms`.
630    RateLimited,
631    /// The broker is shutting down. Retry against a fresh broker.
632    ShuttingDown,
633    /// Any other refusal code (peer rejected, internal, fd pressure, spawn
634    /// failure, unspecified, or a code newer than this build understands).
635    Other(ErrorCode),
636}
637
638impl RefusalKind {
639    /// Map a wire [`ErrorCode`] to its [`RefusalKind`].
640    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    /// The rule these assert now lives on the declaration
658    /// (`env_vars::BROKER_CLIENT_TIMEOUT_MS`), so they exercise the real read
659    /// rather than a private copy of the parsing -- which is what would have
660    /// let this timeout drift away from its neighbours in the first place.
661    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        // A broker endpoint that does not exist must fail fast (connection
698        // refused / not found), never hang, and return within the bounded
699        // helper-thread deadline.
700        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}