Skip to main content

running_process/broker/
client_v2.rs

1//! v2 broker client (slice 4 of #488).
2//!
3//! Counterpart of [`super::client`]. Single public entry point
4//! [`connect`]: dial the v2 broker pipe by program name, exchange a
5//! Hello / Negotiated, return a [`ClientSession`] handle.
6//!
7//! The v2 broker fronts each program via the namespace defined by
8//! [`super::lifecycle::names_v2::v2_program_pipe`]. The Hello round-trip
9//! itself reuses v1's framing (`protocol::{read_frame, write_frame}`)
10//! and message shapes (`Hello`, `HelloReply`) per #470's coexistence
11//! table. Subsequent slices add post-Hello operations (streaming,
12//! HTTP endpoint discovery, etc.); this slice exposes only the
13//! handshake so downstream consumers (zccache et al.) can pin against
14//! a stable v2 client API while the broker side grows under them.
15
16use 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
23/// Default deadline for the Hello round-trip in [`connect`].
24///
25/// Mirrors v1's `AsyncBrokerSession::adopt` budget (~3s). A v2 broker
26/// that accepts the dial but stalls (deadlock, GC pause, hung backend
27/// resolver, ENOSPC log write) would otherwise hang the caller
28/// indefinitely — local-socket streams have no portable read deadline,
29/// so the only bound is via a helper thread + `recv_timeout`. Fixes
30/// #517.
31pub 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/// Errors surfaced by [`connect`].
48#[derive(Debug, thiserror::Error)]
49pub enum BrokerV2Error {
50    /// `user_sid_hash` failed.
51    #[error(transparent)]
52    Sid(#[from] SidError),
53
54    /// Building the v2 pipe name failed.
55    #[error(transparent)]
56    PipeName(#[from] PipePathError),
57
58    /// Dialing the v2 broker pipe failed (no listener, permission denied, ...).
59    #[error("dial v2 broker pipe at {socket_path:?}: {source}")]
60    Dial {
61        /// Path the client attempted to dial.
62        socket_path: String,
63        /// Underlying IO error.
64        #[source]
65        source: std::io::Error,
66    },
67
68    /// Framing-layer error on read or write (envelope version mismatch,
69    /// truncated body, oversized frame, ...).
70    #[error(transparent)]
71    Framing(#[from] FramingError),
72
73    /// Underlying IO failure during Hello / HelloReply exchange.
74    #[error("Hello round-trip io: {0}")]
75    Io(#[from] std::io::Error),
76
77    /// `HelloReply` payload failed to decode.
78    #[error("HelloReply decode: {0}")]
79    Decode(#[from] prost::DecodeError),
80
81    /// `HelloReply` was syntactically valid but missing its `result` oneof.
82    #[error("HelloReply.result missing")]
83    MissingResult,
84
85    /// Broker explicitly refused the Hello (returned a `Refused` reply).
86    ///
87    /// `retry_after_ms` is promoted from `details.retry_after_ms` to a
88    /// top-level field so RateLimited callers don't have to thread the
89    /// boxed prost payload back out to honor broker-supplied backoff.
90    /// Matches the shape of v1's `BrokerClientError::Refused`. Fixes
91    /// #518. `details` is kept so any future scalar / nested field in
92    /// the prost message stays accessible without another API break.
93    #[error("broker refused Hello: {reason}")]
94    Refused {
95        /// Human-readable refusal text.
96        reason: String,
97        /// Suggested back-off before retrying (0 = no hint). Mirrors the
98        /// proto wire type (`Refused.retry_after_ms` is `uint64`).
99        retry_after_ms: u64,
100        /// Decoded refused payload for further inspection by callers.
101        details: Box<Refused>,
102    },
103
104    /// Encoding the outbound `Hello` failed.
105    #[error("Hello encode: {0}")]
106    Encode(#[from] prost::EncodeError),
107}
108
109/// Error from the install-path-scoped broker connection API.
110///
111/// This is deliberately separate from [`BrokerV2Error`] so adding the new
112/// identity failure cannot break downstream exhaustive matches on that
113/// established public enum.
114#[derive(Debug, thiserror::Error)]
115pub enum BrokerPathConnectError {
116    /// The installed broker path could not be canonicalized exactly.
117    #[error(transparent)]
118    Identity(#[from] BrokerPathIdentityError),
119
120    /// The derived endpoint could not complete the v2 broker handshake.
121    #[error(transparent)]
122    Connect(#[from] BrokerV2Error),
123}
124
125/// Internal error vocabulary for the compatibility adapter's explicit-Hello
126/// path. Keeping the additional validation stages here preserves exhaustive
127/// downstream matches on the established public [`BrokerV2Error`] enum.
128#[derive(Debug, thiserror::Error)]
129pub(crate) enum ExplicitHelloError {
130    /// An error already represented by the stable v2 client API.
131    #[error(transparent)]
132    Broker(#[from] BrokerV2Error),
133
134    /// The outer response `Frame` failed to decode.
135    #[error("response Frame decode: {0}")]
136    DecodeFrame(prost::DecodeError),
137
138    /// The broker returned a decodable response with an invalid envelope or
139    /// request correlation.
140    #[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/// Async counterpart of [`ClientSession`] for tokio callers.
157///
158/// Both v2 client operations block: `connect_with_deadline` bounds the Hello
159/// with a helper thread and `recv_timeout`, and the backend dial is a blocking
160/// `connect`. Neither may run on a runtime worker, so each is wrapped in
161/// `spawn_blocking` here — the same approach v1's `AsyncBrokerSession` takes,
162/// and for the same reason: the v2 wire is defined against blocking I/O, and
163/// duplicating it against `AsyncRead`/`AsyncWrite` would mean two wire
164/// implementations to keep in step.
165///
166/// The pair this exists for is v1's `AsyncBrokerSession::adopt` ->
167/// `into_backend_io`, which is what `client_compat` re-exports today (#532
168/// criterion 5). Matching that shape is what lets those re-exports point at
169/// `client_v2` without the consumer changing.
170#[cfg(feature = "client-async")]
171#[derive(Debug)]
172pub struct AsyncClientSession {
173    inner: ClientSession,
174}
175
176#[cfg(feature = "client-async")]
177impl AsyncClientSession {
178    /// Negotiate with the v2 broker on a blocking worker.
179    ///
180    /// Bounded by [`DEFAULT_HELLO_DEADLINE`]; for a custom bound use
181    /// [`connect_with_deadline`](Self::connect_with_deadline).
182    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    /// [`connect`](Self::connect) with a caller-supplied Hello deadline.
187    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    /// The broker's negotiated reply to our `Hello`.
203    pub fn negotiated(&self) -> &Negotiated {
204        self.inner.negotiated()
205    }
206
207    /// Dial the negotiated backend on a blocking worker.
208    ///
209    /// `async` rather than a plain delegate because the dial is a blocking
210    /// `connect` on a local socket: calling it directly from a task would
211    /// stall a runtime worker for as long as the backend takes to accept,
212    /// which for an unresponsive backend is the whole connect timeout.
213    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    /// [`connect_backend`](Self::connect_backend), handed back as an owned OS
222    /// handle. The v2 counterpart of v1's `AsyncBrokerSession::into_backend_io`.
223    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    /// Drop to the blocking session.
232    pub fn into_blocking(self) -> ClientSession {
233        self.inner
234    }
235}
236
237/// Failure from an [`AsyncClientSession`] operation.
238///
239/// Keeps the blocking errors intact rather than flattening them: a caller
240/// distinguishing a refusal from a dial failure must still be able to, and a
241/// runtime-level join failure is neither of those things and should not be
242/// disguised as one.
243#[cfg(feature = "client-async")]
244#[derive(Debug, thiserror::Error)]
245pub enum AsyncConnectError {
246    /// The broker exchange itself failed.
247    #[error(transparent)]
248    Broker(#[from] BrokerV2Error),
249
250    /// The negotiated backend could not be dialed.
251    #[error(transparent)]
252    Dial(#[from] BackendDialError),
253
254    /// The blocking worker did not report back — the task panicked or the
255    /// runtime shut down under it. Distinct from both of the above: nothing
256    /// was learned about the broker or the backend.
257    #[error("the blocking worker did not complete: {0}")]
258    Join(String),
259}
260
261/// A live session with the v2 broker.
262///
263/// Wraps the underlying local IPC stream plus the broker's [`Negotiated`]
264/// reply. Future slices add operations on top (streaming frames, HTTP
265/// endpoint discovery, etc.); slice 4 exposes only the handshake
266/// result so downstream consumers can pin the API shape now.
267#[derive(Debug)]
268pub struct ClientSession {
269    stream: ipc::Stream,
270    negotiated: Negotiated,
271}
272
273impl ClientSession {
274    /// The broker's negotiated reply to our `Hello`.
275    pub fn negotiated(&self) -> &Negotiated {
276        &self.negotiated
277    }
278
279    /// Consume the session into the raw byte stream + negotiated reply.
280    ///
281    /// Slices that add post-handshake operations build them on this
282    /// raw stream until the v2 client surface stabilizes.
283    pub fn into_inner(self) -> (LegacyStream, Negotiated) {
284        (into_legacy_ipc_stream(self.stream), self.negotiated)
285    }
286
287    /// Dial the backend the broker named, and hand back that connection.
288    ///
289    /// The stream inside a [`ClientSession`] is connected to the **broker**,
290    /// not to the backend — it exists to carry the Hello. The data connection
291    /// is a second dial, to `Negotiated.backend_pipe`, and this performs it.
292    ///
293    /// This is v1's `BrokerNegotiated` route, step for step: `client.rs`
294    /// reads the `HelloReply`, refuses an empty `backend_pipe`, then calls
295    /// [`crate::broker::client::connect_local_socket`] on it and treats *that* socket as the
296    /// connection. Keeping the sequence identical is the point — a consumer
297    /// moving from v1's `client_compat` re-exports to `client_v2` must not be
298    /// able to tell, and the way to guarantee that is to do the same thing
299    /// rather than something equivalent-looking.
300    ///
301    /// The broker stream is dropped here, as v1 drops it: its job ended with
302    /// the reply.
303    pub fn connect_backend(self) -> Result<LegacyStream, BackendDialError> {
304        self.connect_backend_ipc().map(into_legacy_ipc_stream)
305    }
306
307    /// [`connect_backend`](Self::connect_backend) without the legacy
308    /// source-compatibility unwrap.
309    ///
310    /// `connect_backend` predates the opaque IPC facade and keeps handing back
311    /// the transport type until the next major release. In-repo callers use
312    /// this instead so the native type never leaves `platform::ipc`.
313    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    /// [`connect_backend`](Self::connect_backend), handed back as an owned OS
321    /// handle for a consumer that wants to run its own protocol over it.
322    ///
323    /// The v2 counterpart of v1's `into_backend_io`. v1 can hand back the
324    /// session's own stream because by then it is already the backend
325    /// connection; here the dial happens first, so the handle a caller
326    /// receives is the same kind of socket either way.
327    ///
328    /// Unix-only, matching v1: the Windows `OwnedHandle` path is deferred
329    /// (#720) and returns `IntoBackendIoError::WindowsUnsupported`. Plain
330    /// backticks, not an intra-doc link: that variant is `#[cfg(windows)]`,
331    /// so a link to it is unresolvable on the platform CI documents. The
332    /// neighbouring reference in `adopt.rs` is written the same way for the
333    /// same reason. zccache
334    /// already re-dials with its own transport on Windows for that reason, so
335    /// this parity is what keeps its two platform lanes unchanged.
336    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/// Why dialing the negotiated backend failed.
343///
344/// Mirrors the v1 distinctions rather than collapsing them: "the broker named
345/// no backend" and "the backend would not accept" call for different consumer
346/// behaviour, and v1 already separates them (`EmptyBackendPipe` vs
347/// `BackendConnect`).
348#[derive(Debug, thiserror::Error)]
349pub enum BackendDialError {
350    /// The broker negotiated successfully but named no backend.
351    ///
352    /// Not a refusal: the v2 broker replies with an empty `backend_pipe` when
353    /// a service is registered and version-compatible but its daemon has not
354    /// published yet. Retrying later can succeed, which is why this is not
355    /// folded into the connect error.
356    #[error("broker negotiated but named no backend pipe")]
357    EmptyBackendPipe,
358
359    /// The backend pipe was named but would not accept a connection.
360    #[error("could not connect to the negotiated backend: {0}")]
361    Connect(#[source] std::io::Error),
362
363    /// The connection was made but could not be handed back as an owned
364    /// handle — on Windows, always (#720).
365    #[error("could not take ownership of the backend socket: {0}")]
366    IntoBackendIo(#[source] IntoBackendIoError),
367}
368
369/// Dial the v2 broker for `program` and exchange Hello / Negotiated.
370///
371/// Computes the pipe name via [`v2_program_pipe`], dials it, sends a
372/// Hello carrying `program` as `service_name` and `version_hint` as
373/// `wanted_version`, reads the HelloReply, and either returns a
374/// [`ClientSession`] (on `Negotiated`) or a [`BrokerV2Error::Refused`]
375/// (on `Refused`).
376///
377/// `connection_id` on the outbound Hello is left at 0 — the broker
378/// assigns one and echoes it in the Negotiated reply.
379///
380/// Bounded by [`DEFAULT_HELLO_DEADLINE`]; for a custom deadline use
381/// [`connect_with_deadline`].
382pub fn connect(program: &str, version_hint: &str) -> Result<ClientSession, BrokerV2Error> {
383    connect_service(program, program, version_hint)
384}
385
386/// Dial the v2 broker bound for `program` while routing the Hello to an
387/// independently named `service_name`.
388///
389/// Shared brokers use one stable bind namespace and select among backend
390/// partitions through `Hello.service_name`. [`connect`] remains the convenient
391/// same-name form for dedicated brokers.
392pub 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
400/// Connect to the v2 broker for `program`, or terminate the process.
401///
402/// The fail-fast entry point for callers that must never spin on an
403/// unreachable daemon (running-process#894). Where [`connect`] returns an
404/// error the caller might loop on — the retry/respawn behaviour that pinned
405/// every core and hung the machine downstream — this makes an unreachable
406/// daemon terminal: one bounded attempt, then an all-thread stack dump (so the
407/// stuck thread is visible) and `exit 1`. It never retries and never respawns.
408///
409/// An out-of-band [`ConnectWatchdog`] guarantees termination even if the dump
410/// or the exit epilogue itself wedges: the attempt must finish within
411/// `deadline + WATCHDOG_GRACE` or the process is aborted. On the success path
412/// the watchdog is disarmed as the returned session leaves this function.
413///
414/// This does not return on failure; the return type reflects the success case.
415pub fn connect_or_die(program: &str, version_hint: &str, deadline: Duration) -> ClientSession {
416    // Armed for the whole attempt. Dropped (disarmed) only when we return a
417    // session below; `std::process::exit` skips destructors, so the terminal
418    // path deliberately leaves it armed as a backstop.
419    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
443/// Same as [`connect`] but with a caller-supplied deadline for the
444/// Hello round-trip. On deadline returns
445/// `BrokerV2Error::Io(ErrorKind::TimedOut)` and the helper thread
446/// continues to drain (there is no portable way to cancel a sync
447/// `Stream::connect` / framed read mid-call).
448///
449/// Fixes #517 — without this bound, a v2 broker that accepts the dial
450/// then stalls hangs the caller indefinitely.
451pub 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
459/// Deadline-bounded form of [`connect_service`].
460pub 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
476/// Connect to the broker endpoint derived from an installed broker path.
477///
478/// The canonical path is the complete scope identity. This intentionally
479/// bypasses [`user_sid_hash`]: a machine-wide installation is shared, while a
480/// user-private installation already differs by its path.
481pub 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
504/// Deadline-bounded legacy per-user connect using a caller-supplied scope hash.
505///
506/// The bare name still resolves through the per-user runtime directory. Do not
507/// pass [`broker_path_scope_hash`] here: install-path-scoped callers must use
508/// [`connect_service_for_broker_path_with_deadline`] so Unix does not add user
509/// identity back into the resolved endpoint.
510pub 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/// Connect to an explicit v2 broker endpoint with a caller-supplied Hello.
529///
530/// This is the compatibility-preserving form: callers that already own the
531/// complete Hello contract (client identity, capabilities, keepalive and
532/// request identity) can move to the v2 transport without those fields being
533/// replaced by `client_v2` defaults.
534#[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
583/// Inner connect without a deadline. Called from inside the helper
584/// thread spawned by [`connect_with_deadline`].
585fn 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    // The wire-level `write_frame`/`read_frame` pair is only the raw
639    // length-prefixed byte framing (`protocol::framing`) -- v1's actual
640    // message framing is the `Frame` protobuf envelope
641    // (`envelope_version`/`kind`/`payload`/...), which the server's
642    // `connection.rs` accept loop `Frame::decode`s on every Hello and
643    // `Frame`-wraps every reply (`write_response_frame`). Sending the bare
644    // `Hello` bytes here (as this function previously did) is a genuine
645    // client/server framing mismatch: the server's `Frame::decode` of a
646    // bare `Hello` payload happens to succeed anyway (both messages start
647    // with low-numbered fields), but the reply comes back `Frame`-wrapped,
648    // and decoding those bytes directly as `HelloReply` misreads `Frame`'s
649    // own fields (e.g. `envelope_version`, a `Varint`) as `HelloReply`'s
650    // `result` oneof (which is entirely message-typed, `LengthDelimited`)
651    // -- exactly the `UnexpectedWireType { actual: Varint, expected:
652    // LengthDelimited }` decode failure this was caught by (soldr#2364).
653    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
701/// Resolve the per-user endpoint a bare broker-v2 pipe name lives at.
702///
703/// # Why this is not derived here
704///
705/// It used to be: this function spelled out `XDG_RUNTIME_DIR`, macOS's
706/// `TMPDIR` plus a hashed leaf short enough for `sun_path`, a `getuid()` for
707/// the fallback directory, and the Windows pipe prefix. The server side of
708/// the same endpoint already asked `platform::ipc` for it (see
709/// `singleton_bind::resolve_path_scoped_socket_path`, which is this call with
710/// `path_scoped: true`).
711///
712/// Two independent derivations of one endpoint fail silently when they drift
713/// -- the client simply reports "no broker running" forever -- and they had
714/// already drifted. The copy here read `TMPDIR` and `XDG_RUNTIME_DIR` through
715/// the declared environment table, where an empty value means unset; the
716/// facade reads them with `var_os`, where an empty value is a real value that
717/// joins into a *relative* socket path. A caller with `TMPDIR=""` got one
718/// path from the client and another from the server.
719fn 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/// Resolve a test endpoint the same way the code under test does.
724///
725/// Deliberately goes through `platform::ipc` rather than re-deriving a name:
726/// every bind and dial must share the canonical conversion boundary so a
727/// resolved Windows pipe cannot acquire its namespace twice.
728#[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    /// Test-side counterpart of [`connect`]'s Frame-wrapping: reads a
837    /// length-prefixed `Frame`-wrapped `Hello` off `stream` and decodes
838    /// the inner `Hello`. These in-process stub brokers stand in for the
839    /// real `serve_launching_backends` accept loop, so they must speak
840    /// the same on-wire shape the real server does (soldr#2364) -- a
841    /// stub that reads/writes bare `Hello`/`HelloReply` bytes no longer
842    /// matches what `connect` sends/expects.
843    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    /// Test-side counterpart of [`connect`]'s Frame-wrapping: encodes
853    /// `reply` as a `Frame`-wrapped `HelloReply` and writes it to `stream`.
854    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    /// RAII guard: on `Drop`, removes the socket file at `path`. Used by
870    /// [`spawn_stub_broker`] so a panic between bind and the final
871    /// explicit `remove_file` doesn't leak a stale `.sock` that would
872    /// poison the next test run.
873    ///
874    /// Fixes #519: previously, any panic between `tx.send` and the
875    /// explicit `remove_file` left a stale socket. The next test run
876    /// either got `EADDRINUSE` on bind or `ECONNREFUSED` on connect to
877    /// the dead socket — both masking the real failure.
878    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    /// Make an endpoint path bindable, and take it away again afterwards.
889    ///
890    /// Where endpoints are files, a stale one from a previous run is what
891    /// #519 recorded masking real failures -- `EADDRINUSE` on bind or
892    /// `ECONNREFUSED` on connect to a dead socket. So the path is cleared
893    /// before binding and removed after, whatever the test did in between.
894    ///
895    /// Where endpoints are not files -- a Windows named pipe has no
896    /// directory entry and disappears with its last handle -- there is
897    /// nothing to prepare and nothing to remove, and the guard is inert.
898    /// That is asked of `platform::ipc` rather than of the host, so the
899    /// answer cannot drift from the transport actually in use.
900    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    /// In-process stub broker: listens on the given path, accepts ONE
913    /// connection, reads a Hello, sends back a `Negotiated` with
914    /// `connection_id = 0xC0FFEE`. Returns nothing — the test asserts
915    /// against the ClientSession the real client builds.
916    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            // RAII guard removes the socket on scope exit; the explicit
939            // remove that lived here previously was a no-op leftover.
940            let _ = hello.service_name;
941        });
942        rx
943    }
944
945    #[test]
946    fn connect_completes_hello_round_trip_against_stub_broker() {
947        // Use a per-test program name so parallel tests don't collide.
948        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        // The Listener on Windows is fully ready as soon as `create_sync`
959        // returns; on Unix the same holds. But a short retry loop is
960        // resilient to spawning race in CI.
961        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    /// In-process stub that accepts the dial then sleeps forever — the
1035    /// pathological case that motivated #517. Without the helper-thread
1036    /// deadline, the client hangs indefinitely.
1037    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            // Stall — never reads the Hello, never replies. The deadline
1046            // bound on the client side is what releases it.
1047            thread::sleep(Duration::from_secs(60));
1048        });
1049        rx
1050    }
1051
1052    /// `connect_with_deadline` returns `TimedOut` when the broker
1053    /// accepts then stalls. Fixes #517.
1054    #[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    /// `BrokerV2Error::Refused` exposes `retry_after_ms` as a top-level
1079    /// field, mirroring v1's `BrokerClientError::Refused`. Fixes #518.
1080    /// Constructs a stub broker that replies with Refused, asserts the
1081    /// retry hint surfaces top-level (not buried in `details`).
1082    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    /// Stress stub: accepts `count` connections in a loop, replying
1105    /// Negotiated to each. Used by the concurrent-connect stress test
1106    /// to prove the client side doesn't deadlock or leak handles when
1107    /// many threads dial simultaneously.
1108    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    /// Stress test: 8 concurrent `connect_with_deadline` calls against a
1140    /// multi-accept stub broker. All must succeed within wall-clock
1141    /// budget — the helper-thread + `recv_timeout` pattern must scale
1142    /// to concurrent callers without serializing on a global mutex or
1143    /// deadlocking on the channel.
1144    #[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    /// Adversarial stub: accepts, reads Hello, replies with a HelloReply
1182    /// whose `result` oneof is `None` (proto3 default — easy bug if a
1183    /// future broker forgets to set the variant). Must surface as
1184    /// `BrokerV2Error::MissingResult`, not be mis-routed as success.
1185    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    /// Adversarial: broker accepts then immediately drops the stream
1228    /// without reading the Hello or writing a reply. Must surface as
1229    /// a typed transport error (Framing/Io), never as a successful
1230    /// session, never hang past the deadline.
1231    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); // immediate close
1240        });
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        // The exact variant depends on whether the write or read hits the
1266        // disconnect first: Framing(UnexpectedEof), Io(BrokenPipe), or
1267        // Dial (rare race). All are transport-class — none is a session.
1268        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    /// Adversarial: every malformed program name must be rejected BEFORE
1280    /// `Stream::connect` runs — proves `v2_program_pipe`'s validation is
1281    /// the front gate. Catches NUL injection, path traversal, uppercase,
1282    /// over-long names, and empties. The expected error variant is
1283    /// `BrokerV2Error::PipeName(_)` because `v2_program_pipe`'s
1284    /// `validate_service_name` fires before any IO.
1285    #[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    /// Pin u64::MAX round-trips through `retry_after_ms` without overflow.
1307    /// `Duration::from_millis(u64::MAX)` is valid (~584M years); locks
1308    /// the contract for any caller doing `Duration::from_millis(retry_after_ms)`.
1309    #[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                // Caller-side contract: this Duration construction must not panic.
1339                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    /// The blocking Hello does not occupy the runtime worker.
1387    ///
1388    /// This is the property the async type exists for, and nothing else here
1389    /// tests it — verified by removing `spawn_blocking` and watching every
1390    /// other async test still pass. Correctness of the result is identical
1391    /// either way; what differs is whether the runtime can do anything else
1392    /// meanwhile.
1393    ///
1394    /// Uses the stalling broker so the call reliably takes its full deadline.
1395    /// On a current-thread runtime a spawned task only runs when the current
1396    /// task yields, so if the Hello ran inline the flag would still be unset
1397    /// when the assert executes.
1398    #[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/// Coverage for the backend dial (#532).
1440///
1441/// The dial is the step that makes a v2 session reach a backend at all, and
1442/// it is the step a consumer swapping off v1's `client_compat` re-exports
1443/// inherits silently — every signature still compiles whether or not the
1444/// second connection is made correctly.
1445#[cfg(test)]
1446mod backend_dial_tests {
1447    use super::*;
1448
1449    /// Build a session with a chosen `backend_pipe`.
1450    ///
1451    /// The broker connection's contents are irrelevant — `connect_backend`
1452    /// drops it — but it must be a real opaque stream so the field being
1453    /// occupied proves the backend dial does not reuse it.
1454    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    /// A negotiated reply naming no backend is not a connection failure.
1477    ///
1478    /// The v2 broker returns an empty `backend_pipe` when a service is
1479    /// registered and version-compatible but its daemon has not published
1480    /// yet. Collapsing that into the connect error would tell a caller the
1481    /// backend refused it, when nothing was ever dialed — and the two call
1482    /// for different retry behaviour, which is why v1 separates them too.
1483    #[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    /// The dial reaches the backend, and the returned socket is live.
1500    ///
1501    /// Asserting a byte round-trip rather than just `is_ok()`: a function
1502    /// that returned the *broker* stream — the mistake this whole change is
1503    /// about — would also return `Ok`, and would also look connected. Only
1504    /// traffic arriving at the backend's listener distinguishes them.
1505    #[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        // Accept on a helper thread with a deadline. A bare `accept()` blocks
1517        // forever when nothing dials, so a regression that skips the dial
1518        // would hang this test rather than fail it — and a hang is only
1519        // caught by nextest's 2-minute killer, which reports a timeout rather
1520        // than the reason. Verified: with the dial removed, this now fails in
1521        // seconds saying nothing reached the backend.
1522        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        // Arrives at the backend's listener, not the broker's.
1532        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    /// A named-but-dead backend is a connect error, not a panic.
1544    #[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    /// A current-thread runtime is enough: `spawn_blocking` uses the separate
1562    /// blocking pool, and this crate's tokio is built without `macros` or
1563    /// `rt-multi-thread`, so `#[tokio::test]` is not available.
1564    #[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    /// The async path dials the backend and the socket it yields is live.
1572    ///
1573    /// Same assertion as the blocking test and for the same reason: returning
1574    /// the broker stream would also be `Ok`. This additionally proves the
1575    /// `spawn_blocking` hop preserves the connection — a socket that did not
1576    /// survive being moved across threads would fail here and nowhere else.
1577    #[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    /// A dial failure stays a dial failure across the runtime hop.
1611    ///
1612    /// The hazard the error type exists for: wrapping the blocking call in
1613    /// `spawn_blocking` introduces a second failure mode (the worker not
1614    /// reporting back), and it would be easy to collapse both into one
1615    /// variant. A caller that cannot tell "the backend refused" from "the
1616    /// runtime went away" cannot decide whether retrying is meaningful.
1617    #[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    /// An empty backend pipe keeps its identity through the async path too.
1637    #[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}