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::traits::Stream as _;
20use interprocess::local_socket::Stream;
21use prost::Message;
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_local_socket;
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<Stream, 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 [`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: 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) -> (Stream, Negotiated) {
284        (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    /// [`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<Stream, BackendDialError> {
304        if self.negotiated.backend_pipe.is_empty() {
305            return Err(BackendDialError::EmptyBackendPipe);
306        }
307        connect_local_socket(&self.negotiated.backend_pipe).map_err(BackendDialError::Connect)
308    }
309
310    /// [`connect_backend`](Self::connect_backend), handed back as an owned OS
311    /// handle for a consumer that wants to run its own protocol over it.
312    ///
313    /// The v2 counterpart of v1's `into_backend_io`. v1 can hand back the
314    /// session's own stream because by then it is already the backend
315    /// connection; here the dial happens first, so the handle a caller
316    /// receives is the same kind of socket either way.
317    ///
318    /// Unix-only, matching v1: the Windows `OwnedHandle` path is deferred
319    /// (#720) and returns `IntoBackendIoError::WindowsUnsupported`. Plain
320    /// backticks, not an intra-doc link: that variant is `#[cfg(windows)]`,
321    /// so a link to it is unresolvable on the platform CI documents. The
322    /// neighbouring reference in `adopt.rs` is written the same way for the
323    /// same reason. zccache
324    /// already re-dials with its own transport on Windows for that reason, so
325    /// this parity is what keeps its two platform lanes unchanged.
326    pub fn into_backend_io(self) -> Result<OwnedBackendIo, BackendDialError> {
327        let stream = self.connect_backend()?;
328        OwnedBackendIo::from_local_socket_stream(stream).map_err(BackendDialError::IntoBackendIo)
329    }
330}
331
332/// Why dialing the negotiated backend failed.
333///
334/// Mirrors the v1 distinctions rather than collapsing them: "the broker named
335/// no backend" and "the backend would not accept" call for different consumer
336/// behaviour, and v1 already separates them (`EmptyBackendPipe` vs
337/// `BackendConnect`).
338#[derive(Debug, thiserror::Error)]
339pub enum BackendDialError {
340    /// The broker negotiated successfully but named no backend.
341    ///
342    /// Not a refusal: the v2 broker replies with an empty `backend_pipe` when
343    /// a service is registered and version-compatible but its daemon has not
344    /// published yet. Retrying later can succeed, which is why this is not
345    /// folded into the connect error.
346    #[error("broker negotiated but named no backend pipe")]
347    EmptyBackendPipe,
348
349    /// The backend pipe was named but would not accept a connection.
350    #[error("could not connect to the negotiated backend: {0}")]
351    Connect(#[source] std::io::Error),
352
353    /// The connection was made but could not be handed back as an owned
354    /// handle — on Windows, always (#720).
355    #[error("could not take ownership of the backend socket: {0}")]
356    IntoBackendIo(#[source] IntoBackendIoError),
357}
358
359/// Dial the v2 broker for `program` and exchange Hello / Negotiated.
360///
361/// Computes the pipe name via [`v2_program_pipe`], dials it, sends a
362/// Hello carrying `program` as `service_name` and `version_hint` as
363/// `wanted_version`, reads the HelloReply, and either returns a
364/// [`ClientSession`] (on `Negotiated`) or a [`BrokerV2Error::Refused`]
365/// (on `Refused`).
366///
367/// `connection_id` on the outbound Hello is left at 0 — the broker
368/// assigns one and echoes it in the Negotiated reply.
369///
370/// Bounded by [`DEFAULT_HELLO_DEADLINE`]; for a custom deadline use
371/// [`connect_with_deadline`].
372pub fn connect(program: &str, version_hint: &str) -> Result<ClientSession, BrokerV2Error> {
373    connect_service(program, program, version_hint)
374}
375
376/// Dial the v2 broker bound for `program` while routing the Hello to an
377/// independently named `service_name`.
378///
379/// Shared brokers use one stable bind namespace and select among backend
380/// partitions through `Hello.service_name`. [`connect`] remains the convenient
381/// same-name form for dedicated brokers.
382pub fn connect_service(
383    program: &str,
384    service_name: &str,
385    version_hint: &str,
386) -> Result<ClientSession, BrokerV2Error> {
387    connect_service_with_deadline(program, service_name, version_hint, DEFAULT_HELLO_DEADLINE)
388}
389
390/// Connect to the v2 broker for `program`, or terminate the process.
391///
392/// The fail-fast entry point for callers that must never spin on an
393/// unreachable daemon (running-process#894). Where [`connect`] returns an
394/// error the caller might loop on — the retry/respawn behaviour that pinned
395/// every core and hung the machine downstream — this makes an unreachable
396/// daemon terminal: one bounded attempt, then an all-thread stack dump (so the
397/// stuck thread is visible) and `exit 1`. It never retries and never respawns.
398///
399/// An out-of-band [`ConnectWatchdog`] guarantees termination even if the dump
400/// or the exit epilogue itself wedges: the attempt must finish within
401/// `deadline + WATCHDOG_GRACE` or the process is aborted. On the success path
402/// the watchdog is disarmed as the returned session leaves this function.
403///
404/// This does not return on failure; the return type reflects the success case.
405pub fn connect_or_die(program: &str, version_hint: &str, deadline: Duration) -> ClientSession {
406    // Armed for the whole attempt. Dropped (disarmed) only when we return a
407    // session below; `std::process::exit` skips destructors, so the terminal
408    // path deliberately leaves it armed as a backstop.
409    let watchdog = ConnectWatchdog::arm(deadline + WATCHDOG_GRACE);
410
411    match connect_with_deadline(program, version_hint, deadline) {
412        Ok(session) => {
413            drop(watchdog);
414            session
415        }
416        Err(err) => {
417            let error = err.to_string();
418            eprintln!(
419                "running-process: v2 broker for '{program}' unreachable within \
420                 {deadline:?}: {error} — capturing a stack dump and exiting (no retry)"
421            );
422            if let Some(path) = capture_connect_dump(program, deadline, &error) {
423                eprintln!(
424                    "running-process: all-thread stack dump written to {}",
425                    path.display()
426                );
427            }
428            std::process::exit(1);
429        }
430    }
431}
432
433/// Same as [`connect`] but with a caller-supplied deadline for the
434/// Hello round-trip. On deadline returns
435/// `BrokerV2Error::Io(ErrorKind::TimedOut)` and the helper thread
436/// continues to drain (there is no portable way to cancel a sync
437/// `Stream::connect` / framed read mid-call).
438///
439/// Fixes #517 — without this bound, a v2 broker that accepts the dial
440/// then stalls hangs the caller indefinitely.
441pub fn connect_with_deadline(
442    program: &str,
443    version_hint: &str,
444    deadline: Duration,
445) -> Result<ClientSession, BrokerV2Error> {
446    connect_service_with_deadline(program, program, version_hint, deadline)
447}
448
449/// Deadline-bounded form of [`connect_service`].
450pub fn connect_service_with_deadline(
451    program: &str,
452    service_name: &str,
453    version_hint: &str,
454    deadline: Duration,
455) -> Result<ClientSession, BrokerV2Error> {
456    let scope_hash = user_sid_hash()?;
457    connect_service_with_scope_hash_and_deadline(
458        program,
459        &scope_hash,
460        service_name,
461        version_hint,
462        deadline,
463    )
464}
465
466/// Connect to the broker endpoint derived from an installed broker path.
467///
468/// The canonical path is the complete scope identity. This intentionally
469/// bypasses [`user_sid_hash`]: a machine-wide installation is shared, while a
470/// user-private installation already differs by its path.
471pub fn connect_service_for_broker_path_with_deadline(
472    program: &str,
473    broker_path: impl AsRef<std::path::Path>,
474    service_name: &str,
475    version_hint: &str,
476    deadline: Duration,
477) -> Result<ClientSession, BrokerPathConnectError> {
478    let scope_hash = broker_path_scope_hash(broker_path)?;
479    let pipe_name = v2_program_pipe(program, &scope_hash, 0).map_err(BrokerV2Error::from)?;
480    let socket_path =
481        crate::broker::server::singleton_bind::resolve_path_scoped_socket_path(&pipe_name)
482            .map_err(|err| {
483                BrokerV2Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, err))
484            })?;
485    Ok(connect_service_at_socket_with_deadline(
486        program,
487        socket_path,
488        service_name,
489        version_hint,
490        deadline,
491    )?)
492}
493
494/// Deadline-bounded legacy per-user connect using a caller-supplied scope hash.
495///
496/// The bare name still resolves through the per-user runtime directory. Do not
497/// pass [`broker_path_scope_hash`] here: install-path-scoped callers must use
498/// [`connect_service_for_broker_path_with_deadline`] so Unix does not add user
499/// identity back into the resolved endpoint.
500pub fn connect_service_with_scope_hash_and_deadline(
501    program: &str,
502    scope_hash: &str,
503    service_name: &str,
504    version_hint: &str,
505    deadline: Duration,
506) -> Result<ClientSession, BrokerV2Error> {
507    let pipe_name = v2_program_pipe(program, scope_hash, 0)?;
508    let socket_path = resolve_socket_path(&pipe_name);
509    connect_service_at_socket_with_deadline(
510        program,
511        socket_path,
512        service_name,
513        version_hint,
514        deadline,
515    )
516}
517
518/// Connect to an explicit v2 broker endpoint with a caller-supplied Hello.
519///
520/// This is the compatibility-preserving form: callers that already own the
521/// complete Hello contract (client identity, capabilities, keepalive and
522/// request identity) can move to the v2 transport without those fields being
523/// replaced by `client_v2` defaults.
524#[cfg(feature = "client-async")]
525pub(crate) fn connect_hello_at_endpoint_with_deadline(
526    broker_endpoint: impl Into<String>,
527    hello: Hello,
528    deadline: Duration,
529) -> Result<ClientSession, ExplicitHelloError> {
530    let socket_path = broker_endpoint.into();
531    let (tx, rx) = std::sync::mpsc::channel();
532    std::thread::spawn(move || {
533        let _ = tx.send(connect_unbounded_with_hello(&socket_path, hello));
534    });
535    match rx.recv_timeout(deadline) {
536        Ok(result) => result,
537        Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
538            std::io::ErrorKind::TimedOut,
539            format!("v2 broker Hello did not complete within {deadline:?}"),
540        ))
541        .into()),
542    }
543}
544
545fn connect_service_at_socket_with_deadline(
546    program: &str,
547    socket_path: String,
548    service_name: &str,
549    version_hint: &str,
550    deadline: Duration,
551) -> Result<ClientSession, BrokerV2Error> {
552    let program = program.to_owned();
553    let service_name = service_name.to_owned();
554    let version_hint = version_hint.to_owned();
555    let (tx, rx) = std::sync::mpsc::channel();
556    std::thread::spawn(move || {
557        let _ = tx.send(connect_unbounded(
558            &program,
559            &socket_path,
560            &service_name,
561            &version_hint,
562        ));
563    });
564    match rx.recv_timeout(deadline) {
565        Ok(result) => result,
566        Err(_) => Err(BrokerV2Error::Io(std::io::Error::new(
567            std::io::ErrorKind::TimedOut,
568            format!("v2 broker Hello did not complete within {deadline:?}"),
569        ))),
570    }
571}
572
573/// Inner connect without a deadline. Called from inside the helper
574/// thread spawned by [`connect_with_deadline`].
575fn connect_unbounded(
576    program: &str,
577    socket_path: &str,
578    service_name: &str,
579    version_hint: &str,
580) -> Result<ClientSession, BrokerV2Error> {
581    let name = wrap_socket_name(socket_path).map_err(|err| BrokerV2Error::Dial {
582        socket_path: socket_path.to_string(),
583        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, err),
584    })?;
585    let mut stream = Stream::connect(name).map_err(|source| BrokerV2Error::Dial {
586        socket_path: socket_path.to_string(),
587        source,
588    })?;
589    let hello = default_hello(program, service_name, version_hint);
590    let negotiated =
591        hello_round_trip(&mut stream, hello).map_err(ExplicitHelloError::into_broker_v2)?;
592    Ok(ClientSession { stream, negotiated })
593}
594
595#[cfg(feature = "client-async")]
596fn connect_unbounded_with_hello(
597    socket_path: &str,
598    hello: Hello,
599) -> Result<ClientSession, ExplicitHelloError> {
600    let name = wrap_socket_name(socket_path).map_err(|err| BrokerV2Error::Dial {
601        socket_path: socket_path.to_string(),
602        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, err),
603    })?;
604    let mut stream = Stream::connect(name).map_err(|source| BrokerV2Error::Dial {
605        socket_path: socket_path.to_string(),
606        source,
607    })?;
608    let negotiated = hello_round_trip(&mut stream, hello)?;
609    Ok(ClientSession { stream, negotiated })
610}
611
612fn default_hello(program: &str, service_name: &str, version_hint: &str) -> Hello {
613    Hello {
614        client_min_protocol: ENVELOPE_VERSION as u32,
615        client_max_protocol: ENVELOPE_VERSION as u32,
616        service_name: service_name.to_string(),
617        wanted_version: version_hint.to_string(),
618        client_version: env!("CARGO_PKG_VERSION").to_string(),
619        client_capabilities: 0,
620        auth_token: Vec::new(),
621        request_id: format!("client_v2-{program}-{}", std::process::id()),
622        connection_id: 0,
623        peer_pid: std::process::id(),
624        client_lib_name: "running-process broker::client_v2".to_string(),
625        client_lib_version: env!("CARGO_PKG_VERSION").to_string(),
626        peer_attestation_nonce: Vec::new(),
627        capability_token: Vec::new(),
628        client_keepalive_secs: 0,
629    }
630}
631
632fn hello_round_trip<S: Read + Write>(
633    stream: &mut S,
634    hello: Hello,
635) -> Result<Negotiated, ExplicitHelloError> {
636    // The wire-level `write_frame`/`read_frame` pair is only the raw
637    // length-prefixed byte framing (`protocol::framing`) -- v1's actual
638    // message framing is the `Frame` protobuf envelope
639    // (`envelope_version`/`kind`/`payload`/...), which the server's
640    // `connection.rs` accept loop `Frame::decode`s on every Hello and
641    // `Frame`-wraps every reply (`write_response_frame`). Sending the bare
642    // `Hello` bytes here (as this function previously did) is a genuine
643    // client/server framing mismatch: the server's `Frame::decode` of a
644    // bare `Hello` payload happens to succeed anyway (both messages start
645    // with low-numbered fields), but the reply comes back `Frame`-wrapped,
646    // and decoding those bytes directly as `HelloReply` misreads `Frame`'s
647    // own fields (e.g. `envelope_version`, a `Varint`) as `HelloReply`'s
648    // `result` oneof (which is entirely message-typed, `LengthDelimited`)
649    // -- exactly the `UnexpectedWireType { actual: Varint, expected:
650    // LengthDelimited }` decode failure this was caught by (soldr#2364).
651    let hello_bytes = hello.encode_to_vec();
652    let request_frame = Frame {
653        envelope_version: PROTOCOL_VERSION,
654        kind: FrameKind::Request as i32,
655        payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
656        payload: hello_bytes,
657        request_id: 1,
658        payload_encoding: PayloadEncoding::None as i32,
659        deadline_unix_ms: 0,
660        traceparent: String::new(),
661        tracestate: String::new(),
662    };
663    let body = request_frame.encode_to_vec();
664    write_frame(stream, &body).map_err(BrokerV2Error::from)?;
665
666    let reply_frame_bytes = read_frame(stream).map_err(BrokerV2Error::from)?;
667    let reply_frame =
668        Frame::decode(reply_frame_bytes.as_slice()).map_err(ExplicitHelloError::DecodeFrame)?;
669    validate_frame_envelope(&reply_frame, FrameKind::Response, CONTROL_PAYLOAD_PROTOCOL)
670        .map_err(map_response_frame_validation)?;
671    if reply_frame.request_id != request_frame.request_id {
672        return Err(ExplicitHelloError::UnexpectedResponseFrame(
673            "request_id does not match the Hello request",
674        ));
675    }
676    let reply =
677        HelloReply::decode(reply_frame.payload.as_slice()).map_err(BrokerV2Error::Decode)?;
678    match reply.result {
679        Some(hello_reply::Result::Negotiated(n)) => Ok(n),
680        Some(hello_reply::Result::Refused(r)) => Err(BrokerV2Error::Refused {
681            reason: r.reason.clone(),
682            retry_after_ms: r.retry_after_ms,
683            details: Box::new(r),
684        }
685        .into()),
686        None => Err(BrokerV2Error::MissingResult.into()),
687    }
688}
689
690fn map_response_frame_validation(error: FrameValidationError) -> ExplicitHelloError {
691    ExplicitHelloError::UnexpectedResponseFrame(match error {
692        FrameValidationError::EnvelopeVersion { .. } => "envelope_version is not v1",
693        FrameValidationError::Kind { .. } => "kind is not RESPONSE",
694        FrameValidationError::PayloadProtocol { .. } => "payload_protocol is not control-plane",
695        FrameValidationError::PayloadEncoding { .. } => "payload is compressed",
696    })
697}
698
699fn resolve_socket_path(bare_name: &str) -> String {
700    #[cfg(windows)]
701    {
702        format!(r"\\.\pipe\{bare_name}")
703    }
704    #[cfg(unix)]
705    {
706        use std::path::PathBuf;
707        let dir: PathBuf = {
708            #[cfg(target_os = "macos")]
709            {
710                let uid = unsafe { libc::getuid() };
711                let tmp = std::env::var_os("TMPDIR")
712                    .map(PathBuf::from)
713                    .unwrap_or_else(|| PathBuf::from("/tmp"));
714                tmp.join(format!(".rp-{uid}-broker-v2"))
715            }
716            #[cfg(not(target_os = "macos"))]
717            {
718                if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
719                    PathBuf::from(d).join("running-process").join("broker-v2")
720                } else {
721                    let uid = unsafe { libc::getuid() };
722                    PathBuf::from(format!("/tmp/running-process-{uid}/broker-v2"))
723                }
724            }
725        };
726        let leaf = if cfg!(target_os = "macos") {
727            let mut hash = blake3::Hasher::new();
728            hash.update(bare_name.as_bytes());
729            let bytes = hash.finalize();
730            let mut hex = String::with_capacity(16);
731            for b in bytes.as_bytes().iter().take(8) {
732                use std::fmt::Write as _;
733                let _ = write!(hex, "{b:02x}");
734            }
735            format!("{hex}.sock")
736        } else {
737            format!("{bare_name}.sock")
738        };
739        dir.join(leaf).to_string_lossy().into_owned()
740    }
741}
742
743fn wrap_socket_name(socket_path: &str) -> Result<interprocess::local_socket::Name<'_>, String> {
744    crate::broker::server::singleton_bind::wrap_socket_name(socket_path)
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use interprocess::local_socket::traits::Listener as _;
751    use interprocess::local_socket::ListenerOptions;
752    use std::sync::mpsc;
753    use std::thread;
754    use std::time::{Duration, Instant};
755
756    struct ScriptedHelloIo {
757        response: std::io::Cursor<Vec<u8>>,
758        request: Vec<u8>,
759    }
760
761    impl Read for ScriptedHelloIo {
762        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
763            self.response.read(buf)
764        }
765    }
766
767    impl Write for ScriptedHelloIo {
768        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
769            self.request.extend_from_slice(buf);
770            Ok(buf.len())
771        }
772
773        fn flush(&mut self) -> std::io::Result<()> {
774            Ok(())
775        }
776    }
777
778    fn scripted_response(body: &[u8]) -> ScriptedHelloIo {
779        let mut response = Vec::new();
780        write_frame(&mut response, body).expect("frame scripted response");
781        ScriptedHelloIo {
782            response: std::io::Cursor::new(response),
783            request: Vec::new(),
784        }
785    }
786
787    fn valid_negotiated_response() -> Frame {
788        let reply = HelloReply {
789            result: Some(hello_reply::Result::Negotiated(Negotiated {
790                backend_pipe: "backend".into(),
791                ..Default::default()
792            })),
793        };
794        Frame {
795            envelope_version: PROTOCOL_VERSION,
796            kind: FrameKind::Response as i32,
797            payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
798            payload: reply.encode_to_vec(),
799            request_id: 1,
800            payload_encoding: PayloadEncoding::None as i32,
801            deadline_unix_ms: 0,
802            traceparent: String::new(),
803            tracestate: String::new(),
804        }
805    }
806
807    #[test]
808    fn hello_rejects_invalid_response_envelopes_and_correlation() {
809        let mut invalid = Vec::new();
810        let mut frame = valid_negotiated_response();
811        frame.envelope_version += 1;
812        invalid.push(frame);
813        let mut frame = valid_negotiated_response();
814        frame.kind = FrameKind::Event as i32;
815        invalid.push(frame);
816        let mut frame = valid_negotiated_response();
817        frame.payload_protocol += 1;
818        invalid.push(frame);
819        let mut frame = valid_negotiated_response();
820        frame.payload_encoding = PayloadEncoding::Zstd as i32;
821        invalid.push(frame);
822        let mut frame = valid_negotiated_response();
823        frame.request_id = 0;
824        invalid.push(frame);
825
826        for frame in invalid {
827            let mut io = scripted_response(&frame.encode_to_vec());
828            assert!(matches!(
829                hello_round_trip(&mut io, default_hello("test", "service", "1")),
830                Err(ExplicitHelloError::UnexpectedResponseFrame(_))
831            ));
832        }
833    }
834
835    #[test]
836    fn hello_distinguishes_outer_frame_and_inner_reply_decode_errors() {
837        let mut bad_frame = scripted_response(&[0xff, 0xff, 0xff]);
838        assert!(matches!(
839            hello_round_trip(&mut bad_frame, default_hello("test", "service", "1")),
840            Err(ExplicitHelloError::DecodeFrame(_))
841        ));
842
843        let mut frame = valid_negotiated_response();
844        frame.payload = vec![0xff, 0xff, 0xff];
845        let mut bad_reply = scripted_response(&frame.encode_to_vec());
846        assert!(matches!(
847            hello_round_trip(&mut bad_reply, default_hello("test", "service", "1")),
848            Err(ExplicitHelloError::Broker(BrokerV2Error::Decode(_)))
849        ));
850    }
851
852    /// Test-side counterpart of [`connect`]'s Frame-wrapping: reads a
853    /// length-prefixed `Frame`-wrapped `Hello` off `stream` and decodes
854    /// the inner `Hello`. These in-process stub brokers stand in for the
855    /// real `serve_launching_backends` accept loop, so they must speak
856    /// the same on-wire shape the real server does (soldr#2364) -- a
857    /// stub that reads/writes bare `Hello`/`HelloReply` bytes no longer
858    /// matches what `connect` sends/expects.
859    fn read_hello_frame(stream: &mut impl Read) -> (Hello, u64) {
860        let bytes = read_frame(stream).expect("read Hello frame");
861        let frame = Frame::decode(bytes.as_slice()).expect("decode Frame");
862        (
863            Hello::decode(frame.payload.as_slice()).expect("decode Hello"),
864            frame.request_id,
865        )
866    }
867
868    /// Test-side counterpart of [`connect`]'s Frame-wrapping: encodes
869    /// `reply` as a `Frame`-wrapped `HelloReply` and writes it to `stream`.
870    fn write_hello_reply_frame(stream: &mut impl Write, request_id: u64, reply: &HelloReply) {
871        let reply_frame = Frame {
872            envelope_version: PROTOCOL_VERSION,
873            kind: FrameKind::Response as i32,
874            payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
875            payload: reply.encode_to_vec(),
876            request_id,
877            payload_encoding: PayloadEncoding::None as i32,
878            deadline_unix_ms: 0,
879            traceparent: String::new(),
880            tracestate: String::new(),
881        };
882        write_frame(stream, &reply_frame.encode_to_vec()).expect("write HelloReply frame");
883    }
884
885    /// RAII guard: on `Drop`, removes the socket file at `path`. Used by
886    /// [`spawn_stub_broker`] so a panic between bind and the final
887    /// explicit `remove_file` doesn't leak a stale `.sock` that would
888    /// poison the next test run.
889    ///
890    /// Fixes #519: previously, any panic between `tx.send` and the
891    /// explicit `remove_file` left a stale socket. The next test run
892    /// either got `EADDRINUSE` on bind or `ECONNREFUSED` on connect to
893    /// the dead socket — both masking the real failure.
894    #[cfg(unix)]
895    struct SocketCleanup(std::path::PathBuf);
896
897    #[cfg(unix)]
898    impl Drop for SocketCleanup {
899        fn drop(&mut self) {
900            let _ = std::fs::remove_file(&self.0);
901        }
902    }
903
904    /// In-process stub broker: listens on the given path, accepts ONE
905    /// connection, reads a Hello, sends back a `Negotiated` with
906    /// `connection_id = 0xC0FFEE`. Returns nothing — the test asserts
907    /// against the ClientSession the real client builds.
908    fn spawn_stub_broker(socket_path: String) -> mpsc::Receiver<()> {
909        let (tx, rx) = mpsc::channel();
910        thread::spawn(move || {
911            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
912            #[cfg(unix)]
913            let _cleanup = {
914                let _ =
915                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
916                let _ = std::fs::remove_file(&socket_path);
917                SocketCleanup(std::path::PathBuf::from(&socket_path))
918            };
919            let listener = ListenerOptions::new()
920                .name(name)
921                .create_sync()
922                .expect("ListenerOptions create_sync");
923            tx.send(()).expect("send listener-ready signal");
924            let mut stream = listener.accept().expect("accept");
925            let (hello, request_id) = read_hello_frame(&mut stream);
926            let reply = HelloReply {
927                result: Some(hello_reply::Result::Negotiated(Negotiated {
928                    negotiated_protocol: ENVELOPE_VERSION as u32,
929                    daemon_version: "stub-1.2.3".to_string(),
930                    backend_pipe: String::new(),
931                    warnings: Vec::new(),
932                    server_capabilities: 0,
933                    keepalive_interval_secs: 0,
934                    handle_passed_token: Vec::new(),
935                    connection_id: 0x00C0_FFEE,
936                })),
937            };
938            write_hello_reply_frame(&mut stream, request_id, &reply);
939            // RAII guard removes the socket on scope exit; the explicit
940            // remove that lived here previously was a no-op leftover.
941            let _ = hello.service_name;
942        });
943        rx
944    }
945
946    #[test]
947    fn connect_completes_hello_round_trip_against_stub_broker() {
948        // Use a per-test program name so parallel tests don't collide.
949        let program = "client-v2-stub";
950        let sid = user_sid_hash().expect("user_sid_hash");
951        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
952        let socket_path = resolve_socket_path(&pipe_name);
953
954        let ready = spawn_stub_broker(socket_path.clone());
955        ready
956            .recv_timeout(Duration::from_secs(2))
957            .expect("stub broker listening");
958
959        // The Listener on Windows is fully ready as soon as `create_sync`
960        // returns; on Unix the same holds. But a short retry loop is
961        // resilient to spawning race in CI.
962        let start = Instant::now();
963        let session = loop {
964            match connect(program, "0.0.0") {
965                Ok(s) => break s,
966                Err(err) if start.elapsed() < Duration::from_secs(2) => {
967                    eprintln!("connect retry after error: {err}");
968                    std::thread::sleep(Duration::from_millis(50));
969                    continue;
970                }
971                Err(err) => panic!("connect failed after retries: {err}"),
972            }
973        };
974
975        let neg = session.negotiated();
976        assert_eq!(neg.negotiated_protocol, ENVELOPE_VERSION as u32);
977        assert_eq!(neg.connection_id, 0x00C0_FFEE);
978        assert_eq!(neg.daemon_version, "stub-1.2.3");
979    }
980
981    #[test]
982    fn connect_service_dials_broker_program_but_routes_named_service() {
983        let program = "client-v2-router";
984        let service_name = "soldr-daemon-root-version-hash";
985        let sid = user_sid_hash().expect("user_sid_hash");
986        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
987        let socket_path = resolve_socket_path(&pipe_name);
988
989        let (ready_tx, ready_rx) = mpsc::channel();
990        let (hello_tx, hello_rx) = mpsc::channel();
991        thread::spawn(move || {
992            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
993            #[cfg(unix)]
994            let _cleanup = {
995                let _ =
996                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
997                let _ = std::fs::remove_file(&socket_path);
998                SocketCleanup(std::path::PathBuf::from(&socket_path))
999            };
1000            let listener = ListenerOptions::new()
1001                .name(name)
1002                .create_sync()
1003                .expect("ListenerOptions create_sync");
1004            ready_tx.send(()).expect("ready");
1005            let mut stream = listener.accept().expect("accept");
1006            let (hello, request_id) = read_hello_frame(&mut stream);
1007            hello_tx
1008                .send(hello.service_name.clone())
1009                .expect("observed service name");
1010            write_hello_reply_frame(
1011                &mut stream,
1012                request_id,
1013                &HelloReply {
1014                    result: Some(hello_reply::Result::Negotiated(Negotiated {
1015                        backend_pipe: "route-endpoint".into(),
1016                        ..Default::default()
1017                    })),
1018                },
1019            );
1020        });
1021
1022        ready_rx
1023            .recv_timeout(Duration::from_secs(2))
1024            .expect("stub broker listening");
1025        let session = connect_service(program, service_name, "0.8.0")
1026            .expect("independent service route connects");
1027        assert_eq!(session.negotiated().backend_pipe, "route-endpoint");
1028        assert_eq!(
1029            hello_rx.recv_timeout(Duration::from_secs(2)).unwrap(),
1030            service_name
1031        );
1032    }
1033
1034    #[test]
1035    fn connect_with_no_broker_returns_dial_error() {
1036        let err =
1037            connect("client-v2-no-broker-ever", "0.0.0").expect_err("no broker => Dial error");
1038        match err {
1039            BrokerV2Error::Dial { .. } => {}
1040            other => panic!("expected Dial, got: {other:?}"),
1041        }
1042    }
1043
1044    /// In-process stub that accepts the dial then sleeps forever — the
1045    /// pathological case that motivated #517. Without the helper-thread
1046    /// deadline, the client hangs indefinitely.
1047    fn spawn_stall_broker(socket_path: String) -> mpsc::Receiver<()> {
1048        let (tx, rx) = mpsc::channel();
1049        thread::spawn(move || {
1050            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
1051            #[cfg(unix)]
1052            let _cleanup = {
1053                let _ =
1054                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
1055                let _ = std::fs::remove_file(&socket_path);
1056                SocketCleanup(std::path::PathBuf::from(&socket_path))
1057            };
1058            let listener = ListenerOptions::new()
1059                .name(name)
1060                .create_sync()
1061                .expect("ListenerOptions create_sync");
1062            tx.send(()).expect("send listener-ready signal");
1063            let _stream = listener.accept().expect("accept");
1064            // Stall — never reads the Hello, never replies. The deadline
1065            // bound on the client side is what releases it.
1066            thread::sleep(Duration::from_secs(60));
1067        });
1068        rx
1069    }
1070
1071    /// `connect_with_deadline` returns `TimedOut` when the broker
1072    /// accepts then stalls. Fixes #517.
1073    #[test]
1074    fn connect_with_deadline_fires_on_stalling_broker() {
1075        let program = "client-v2-stall-deadline";
1076        let sid = user_sid_hash().expect("user_sid_hash");
1077        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1078        let socket_path = resolve_socket_path(&pipe_name);
1079        let ready = spawn_stall_broker(socket_path);
1080        ready
1081            .recv_timeout(Duration::from_secs(2))
1082            .expect("stall broker listening");
1083        let start = Instant::now();
1084        let err = connect_with_deadline(program, "0.0.0", Duration::from_millis(200))
1085            .expect_err("stall broker => deadline TimedOut");
1086        let elapsed = start.elapsed();
1087        match err {
1088            BrokerV2Error::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::TimedOut),
1089            other => panic!("expected Io(TimedOut), got: {other:?}"),
1090        }
1091        assert!(
1092            elapsed < Duration::from_secs(2),
1093            "deadline should fire within budget; took {elapsed:?}"
1094        );
1095    }
1096
1097    /// `BrokerV2Error::Refused` exposes `retry_after_ms` as a top-level
1098    /// field, mirroring v1's `BrokerClientError::Refused`. Fixes #518.
1099    /// Constructs a stub broker that replies with Refused, asserts the
1100    /// retry hint surfaces top-level (not buried in `details`).
1101    fn spawn_refusing_broker(socket_path: String, retry_after_ms: u64) -> mpsc::Receiver<()> {
1102        let (tx, rx) = mpsc::channel();
1103        thread::spawn(move || {
1104            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
1105            #[cfg(unix)]
1106            let _cleanup = {
1107                let _ =
1108                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
1109                let _ = std::fs::remove_file(&socket_path);
1110                SocketCleanup(std::path::PathBuf::from(&socket_path))
1111            };
1112            let listener = ListenerOptions::new()
1113                .name(name)
1114                .create_sync()
1115                .expect("ListenerOptions create_sync");
1116            tx.send(()).expect("send listener-ready signal");
1117            let mut stream = listener.accept().expect("accept");
1118            let (_hello, request_id) = read_hello_frame(&mut stream);
1119            let reply = HelloReply {
1120                result: Some(hello_reply::Result::Refused(Refused {
1121                    code: 0,
1122                    reason: "stub refusal".to_string(),
1123                    retry_after_ms,
1124                    ..Refused::default()
1125                })),
1126            };
1127            write_hello_reply_frame(&mut stream, request_id, &reply);
1128        });
1129        rx
1130    }
1131
1132    /// Stress stub: accepts `count` connections in a loop, replying
1133    /// Negotiated to each. Used by the concurrent-connect stress test
1134    /// to prove the client side doesn't deadlock or leak handles when
1135    /// many threads dial simultaneously.
1136    fn spawn_multi_accept_stub_broker(socket_path: String, count: usize) -> mpsc::Receiver<()> {
1137        let (tx, rx) = mpsc::channel();
1138        thread::spawn(move || {
1139            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
1140            #[cfg(unix)]
1141            let _cleanup = {
1142                let _ =
1143                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
1144                let _ = std::fs::remove_file(&socket_path);
1145                SocketCleanup(std::path::PathBuf::from(&socket_path))
1146            };
1147            let listener = ListenerOptions::new()
1148                .name(name)
1149                .create_sync()
1150                .expect("ListenerOptions create_sync");
1151            tx.send(()).expect("send listener-ready signal");
1152            for _ in 0..count {
1153                let mut stream = match listener.accept() {
1154                    Ok(s) => s,
1155                    Err(_) => break,
1156                };
1157                let (_hello, request_id) = read_hello_frame(&mut stream);
1158                let reply = HelloReply {
1159                    result: Some(hello_reply::Result::Negotiated(Negotiated {
1160                        negotiated_protocol: ENVELOPE_VERSION as u32,
1161                        daemon_version: "stub-multi-1".to_string(),
1162                        backend_pipe: String::new(),
1163                        warnings: Vec::new(),
1164                        server_capabilities: 0,
1165                        keepalive_interval_secs: 0,
1166                        handle_passed_token: Vec::new(),
1167                        connection_id: 0x0FFF_F1EE,
1168                    })),
1169                };
1170                write_hello_reply_frame(&mut stream, request_id, &reply);
1171            }
1172        });
1173        rx
1174    }
1175
1176    /// Stress test: 8 concurrent `connect_with_deadline` calls against a
1177    /// multi-accept stub broker. All must succeed within wall-clock
1178    /// budget — the helper-thread + `recv_timeout` pattern must scale
1179    /// to concurrent callers without serializing on a global mutex or
1180    /// deadlocking on the channel.
1181    #[test]
1182    fn concurrent_connects_against_multi_accept_broker() {
1183        let program = "client-v2-concurrent-multi";
1184        let sid = user_sid_hash().expect("user_sid_hash");
1185        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1186        let socket_path = resolve_socket_path(&pipe_name);
1187        const N: usize = 8;
1188        let ready = spawn_multi_accept_stub_broker(socket_path, N);
1189        ready
1190            .recv_timeout(Duration::from_secs(2))
1191            .expect("multi-accept broker listening");
1192
1193        let start = Instant::now();
1194        let handles: Vec<_> = (0..N)
1195            .map(|_| {
1196                let p = program.to_string();
1197                thread::spawn(move || connect_with_deadline(&p, "0.0.0", Duration::from_secs(2)))
1198            })
1199            .collect();
1200        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1201        let elapsed = start.elapsed();
1202
1203        let ok = results.iter().filter(|r| r.is_ok()).count();
1204        assert_eq!(
1205            ok, N,
1206            "all {N} concurrent connects must succeed; got {ok} ok, full results: {results:?}"
1207        );
1208        assert!(
1209            elapsed < Duration::from_secs(5),
1210            "concurrent connect took {elapsed:?}; expected < 5s"
1211        );
1212        for session in results.iter().flatten() {
1213            assert_eq!(session.negotiated().connection_id, 0x0FFF_F1EE);
1214            assert_eq!(session.negotiated().daemon_version, "stub-multi-1");
1215        }
1216    }
1217
1218    /// Adversarial stub: accepts, reads Hello, replies with a HelloReply
1219    /// whose `result` oneof is `None` (proto3 default — easy bug if a
1220    /// future broker forgets to set the variant). Must surface as
1221    /// `BrokerV2Error::MissingResult`, not be mis-routed as success.
1222    fn spawn_missing_result_broker(socket_path: String) -> mpsc::Receiver<()> {
1223        let (tx, rx) = mpsc::channel();
1224        thread::spawn(move || {
1225            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
1226            #[cfg(unix)]
1227            let _cleanup = {
1228                let _ =
1229                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
1230                let _ = std::fs::remove_file(&socket_path);
1231                SocketCleanup(std::path::PathBuf::from(&socket_path))
1232            };
1233            let listener = ListenerOptions::new()
1234                .name(name)
1235                .create_sync()
1236                .expect("ListenerOptions create_sync");
1237            tx.send(()).expect("send listener-ready signal");
1238            let mut stream = listener.accept().expect("accept");
1239            let (_hello, request_id) = read_hello_frame(&mut stream);
1240            let reply = HelloReply { result: None };
1241            write_hello_reply_frame(&mut stream, request_id, &reply);
1242        });
1243        rx
1244    }
1245
1246    #[test]
1247    fn connect_rejects_hello_reply_with_missing_result_oneof() {
1248        let program = "client-v2-missing-result";
1249        let sid = user_sid_hash().expect("user_sid_hash");
1250        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1251        let socket_path = resolve_socket_path(&pipe_name);
1252        let ready = spawn_missing_result_broker(socket_path);
1253        ready
1254            .recv_timeout(Duration::from_secs(2))
1255            .expect("missing-result broker listening");
1256        let start = Instant::now();
1257        let err = loop {
1258            match connect(program, "0.0.0") {
1259                Err(e) => break e,
1260                Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1261                    thread::sleep(Duration::from_millis(50));
1262                    continue;
1263                }
1264                Ok(_) => panic!("expected MissingResult, got Ok"),
1265            }
1266        };
1267        assert!(
1268            matches!(err, BrokerV2Error::MissingResult),
1269            "expected MissingResult, got: {err:?}"
1270        );
1271    }
1272
1273    /// Adversarial: broker accepts then immediately drops the stream
1274    /// without reading the Hello or writing a reply. Must surface as
1275    /// a typed transport error (Framing/Io), never as a successful
1276    /// session, never hang past the deadline.
1277    fn spawn_drop_on_accept_broker(socket_path: String) -> mpsc::Receiver<()> {
1278        let (tx, rx) = mpsc::channel();
1279        thread::spawn(move || {
1280            let name = wrap_socket_name(&socket_path).expect("wrap_socket_name");
1281            #[cfg(unix)]
1282            let _cleanup = {
1283                let _ =
1284                    std::fs::create_dir_all(std::path::Path::new(&socket_path).parent().unwrap());
1285                let _ = std::fs::remove_file(&socket_path);
1286                SocketCleanup(std::path::PathBuf::from(&socket_path))
1287            };
1288            let listener = ListenerOptions::new()
1289                .name(name)
1290                .create_sync()
1291                .expect("ListenerOptions create_sync");
1292            tx.send(()).expect("send listener-ready signal");
1293            let stream = listener.accept().expect("accept");
1294            drop(stream); // immediate close
1295        });
1296        rx
1297    }
1298
1299    #[test]
1300    fn connect_returns_err_on_premature_disconnect() {
1301        let program = "client-v2-prem-disconnect";
1302        let sid = user_sid_hash().expect("user_sid_hash");
1303        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1304        let socket_path = resolve_socket_path(&pipe_name);
1305        let ready = spawn_drop_on_accept_broker(socket_path);
1306        ready
1307            .recv_timeout(Duration::from_secs(2))
1308            .expect("drop-on-accept broker listening");
1309        let start = Instant::now();
1310        let err = loop {
1311            match connect_with_deadline(program, "0.0.0", Duration::from_millis(500)) {
1312                Err(e) => break e,
1313                Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1314                    thread::sleep(Duration::from_millis(50));
1315                    continue;
1316                }
1317                Ok(_) => panic!("expected transport error, got Ok"),
1318            }
1319        };
1320        // The exact variant depends on whether the write or read hits the
1321        // disconnect first: Framing(UnexpectedEof), Io(BrokenPipe), or
1322        // Dial (rare race). All are transport-class — none is a session.
1323        match err {
1324            BrokerV2Error::Framing(_) | BrokerV2Error::Io(_) | BrokerV2Error::Dial { .. } => {}
1325            other => panic!("expected transport variant, got: {other:?}"),
1326        }
1327        assert!(
1328            start.elapsed() < Duration::from_secs(2),
1329            "must not hang past deadline; took {:?}",
1330            start.elapsed()
1331        );
1332    }
1333
1334    /// Adversarial: every malformed program name must be rejected BEFORE
1335    /// `Stream::connect` runs — proves `v2_program_pipe`'s validation is
1336    /// the front gate. Catches NUL injection, path traversal, uppercase,
1337    /// over-long names, and empties. The expected error variant is
1338    /// `BrokerV2Error::PipeName(_)` because `v2_program_pipe`'s
1339    /// `validate_service_name` fires before any IO.
1340    #[test]
1341    fn connect_rejects_invalid_program_names_before_dial() {
1342        let too_long = "a".repeat(65);
1343        for bad in [
1344            "zccache\0evil",
1345            "../etc/passwd",
1346            r"a\b",
1347            "Zccache",
1348            "a b",
1349            too_long.as_str(),
1350            "",
1351        ] {
1352            let err = connect(bad, "0.0.0")
1353                .expect_err(&format!("invalid program name {bad:?} must be rejected"));
1354            assert!(
1355                matches!(err, BrokerV2Error::PipeName(_)),
1356                "expected PipeName for {bad:?}, got: {err:?}"
1357            );
1358        }
1359    }
1360
1361    /// Pin u64::MAX round-trips through `retry_after_ms` without overflow.
1362    /// `Duration::from_millis(u64::MAX)` is valid (~584M years); locks
1363    /// the contract for any caller doing `Duration::from_millis(retry_after_ms)`.
1364    #[test]
1365    fn refused_with_u64_max_retry_after_ms_round_trips() {
1366        let program = "client-v2-refused-u64-max";
1367        let sid = user_sid_hash().expect("user_sid_hash");
1368        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1369        let socket_path = resolve_socket_path(&pipe_name);
1370        let ready = spawn_refusing_broker(socket_path, u64::MAX);
1371        ready
1372            .recv_timeout(Duration::from_secs(2))
1373            .expect("refusing broker listening");
1374        let start = Instant::now();
1375        let err = loop {
1376            match connect(program, "0.0.0") {
1377                Err(e) => break e,
1378                Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1379                    thread::sleep(Duration::from_millis(50));
1380                    continue;
1381                }
1382                Ok(_) => panic!("expected Refused, got Ok"),
1383            }
1384        };
1385        match err {
1386            BrokerV2Error::Refused {
1387                retry_after_ms,
1388                details,
1389                ..
1390            } => {
1391                assert_eq!(retry_after_ms, u64::MAX);
1392                assert_eq!(details.retry_after_ms, u64::MAX);
1393                // Caller-side contract: this Duration construction must not panic.
1394                let _safe_duration = Duration::from_millis(retry_after_ms);
1395            }
1396            other => panic!("expected Refused, got: {other:?}"),
1397        }
1398    }
1399
1400    #[test]
1401    fn refused_exposes_retry_after_ms_top_level() {
1402        let program = "client-v2-refused-retry";
1403        let sid = user_sid_hash().expect("user_sid_hash");
1404        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1405        let socket_path = resolve_socket_path(&pipe_name);
1406        let ready = spawn_refusing_broker(socket_path, 1234);
1407        ready
1408            .recv_timeout(Duration::from_secs(2))
1409            .expect("refusing broker listening");
1410        let start = Instant::now();
1411        let err = loop {
1412            match connect(program, "0.0.0") {
1413                Err(e) => break e,
1414                Ok(_) if start.elapsed() < Duration::from_secs(2) => {
1415                    thread::sleep(Duration::from_millis(50));
1416                    continue;
1417                }
1418                Ok(_) => panic!("expected Refused"),
1419            }
1420        };
1421        match err {
1422            BrokerV2Error::Refused {
1423                retry_after_ms,
1424                reason,
1425                details,
1426            } => {
1427                assert_eq!(
1428                    retry_after_ms, 1234,
1429                    "retry hint must surface top-level (was: {retry_after_ms})"
1430                );
1431                assert_eq!(reason, "stub refusal");
1432                assert_eq!(
1433                    details.retry_after_ms, 1234,
1434                    "details payload still carries the field for full diagnostics"
1435                );
1436            }
1437            other => panic!("expected Refused, got: {other:?}"),
1438        }
1439    }
1440
1441    /// The blocking Hello does not occupy the runtime worker.
1442    ///
1443    /// This is the property the async type exists for, and nothing else here
1444    /// tests it — verified by removing `spawn_blocking` and watching every
1445    /// other async test still pass. Correctness of the result is identical
1446    /// either way; what differs is whether the runtime can do anything else
1447    /// meanwhile.
1448    ///
1449    /// Uses the stalling broker so the call reliably takes its full deadline.
1450    /// On a current-thread runtime a spawned task only runs when the current
1451    /// task yields, so if the Hello ran inline the flag would still be unset
1452    /// when the assert executes.
1453    #[cfg(feature = "client-async")]
1454    #[test]
1455    fn the_hello_does_not_occupy_the_runtime_worker() {
1456        use std::sync::atomic::{AtomicBool, Ordering};
1457        use std::sync::Arc;
1458
1459        let program = "client-v2-async-nonblocking";
1460        let sid = user_sid_hash().expect("user_sid_hash");
1461        let pipe_name = v2_program_pipe(program, &sid, 0).expect("pipe name");
1462        let socket_path = resolve_socket_path(&pipe_name);
1463        let ready = spawn_stall_broker(socket_path);
1464        ready
1465            .recv_timeout(Duration::from_secs(2))
1466            .expect("stall broker listening");
1467
1468        let rt = tokio::runtime::Builder::new_current_thread()
1469            .build()
1470            .expect("current-thread runtime");
1471        rt.block_on(async {
1472            let progressed = Arc::new(AtomicBool::new(false));
1473            let flag = Arc::clone(&progressed);
1474            let other = tokio::spawn(async move {
1475                flag.store(true, Ordering::SeqCst);
1476            });
1477
1478            let _ = AsyncClientSession::connect_with_deadline(
1479                program,
1480                "0.0.0",
1481                Duration::from_millis(200),
1482            )
1483            .await;
1484
1485            assert!(
1486                progressed.load(Ordering::SeqCst),
1487                "the runtime made no progress during the Hello — it ran on the worker"
1488            );
1489            let _ = other.await;
1490        });
1491    }
1492}
1493
1494/// Coverage for the backend dial (#532).
1495///
1496/// The dial is the step that makes a v2 session reach a backend at all, and
1497/// it is the step a consumer swapping off v1's `client_compat` re-exports
1498/// inherits silently — every signature still compiles whether or not the
1499/// second connection is made correctly.
1500#[cfg(test)]
1501mod backend_dial_tests {
1502    use super::*;
1503    // `Stream as _` is not repeated here: `use super::*` already brings the
1504    // module's own import of it into scope, and naming it twice is a
1505    // `-D warnings` failure.
1506    use interprocess::local_socket::traits::Listener as _;
1507    use interprocess::local_socket::{ListenerOptions, Stream};
1508
1509    /// Build a session with a chosen `backend_pipe`.
1510    ///
1511    /// `stream` stands in for the broker connection. Its contents are
1512    /// irrelevant — `connect_backend` drops it — but it must be a real
1513    /// `Stream`, which is the point: the field being occupied is what proves
1514    /// the dial does not reuse it.
1515    fn session_with(stream: Stream, backend_pipe: &str) -> ClientSession {
1516        ClientSession {
1517            stream,
1518            negotiated: Negotiated {
1519                backend_pipe: backend_pipe.to_string(),
1520                ..Default::default()
1521            },
1522        }
1523    }
1524
1525    /// Resolve a name the same way the code under test does.
1526    ///
1527    /// Deliberately delegates to production's `local_socket_name` rather than
1528    /// re-deriving it. Every bind and dial must share the canonical conversion
1529    /// boundary so a resolved Windows pipe cannot acquire its namespace twice.
1530    fn socket_name(path: &str) -> interprocess::local_socket::Name<'_> {
1531        crate::broker::server::connection::local_socket_name(path).expect("socket name")
1532    }
1533
1534    fn temp_endpoint(tag: &str) -> (tempfile::TempDir, String) {
1535        let dir = tempfile::tempdir().expect("tempdir");
1536        let path = if cfg!(windows) {
1537            format!(r"\.\pipe\rp-v2-dial-{tag}-{}", std::process::id())
1538        } else {
1539            dir.path().join(format!("{tag}.sock")).display().to_string()
1540        };
1541        (dir, path)
1542    }
1543
1544    /// A negotiated reply naming no backend is not a connection failure.
1545    ///
1546    /// The v2 broker returns an empty `backend_pipe` when a service is
1547    /// registered and version-compatible but its daemon has not published
1548    /// yet. Collapsing that into the connect error would tell a caller the
1549    /// backend refused it, when nothing was ever dialed — and the two call
1550    /// for different retry behaviour, which is why v1 separates them too.
1551    #[test]
1552    fn a_negotiated_reply_with_no_backend_pipe_is_its_own_error() {
1553        let (_dir, path) = temp_endpoint("empty");
1554        let listener = ListenerOptions::new()
1555            .name(socket_name(&path))
1556            .create_sync()
1557            .expect("bind");
1558        let broker_side = Stream::connect(socket_name(&path)).expect("dial");
1559        let _accepted = listener.accept().expect("accept");
1560
1561        let err = session_with(broker_side, "")
1562            .connect_backend()
1563            .expect_err("an empty backend pipe must not be dialed");
1564        assert!(
1565            matches!(err, BackendDialError::EmptyBackendPipe),
1566            "expected EmptyBackendPipe, got {err:?}"
1567        );
1568    }
1569
1570    /// The dial reaches the backend, and the returned socket is live.
1571    ///
1572    /// Asserting a byte round-trip rather than just `is_ok()`: a function
1573    /// that returned the *broker* stream — the mistake this whole change is
1574    /// about — would also return `Ok`, and would also look connected. Only
1575    /// traffic arriving at the backend's listener distinguishes them.
1576    #[test]
1577    fn the_dial_connects_to_the_backend_and_carries_traffic() {
1578        let (_bdir, broker_path) = temp_endpoint("broker");
1579        let broker_listener = ListenerOptions::new()
1580            .name(socket_name(&broker_path))
1581            .create_sync()
1582            .expect("bind broker");
1583        let broker_side = Stream::connect(socket_name(&broker_path)).expect("dial broker");
1584        let _broker_accepted = broker_listener.accept().expect("accept broker");
1585
1586        let (_kdir, backend_path) = temp_endpoint("backend");
1587        let backend_listener = ListenerOptions::new()
1588            .name(socket_name(&backend_path))
1589            .create_sync()
1590            .expect("bind backend");
1591
1592        // Accept on a helper thread with a deadline. A bare `accept()` blocks
1593        // forever when nothing dials, so a regression that skips the dial
1594        // would hang this test rather than fail it — and a hang is only
1595        // caught by nextest's 2-minute killer, which reports a timeout rather
1596        // than the reason. Verified: with the dial removed, this now fails in
1597        // seconds saying nothing reached the backend.
1598        let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
1599        std::thread::spawn(move || {
1600            let _ = accepted_tx.send(backend_listener.accept());
1601        });
1602
1603        let mut data = session_with(broker_side, &backend_path)
1604            .connect_backend()
1605            .expect("dial the negotiated backend");
1606
1607        // Arrives at the backend's listener, not the broker's.
1608        let mut served = accepted_rx
1609            .recv_timeout(std::time::Duration::from_secs(10))
1610            .expect("nothing connected to the backend within 10s")
1611            .expect("backend accept");
1612        data.write_all(b"ping").expect("write to backend");
1613        data.flush().expect("flush");
1614        let mut got = [0u8; 4];
1615        served.read_exact(&mut got).expect("backend read");
1616        assert_eq!(&got, b"ping", "bytes did not reach the backend");
1617    }
1618
1619    /// A named-but-dead backend is a connect error, not a panic.
1620    #[test]
1621    fn a_backend_that_is_not_listening_reports_a_connect_error() {
1622        let (_bdir, broker_path) = temp_endpoint("broker2");
1623        let broker_listener = ListenerOptions::new()
1624            .name(socket_name(&broker_path))
1625            .create_sync()
1626            .expect("bind broker");
1627        let broker_side = Stream::connect(socket_name(&broker_path)).expect("dial broker");
1628        let _broker_accepted = broker_listener.accept().expect("accept broker");
1629
1630        let (_kdir, dead_path) = temp_endpoint("nobody-home");
1631        let err = session_with(broker_side, &dead_path)
1632            .connect_backend()
1633            .expect_err("nothing is listening there");
1634        assert!(
1635            matches!(err, BackendDialError::Connect(_)),
1636            "expected Connect, got {err:?}"
1637        );
1638    }
1639
1640    /// A current-thread runtime is enough: `spawn_blocking` uses the separate
1641    /// blocking pool, and this crate's tokio is built without `macros` or
1642    /// `rt-multi-thread`, so `#[tokio::test]` is not available.
1643    #[cfg(feature = "client-async")]
1644    fn runtime() -> tokio::runtime::Runtime {
1645        tokio::runtime::Builder::new_current_thread()
1646            .build()
1647            .expect("current-thread runtime")
1648    }
1649
1650    /// The async path dials the backend and the socket it yields is live.
1651    ///
1652    /// Same assertion as the blocking test and for the same reason: returning
1653    /// the broker stream would also be `Ok`. This additionally proves the
1654    /// `spawn_blocking` hop preserves the connection — a socket that did not
1655    /// survive being moved across threads would fail here and nowhere else.
1656    #[cfg(feature = "client-async")]
1657    #[test]
1658    fn the_async_dial_reaches_the_backend() {
1659        let (_bdir, broker_path) = temp_endpoint("abroker");
1660        let broker_listener = ListenerOptions::new()
1661            .name(socket_name(&broker_path))
1662            .create_sync()
1663            .expect("bind broker");
1664        let broker_side = Stream::connect(socket_name(&broker_path)).expect("dial broker");
1665        let _broker_accepted = broker_listener.accept().expect("accept broker");
1666
1667        let (_kdir, backend_path) = temp_endpoint("abackend");
1668        let backend_listener = ListenerOptions::new()
1669            .name(socket_name(&backend_path))
1670            .create_sync()
1671            .expect("bind backend");
1672
1673        let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
1674        std::thread::spawn(move || {
1675            let _ = accepted_tx.send(backend_listener.accept());
1676        });
1677
1678        let session = AsyncClientSession {
1679            inner: session_with(broker_side, &backend_path),
1680        };
1681        let mut data = runtime()
1682            .block_on(session.connect_backend())
1683            .expect("async dial");
1684
1685        let mut served = accepted_rx
1686            .recv_timeout(std::time::Duration::from_secs(10))
1687            .expect("nothing connected to the backend within 10s")
1688            .expect("backend accept");
1689        data.write_all(b"pong").expect("write");
1690        data.flush().expect("flush");
1691        let mut got = [0u8; 4];
1692        served.read_exact(&mut got).expect("read");
1693        assert_eq!(&got, b"pong", "bytes did not reach the backend");
1694    }
1695
1696    /// A dial failure stays a dial failure across the runtime hop.
1697    ///
1698    /// The hazard the error type exists for: wrapping the blocking call in
1699    /// `spawn_blocking` introduces a second failure mode (the worker not
1700    /// reporting back), and it would be easy to collapse both into one
1701    /// variant. A caller that cannot tell "the backend refused" from "the
1702    /// runtime went away" cannot decide whether retrying is meaningful.
1703    #[cfg(feature = "client-async")]
1704    #[test]
1705    fn a_dial_failure_is_not_reported_as_a_runtime_failure() {
1706        let (_bdir, broker_path) = temp_endpoint("abroker2");
1707        let broker_listener = ListenerOptions::new()
1708            .name(socket_name(&broker_path))
1709            .create_sync()
1710            .expect("bind broker");
1711        let broker_side = Stream::connect(socket_name(&broker_path)).expect("dial broker");
1712        let _broker_accepted = broker_listener.accept().expect("accept broker");
1713
1714        let (_kdir, dead_path) = temp_endpoint("anobody");
1715        let session = AsyncClientSession {
1716            inner: session_with(broker_side, &dead_path),
1717        };
1718        let err = runtime()
1719            .block_on(session.connect_backend())
1720            .expect_err("nothing is listening there");
1721        assert!(
1722            matches!(err, AsyncConnectError::Dial(BackendDialError::Connect(_))),
1723            "expected Dial(Connect), got {err:?}"
1724        );
1725    }
1726
1727    /// An empty backend pipe keeps its identity through the async path too.
1728    #[cfg(feature = "client-async")]
1729    #[test]
1730    fn an_empty_backend_pipe_survives_the_async_hop() {
1731        let (_dir, path) = temp_endpoint("aempty");
1732        let listener = ListenerOptions::new()
1733            .name(socket_name(&path))
1734            .create_sync()
1735            .expect("bind");
1736        let broker_side = Stream::connect(socket_name(&path)).expect("dial");
1737        let _accepted = listener.accept().expect("accept");
1738
1739        let session = AsyncClientSession {
1740            inner: session_with(broker_side, ""),
1741        };
1742        let err = runtime()
1743            .block_on(session.connect_backend())
1744            .expect_err("an empty pipe must not be dialed");
1745        assert!(
1746            matches!(
1747                err,
1748                AsyncConnectError::Dial(BackendDialError::EmptyBackendPipe)
1749            ),
1750            "expected Dial(EmptyBackendPipe), got {err:?}"
1751        );
1752    }
1753}