Skip to main content

hatchery_c2_client/
reconnect.rs

1//! Reconnect-with-backoff supervisor for a `gate4agent-c2` control
2//! connection.
3//!
4//! `connect_local` (`runtime.rs`) connects exactly once: if the physical
5//! connection dies, every caller holding its `C2ControlHandle`/
6//! `C2EventReceiver` is permanently stuck with `C2ControlError::Closed`
7//! forever after, with no signal telling them to reconnect. That is fine
8//! for a short-lived caller (a CLI command, an E2E test) but wrong for a
9//! long-lived daemon that must keep serving requests across a relay
10//! restart.
11//!
12//! This module adds a second, additive entry point,
13//! [`connect_local_reconnecting`], that performs the identical first
14//! connection `connect_local` does (so a dead endpoint at boot still fails
15//! fast, unchanged), then hands the connection to a background supervisor
16//! task that keeps it alive: on loss it republishes a `Reconnecting` link
17//! state, fails in-flight and new requests fast (`C2ControlError::Closed`,
18//! immediately, not after a timeout), and reconnects with an escalating
19//! backoff (parking a hard, unfixable-by-retrying failure such as a bad
20//! token at a long fixed interval instead of hammering the relay).
21//!
22//! The returned [`C2ReconnectingHandle`]/[`C2ReconnectingEventReceiver`]/
23//! topology `watch::Receiver` are bound to the supervisor, not to any one
24//! physical connection: a reconnect is invisible to a caller already
25//! holding them -- they never close, and events/topology keep flowing from
26//! whichever physical connection is live underneath.
27
28use crate::runtime::{
29    connect_local, connect_tunnel_tcp, C2ControlError, C2ControlHandle, C2EventReceiver,
30    C2PendingRequest, EVENT_CAPACITY, HARNESS_MCP_EVENT_CAPACITY,
31};
32use std::net::SocketAddr;
33use hatchery_core::{BrowserClientCommand, BrowserStreamEvent, C2Topology, NodeRequest, NodeRoute, RoutedNodeEvent, RoutedNodeResponse};
34use std::fmt;
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37use tokio::sync::{mpsc, watch};
38
39/// Backoff durations for a transient reconnect failure, indexed by
40/// `failures - 1` (`failures` starts at 1 on the first failed attempt).
41/// Same VALUES as `gate4agent-c2/src/runtime.rs`'s
42/// `C2Timings::default().transient_backoffs` -- `hatchery-c2-client`
43/// cannot depend on that type (the dependency direction is the other way),
44/// so this module defines its own constant with the same numbers by
45/// deliberate choice, not the same type.
46const RECONNECT_TRANSIENT_BACKOFFS: [Duration; 5] = [
47    Duration::from_millis(500),
48    Duration::from_secs(1),
49    Duration::from_secs(2),
50    Duration::from_secs(4),
51    Duration::from_secs(8),
52];
53
54/// Backoff for a failure retrying with the same token/endpoint cannot fix
55/// (a bad credential, a protocol mismatch), and for any transient failure
56/// past `RECONNECT_TRANSIENT_BACKOFFS`'s last step.
57const RECONNECT_PARKED_BACKOFF: Duration = Duration::from_secs(30);
58
59/// Hard cap on the number of physical send attempts
60/// [`C2ReconnectingHandle::request_until`] makes for one logical call,
61/// independent of the caller's own budget -- a link that keeps flapping
62/// back just long enough to fail again must not turn a generous budget
63/// into an unbounded retry storm.
64const REQUEST_UNTIL_MAX_ATTEMPTS: u32 = 3;
65
66/// Whether a reconnect attempt failed in a way that retrying with the same
67/// token/endpoint cannot fix.
68fn is_hard_reconnect_failure(error: &C2ControlError) -> bool {
69    matches!(
70        error,
71        C2ControlError::InvalidEndpoint
72            | C2ControlError::InvalidTunnelControl
73            | C2ControlError::InvalidToken
74            | C2ControlError::Authentication(_)
75            | C2ControlError::Protocol(_)
76    )
77}
78
79/// Pure backoff computation, isolated from `tokio::time::sleep` so it is
80/// independently testable without a runtime.
81fn reconnect_backoff(failures: usize, hard: bool) -> Duration {
82    if !hard {
83        if let Some(delay) = failures
84            .checked_sub(1)
85            .and_then(|index| RECONNECT_TRANSIENT_BACKOFFS.get(index))
86        {
87            return *delay;
88        }
89    }
90    RECONNECT_PARKED_BACKOFF
91}
92
93/// Whether the harness's own dedicated C2 control connection is live right
94/// now or being re-established after a loss.
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum C2LinkState {
97    Connected,
98    Reconnecting,
99}
100
101/// The C2 control token, retained for the reconnect supervisor's entire
102/// lifetime. Never `Clone`, never formatted with `{}` -- mirrors
103/// `HarnessOperatorCredential` (`hatchery-harness-api/src/lib.rs`) and
104/// `C2Client`'s own redacted `Debug` (`hatchery-c2-client/src/lib.rs`).
105struct ReconnectToken(String);
106
107impl fmt::Debug for ReconnectToken {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        formatter.write_str("ReconnectToken([REDACTED])")
110    }
111}
112
113/// Signals the reconnect supervisor task to stop pumping and reconnecting.
114/// Dropped once the last clone of the owning [`C2ReconnectingHandle`] is
115/// dropped.
116struct SupervisorShutdownGuard(watch::Sender<bool>);
117
118impl Drop for SupervisorShutdownGuard {
119    fn drop(&mut self) {
120        let _ = self.0.send(true);
121    }
122}
123
124/// Swappable, never-closing counterpart to `C2ControlHandle`. Every
125/// accessor clones or reads out of a `watch` cell a background supervisor
126/// mutates on reconnect; no caller ever holds a handle bound to one
127/// specific physical connection, so a relay restart never permanently
128/// breaks it.
129#[derive(Clone)]
130pub struct C2ReconnectingHandle {
131    live: watch::Receiver<Option<C2ControlHandle>>,
132    topology: watch::Receiver<Arc<C2Topology>>,
133    link_state: watch::Receiver<C2LinkState>,
134    _supervisor: Arc<SupervisorShutdownGuard>,
135}
136
137impl C2ReconnectingHandle {
138    /// Whether the underlying physical connection is live right now.
139    pub fn link_state(&self) -> C2LinkState {
140        *self.link_state.borrow()
141    }
142
143    /// A fresh receiver over the same link-state cell, for a caller that
144    /// wants to await transitions rather than poll.
145    pub fn link_state_receiver(&self) -> watch::Receiver<C2LinkState> {
146        self.link_state.clone()
147    }
148
149    pub fn current_topology(&self) -> Arc<C2Topology> {
150        Arc::clone(&self.topology.borrow())
151    }
152
153    pub fn subscribe_topology(&self) -> watch::Receiver<Arc<C2Topology>> {
154        self.topology.clone()
155    }
156
157    /// Validates and synchronously enqueues one typed request against
158    /// whichever physical connection is live right now. Returns
159    /// `Err(C2ControlError::Closed)` immediately -- never blocks or
160    /// waits for a reconnect -- while the link is down.
161    pub async fn browser_command(
162        &self,
163        cmd: BrowserClientCommand,
164    ) -> Result<(), C2ControlError> {
165        let control = self.live.borrow().clone().ok_or(C2ControlError::Closed)?;
166        control.browser_command(cmd).await
167    }
168
169    pub fn start_request(
170        &self,
171        route: NodeRoute,
172        request: NodeRequest,
173    ) -> Result<C2PendingRequest, C2ControlError> {
174        match &*self.live.borrow() {
175            Some(handle) => handle.start_request(route, request),
176            None => Err(C2ControlError::Closed),
177        }
178    }
179
180    pub async fn request(
181        &self,
182        route: NodeRoute,
183        request: NodeRequest,
184    ) -> Result<RoutedNodeResponse, C2ControlError> {
185        self.request_with_correlation(route, request, None).await
186    }
187
188    pub async fn request_with_correlation(
189        &self,
190        route: NodeRoute,
191        request: NodeRequest,
192        correlation_id: Option<String>,
193    ) -> Result<RoutedNodeResponse, C2ControlError> {
194        let handle = self.live.borrow().clone();
195        match handle {
196            Some(handle) => handle.request_with_correlation(route, request, correlation_id).await,
197            None => Err(C2ControlError::Closed),
198        }
199    }
200
201    /// [`Self::request`], but for a caller willing to wait out a physical
202    /// reconnect: on `Err(C2ControlError::ConnectionLost { .. })` or
203    /// `Err(C2ControlError::Closed)` for a request
204    /// [`NodeRequest::is_replay_safe`] admits, awaits the link-state watch
205    /// ([`Self::link_state_receiver`]) turning [`C2LinkState::Connected`]
206    /// again -- bounded by whatever of `budget` is left -- then resends,
207    /// up to [`REQUEST_UNTIL_MAX_ATTEMPTS`] physical attempts total for
208    /// this one logical call. A request `is_replay_safe` refuses, or any
209    /// failure other than those two, returns on the very first attempt
210    /// exactly like `request` does -- this method changes nothing about
211    /// `request`'s own behaviour or about `control_owner`'s pending-request
212    /// failure handling; the retry lives here, one layer up.
213    ///
214    /// `budget == Duration::ZERO` degenerates to exactly one attempt (the
215    /// first elapsed-budget check trips before any wait), so a caller that
216    /// wants `request`'s existing all-or-nothing semantics can pass that
217    /// instead of calling `request` directly.
218    pub async fn request_until(
219        &self,
220        route: NodeRoute,
221        request: NodeRequest,
222        budget: Duration,
223    ) -> Result<RoutedNodeResponse, C2ControlError> {
224        let deadline = Instant::now() + budget;
225        let replay_safe = request.is_replay_safe();
226        let mut link_state = self.link_state.clone();
227        let mut attempts: u32 = 0;
228        loop {
229            attempts += 1;
230            let result = self.request(route.clone(), request.clone()).await;
231            let retryable = matches!(
232                result,
233                Err(C2ControlError::ConnectionLost { .. }) | Err(C2ControlError::Closed)
234            );
235            if !retryable || !replay_safe || attempts >= REQUEST_UNTIL_MAX_ATTEMPTS {
236                return result;
237            }
238            let now = Instant::now();
239            if now >= deadline {
240                return result;
241            }
242            if tokio::time::timeout(deadline - now, wait_for_link_connected(&mut link_state))
243                .await
244                .is_err()
245            {
246                return result;
247            }
248        }
249    }
250}
251
252/// Awaits the link-state watch reporting [`C2LinkState::Connected`],
253/// returning immediately if it already does. Only ever raced against a
254/// caller-supplied timeout ([`C2ReconnectingHandle::request_until`]) -- a
255/// dropped supervisor (the sender side gone) also returns immediately,
256/// since a frozen `watch::Receiver` cannot change further and the next
257/// attempt in the caller's loop will just observe the same failure again.
258async fn wait_for_link_connected(link_state: &mut watch::Receiver<C2LinkState>) {
259    while *link_state.borrow() != C2LinkState::Connected {
260        if link_state.changed().await.is_err() {
261            return;
262        }
263    }
264}
265
266/// The event stream for a reconnecting connection. Backed by two bridge
267/// channels created once, before the first physical connection, that only
268/// close when the supervisor itself terminates -- a reconnect is invisible
269/// to it. The two channels mirror the split `C2EventReceiver` already
270/// carries per physical connection (regular events vs. harness_mcp
271/// read-proxy events), so a caller that wants harness_mcp events served
272/// ahead of a regular-event backlog can drain them independently instead
273/// of going through the merged `recv()`.
274pub struct C2ReconnectingEventReceiver {
275    inner: mpsc::Receiver<RoutedNodeEvent>,
276    harness_mcp: mpsc::Receiver<RoutedNodeEvent>,
277    browser: mpsc::Receiver<BrowserStreamEvent>,
278}
279
280impl C2ReconnectingEventReceiver {
281    /// Returns every event from both channels, harness_mcp-first when both
282    /// are ready -- the exact merge algorithm `C2EventReceiver::recv`
283    /// already implements per physical connection, moved one layer up to
284    /// this bridge's own two channels. Existing callers that only ever call
285    /// this method see the identical observable behavior as before the
286    /// split: same events, same relative order.
287    pub async fn recv(&mut self) -> Option<RoutedNodeEvent> {
288        loop {
289            if self.harness_mcp.is_closed() && self.harness_mcp.is_empty() {
290                return self.inner.recv().await;
291            }
292            if self.inner.is_closed() && self.inner.is_empty() {
293                return self.harness_mcp.recv().await;
294            }
295            tokio::select! {
296                biased;
297                event = self.harness_mcp.recv() => {
298                    if event.is_some() { return event; }
299                }
300                event = self.inner.recv() => {
301                    if event.is_some() { return event; }
302                }
303            }
304        }
305    }
306
307    /// Drains only the regular-event channel, leaving harness_mcp events
308    /// untouched.
309    pub async fn recv_browser(&mut self) -> Option<BrowserStreamEvent> {
310        self.browser.recv().await
311    }
312
313    pub async fn recv_regular(&mut self) -> Option<RoutedNodeEvent> {
314        self.inner.recv().await
315    }
316
317    /// Drains only the harness_mcp channel, leaving regular events
318    /// untouched.
319    pub async fn recv_harness_mcp(&mut self) -> Option<RoutedNodeEvent> {
320        self.harness_mcp.recv().await
321    }
322
323    /// Splits into two independent mutable borrows, one per channel, so a
324    /// caller can race both in a single `tokio::select!` (the harness
325    /// runtime's own main loop, prioritizing `HarnessMcpReadCall` ahead of
326    /// its regular-event backlog). Two separate `recv_regular`/
327    /// `recv_harness_mcp` calls cannot be used together in one `select!`:
328    /// each is an `&mut self` async method, so the future each returns
329    /// borrows the whole receiver for its lifetime, and the borrow checker
330    /// rejects two such futures coexisting even though they touch disjoint
331    /// fields. Splitting once, up front, hands out two genuinely disjoint
332    /// borrows instead.
333    pub fn split_mut(
334        &mut self,
335    ) -> (&mut mpsc::Receiver<RoutedNodeEvent>, &mut mpsc::Receiver<RoutedNodeEvent>) {
336        (&mut self.inner, &mut self.harness_mcp)
337    }
338
339    pub fn split_all(
340        &mut self,
341    ) -> (
342        &mut mpsc::Receiver<RoutedNodeEvent>,
343        &mut mpsc::Receiver<RoutedNodeEvent>,
344        &mut mpsc::Receiver<BrowserStreamEvent>,
345    ) {
346        (&mut self.inner, &mut self.harness_mcp, &mut self.browser)
347    }
348}
349
350/// The long-lived channels the supervisor publishes into. Held as one
351/// struct (rather than five function parameters) purely for readability.
352struct SupervisorChannels {
353    live: watch::Sender<Option<C2ControlHandle>>,
354    topology: watch::Sender<Arc<C2Topology>>,
355    link_state: watch::Sender<C2LinkState>,
356    events: mpsc::Sender<RoutedNodeEvent>,
357    harness_mcp_events: mpsc::Sender<RoutedNodeEvent>,
358    shutdown: watch::Receiver<bool>,
359    browser: mpsc::Sender<BrowserStreamEvent>,
360}
361
362/// Outcome of pumping one physical connection to completion.
363enum ConnectionEnded {
364    /// The connection's event stream or topology watch closed -- the link
365    /// died and must be re-established.
366    Lost,
367    /// The supervisor was told to stop.
368    ShuttingDown,
369    /// The caller-held event receiver was dropped; nothing is left to
370    /// serve, so the whole supervisor should stop.
371    ConsumerGone,
372}
373
374/// Forwards events and topology changes from one physical connection into
375/// the long-lived bridge channels until that connection dies, the
376/// supervisor is told to shut down, or the caller's event receiver is
377/// gone. Every `.await` point races the shutdown signal.
378async fn pump_one_connection(
379    events: &mut C2EventReceiver,
380    topology: &mut watch::Receiver<Arc<C2Topology>>,
381    events_tx: &mpsc::Sender<RoutedNodeEvent>,
382    harness_mcp_events_tx: &mpsc::Sender<RoutedNodeEvent>,
383    browser_tx: &mpsc::Sender<BrowserStreamEvent>,
384    topology_tx: &watch::Sender<Arc<C2Topology>>,
385    shutdown: &mut watch::Receiver<bool>,
386) -> ConnectionEnded {
387    let (regular_events, harness_mcp_events, browser_events) = events.split_mut();
388    loop {
389        tokio::select! {
390            biased;
391            _ = shutdown.changed() => return ConnectionEnded::ShuttingDown,
392            event = harness_mcp_events.recv() => {
393                let Some(event) = event else { return ConnectionEnded::Lost; };
394                tokio::select! {
395                    biased;
396                    _ = shutdown.changed() => return ConnectionEnded::ShuttingDown,
397                    sent = harness_mcp_events_tx.send(event) => {
398                        if sent.is_err() { return ConnectionEnded::ConsumerGone; }
399                    }
400                }
401            }
402            event = regular_events.recv() => {
403                let Some(event) = event else { return ConnectionEnded::Lost; };
404                tokio::select! {
405                    biased;
406                    _ = shutdown.changed() => return ConnectionEnded::ShuttingDown,
407                    sent = events_tx.send(event) => {
408                        if sent.is_err() { return ConnectionEnded::ConsumerGone; }
409                    }
410                }
411            }
412            event = browser_events.recv() => {
413                let Some(event) = event else { return ConnectionEnded::Lost; };
414                tokio::select! {
415                    biased;
416                    _ = shutdown.changed() => return ConnectionEnded::ShuttingDown,
417                    sent = browser_tx.send(event) => {
418                        if sent.is_err() { return ConnectionEnded::ConsumerGone; }
419                    }
420                }
421            }
422            changed = topology.changed() => {
423                if changed.is_err() { return ConnectionEnded::Lost; }
424                let next = topology.borrow().clone();
425                topology_tx.send_replace(next);
426            }
427        }
428    }
429}
430
431/// Reconnects with an escalating backoff until a connection succeeds or
432/// shutdown is signaled. Returns `None` only on shutdown.
433async fn reconnect_with_backoff(
434    dial: &ControlDial,
435    token: &ReconnectToken,
436    shutdown: &mut watch::Receiver<bool>,
437) -> Option<(C2ControlHandle, C2EventReceiver)> {
438    let endpoint = dial.label();
439    let mut failures: usize = 0;
440    loop {
441        match dial.connect(&token.0).await {
442            Ok(connection) => return Some(connection),
443            Err(error) => {
444                failures = failures.saturating_add(1);
445                let hard = is_hard_reconnect_failure(&error);
446                tracing::debug!(
447                    endpoint = %endpoint,
448                    failures,
449                    hard,
450                    error = %error,
451                    "C2 reconnect attempt failed",
452                );
453                let delay = reconnect_backoff(failures, hard);
454                tokio::select! {
455                    biased;
456                    _ = shutdown.changed() => return None,
457                    () = tokio::time::sleep(delay) => {}
458                }
459            }
460        }
461    }
462}
463
464/// One iteration = one physical connection's lifetime: publish it as live,
465/// pump it until it dies (or shutdown), publish it as gone, then reconnect
466/// with backoff and repeat.
467async fn reconnect_supervisor(
468    dial: ControlDial,
469    token: ReconnectToken,
470    mut channels: SupervisorChannels,
471    first_control: C2ControlHandle,
472    first_events: C2EventReceiver,
473) {
474    let endpoint = dial.label();
475    let mut pending = Some((first_control, first_events));
476    let mut was_reconnecting = false;
477    loop {
478        let (control, mut events) = match pending.take() {
479            Some(connection) => connection,
480            None => match reconnect_with_backoff(&dial, &token, &mut channels.shutdown).await {
481                Some(connection) => connection,
482                None => return,
483            },
484        };
485        if was_reconnecting {
486            tracing::info!(endpoint = %endpoint, "C2 relay reconnected");
487        }
488        was_reconnecting = false;
489
490        let mut topology = control.subscribe_topology();
491        let current_topology = control.current_topology();
492        channels.live.send_replace(Some(control));
493        channels.topology.send_replace(current_topology);
494        channels.link_state.send_replace(C2LinkState::Connected);
495
496        let outcome = pump_one_connection(
497            &mut events,
498            &mut topology,
499            &channels.events,
500            &channels.harness_mcp_events,
501            &channels.browser,
502            &channels.topology,
503            &mut channels.shutdown,
504        )
505        .await;
506
507        match outcome {
508            ConnectionEnded::ShuttingDown | ConnectionEnded::ConsumerGone => return,
509            ConnectionEnded::Lost => {
510                channels.live.send_replace(None);
511                channels.link_state.send_replace(C2LinkState::Reconnecting);
512                if !was_reconnecting {
513                    tracing::warn!(endpoint = %endpoint, "C2 relay connection lost, reconnecting");
514                }
515                was_reconnecting = true;
516            }
517        }
518    }
519}
520
521/// Connects to a local `gate4agent-c2` control endpoint the same way
522/// `connect_local` does -- a dead endpoint at boot still fails fast with
523/// the identical error, unchanged -- then hands the connection to a
524/// background supervisor that keeps it alive for as long as the returned
525/// handle (or its event receiver) is held.
526pub async fn connect_local_reconnecting(
527    endpoint: impl Into<String>,
528    token: impl Into<String>,
529) -> Result<(C2ReconnectingHandle, C2ReconnectingEventReceiver), C2ControlError> {
530    connect_reconnecting(ControlDial::Local(endpoint.into()), ReconnectToken(token.into())).await
531}
532
533/// Same supervisor as [`connect_local_reconnecting`], dialing the tunnel TCP
534/// control port instead of the unix socket. Unix reconnect is unchanged.
535pub async fn connect_tunnel_reconnecting(
536    address: SocketAddr,
537    token: impl Into<String>,
538) -> Result<(C2ReconnectingHandle, C2ReconnectingEventReceiver), C2ControlError> {
539    connect_reconnecting(ControlDial::Tunnel(address), ReconnectToken(token.into())).await
540}
541
542#[derive(Clone)]
543enum ControlDial {
544    Local(String),
545    Tunnel(SocketAddr),
546}
547
548impl ControlDial {
549    fn label(&self) -> String {
550        match self {
551            Self::Local(endpoint) => endpoint.clone(),
552            Self::Tunnel(address) => address.to_string(),
553        }
554    }
555
556    async fn connect(
557        &self,
558        token: &str,
559    ) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError> {
560        match self {
561            Self::Local(endpoint) => connect_local(endpoint, token).await,
562            Self::Tunnel(address) => connect_tunnel_tcp(*address, token).await,
563        }
564    }
565}
566
567async fn connect_reconnecting(
568    dial: ControlDial,
569    token: ReconnectToken,
570) -> Result<(C2ReconnectingHandle, C2ReconnectingEventReceiver), C2ControlError> {
571    let (control, events) = dial.connect(&token.0).await?;
572
573    let initial_topology = control.current_topology();
574    let seed_control = control.clone();
575    let (live_tx, live_rx) = watch::channel(Some(seed_control));
576    let (topology_tx, topology_rx) = watch::channel(initial_topology);
577    let (link_state_tx, link_state_rx) = watch::channel(C2LinkState::Connected);
578    let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
579    let (harness_mcp_events_tx, harness_mcp_events_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
580    let (browser_tx, browser_rx) = mpsc::channel::<BrowserStreamEvent>(64);
581    let (shutdown_tx, shutdown_rx) = watch::channel(false);
582
583    tokio::spawn(reconnect_supervisor(
584        dial,
585        token,
586        SupervisorChannels {
587            live: live_tx,
588            topology: topology_tx,
589            link_state: link_state_tx,
590            events: events_tx,
591            harness_mcp_events: harness_mcp_events_tx,
592            browser: browser_tx,
593            shutdown: shutdown_rx,
594        },
595        control,
596        events,
597    ));
598
599    Ok((
600        C2ReconnectingHandle {
601            live: live_rx,
602            topology: topology_rx,
603            link_state: link_state_rx,
604            _supervisor: Arc::new(SupervisorShutdownGuard(shutdown_tx)),
605        },
606        C2ReconnectingEventReceiver { inner: events_rx, harness_mcp: harness_mcp_events_rx, browser: browser_rx },
607    ))
608}
609
610#[cfg(all(test, windows))]
611mod tests {
612    use super::*;
613    use crate::runtime::{c2_proof, client_compatibility_offer};
614    use hatchery_core::{
615        AgentId, ArchitectureId, C2AuthDirection, C2ClientFrame, C2Hello, C2NodeEvent,
616        C2NodeResponse, C2NodeSnapshot, C2ReplyEnvelope, C2RelayRoute, C2RequestEnvelope,
617        C2ServerChallenge, C2ServerFrame, C2TopologyNode, HostDescriptor,
618        NegotiatedC2ControlCompatibility, NodeCursor, NodeId, NodeTransportState,
619        OperatingSystemId, PathEncoding, PathSemantics, PathStyle, StatusResponse,
620        BUILD_STAMP, C2_AUTH_NONCE_BYTES, MAX_C2_AUTH_FRAME_BYTES,
621        MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES, MAX_C2_SERVER_FRAME_BYTES,
622    };
623    use gate4agent_node_protocol::{
624        read_json_frame_limited_body_timeout, write_json_frame_limited, NodeIncarnationId,
625    };
626    use std::collections::BTreeMap;
627    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
628    use std::time::{SystemTime, UNIX_EPOCH};
629    use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
630    use tokio::task::JoinHandle;
631
632    fn unique_control_endpoint() -> String {
633        static NEXT: AtomicU64 = AtomicU64::new(1);
634        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
635        format!(
636            r"\\.\pipe\hatchery-c2-client-reconnect-{}-{now}-{}",
637            std::process::id(),
638            NEXT.fetch_add(1, Ordering::Relaxed),
639        )
640    }
641
642    fn empty_status() -> StatusResponse {
643        StatusResponse {
644            api_version: 1,
645            ready: true,
646            observed_at_unix_ms: 0,
647            nodes: BTreeMap::new(),
648        }
649    }
650
651    fn test_route() -> NodeRoute {
652        NodeRoute {
653            node_id: NodeId::new("node-a").unwrap(),
654            expected_incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
655        }
656    }
657
658    fn routed_event(node_id: &NodeId, sequence: u64) -> RoutedNodeEvent {
659        RoutedNodeEvent {
660            node_id: node_id.clone(),
661            cursor: NodeCursor { incarnation_id: NodeIncarnationId::from_bytes([7; 16]), sequence },
662            event: C2NodeEvent::ResyncRequired { oldest_available_sequence: sequence },
663        }
664    }
665
666    fn now_unix_ms() -> u64 {
667        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis().try_into().unwrap()
668    }
669
670    /// A `HarnessMcpReadCall` event whose deadline is far enough in the
671    /// future (2.5s, under `MAX_HARNESS_MCP_CALL_DEADLINE_MS = 3s`) to
672    /// still pass `harness_mcp_contract_is_valid_at` by the time it lands
673    /// on the client, so `control_owner` classifies it onto the harness_mcp
674    /// channel rather than dropping it as an expired contract.
675    fn harness_mcp_routed_event(node_id: &NodeId, call_index: usize) -> RoutedNodeEvent {
676        RoutedNodeEvent {
677            node_id: node_id.clone(),
678            cursor: NodeCursor { incarnation_id: NodeIncarnationId::from_bytes([7; 16]), sequence: 41 },
679            event: C2NodeEvent::HarnessMcpReadCall {
680                reservation_id: gate4agent_node_protocol::HarnessMcpReservationId::new(
681                    format!("hmcpres_{:024x}", 1),
682                ).unwrap(),
683                activation_digest: gate4agent_node_protocol::HarnessMcpActivationDigest::new(
684                    format!("sha256:{}", "b".repeat(64)),
685                ).unwrap(),
686                record_id: gate4agent_node_protocol::SessionRecordId::new("session-001").unwrap(),
687                session: gate4agent_node_protocol::SessionAddress {
688                    workspace_id: gate4agent_node_protocol::WorkspaceId::new("primary").unwrap(),
689                    session: gate4agent_node_protocol::SessionKey {
690                        instance_id: gate4agent_types::AgentInstanceId(7),
691                        generation: gate4agent_types::SessionGeneration(2),
692                    },
693                },
694                call_id: gate4agent_node_protocol::HarnessMcpCallId::new(
695                    format!("hmcpcall_{call_index:024x}"),
696                ).unwrap(),
697                request: gate4agent_node_protocol::HarnessMcpOpaquePayloadV1 {
698                    content_type:
699                        gate4agent_node_protocol::HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
700                    body: br#"{"kind":"context-get"}"#.to_vec(),
701                },
702                deadline_unix_ms: now_unix_ms() + 2_500,
703            },
704        }
705    }
706
707    fn topology_with_one_node(node_id: &NodeId) -> C2Topology {
708        C2Topology {
709            nodes: vec![C2TopologyNode {
710                node_id: node_id.clone(),
711                endpoint: "test-endpoint".to_owned(),
712                relay_route: C2RelayRoute::default(),
713                transport: NodeTransportState::Online,
714                current_incarnation_id: None,
715                machine: None,
716                provider_contracts: Vec::new(),
717                provider_adapter_contracts: Vec::new(),
718                provider_runtime_statuses: Default::default(),
719            }],
720        }
721    }
722
723    fn canned_node_snapshot(node_id: &NodeId, tag: &str) -> C2NodeSnapshot {
724        C2NodeSnapshot {
725            node_id: node_id.clone(),
726            enabled_providers: vec![AgentId::new(tag).unwrap()],
727            provider_runtime_statuses: Default::default(),
728            workspaces: Vec::new(),
729            session_records: Vec::new(),
730            agent_progress: Vec::new(),
731            managed_worktrees: Vec::new(),
732            launch_inventory: None,
733            machine: None,
734        }
735    }
736
737    fn canned_reply(envelope: &C2RequestEnvelope, tag: &str) -> C2ReplyEnvelope {
738        assert!(matches!(envelope.request.request, NodeRequest::Snapshot));
739        C2ReplyEnvelope {
740            request_id: envelope.request_id,
741            result: Ok(RoutedNodeResponse {
742                node_id: envelope.request.route.node_id.clone(),
743                incarnation_id: envelope.request.route.expected_incarnation_id,
744                response: Ok(C2NodeResponse::Snapshot {
745                    event_sequence: 0,
746                    controller: None,
747                    snapshot: canned_node_snapshot(&envelope.request.route.node_id, tag),
748                }),
749            }),
750            correlation_id: envelope.correlation_id.clone(),
751        }
752    }
753
754    fn assert_snapshot_tag(response: &RoutedNodeResponse, tag: &str) {
755        let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &response.response else {
756            panic!("expected a Snapshot response, got {:?}", response.response);
757        };
758        assert_eq!(snapshot.enabled_providers[0].as_str(), tag);
759    }
760
761    /// Reads the client's Hello, sends a Challenge (with a deliberately
762    /// wrong proof when `corrupt_proof` is set), and -- unless the proof
763    /// was corrupted, in which case the client aborts right here -- reads
764    /// the client's Authenticate frame. Returns the negotiated
765    /// compatibility the caller must echo back in the final Hello.
766    async fn accept_handshake(
767        pipe: &mut NamedPipeServer,
768        token: &str,
769        corrupt_proof: bool,
770    ) -> Option<NegotiatedC2ControlCompatibility> {
771        let frame = read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
772            pipe,
773            MAX_C2_AUTH_FRAME_BYTES,
774            Duration::from_secs(5),
775        )
776        .await
777        .unwrap();
778        let C2ClientFrame::Hello(hello) = frame else { panic!("client did not send hello") };
779        let offer = client_compatibility_offer().unwrap();
780        let selected = NegotiatedC2ControlCompatibility {
781            build_stamp: offer.build_stamp.clone(),
782            capabilities: offer.capabilities.clone(),
783            host: HostDescriptor {
784                operating_system: OperatingSystemId::new("windows").unwrap(),
785                architecture: ArchitectureId::new("x86_64").unwrap(),
786            },
787            path_semantics: PathSemantics { style: PathStyle::Windows, encoding: PathEncoding::Utf8 },
788        };
789        let server_nonce = [7_u8; C2_AUTH_NONCE_BYTES];
790        let mut server_proof = c2_proof(
791            token,
792            C2AuthDirection::Server,
793            &hello.client_nonce,
794            &server_nonce,
795            Some((&offer, &selected)),
796        )
797        .unwrap();
798        if corrupt_proof {
799            server_proof[0] ^= 0xFF;
800        }
801        write_json_frame_limited(
802            pipe,
803            &C2ServerFrame::Challenge(C2ServerChallenge {
804                build_stamp: BUILD_STAMP.to_owned(),
805                server_nonce,
806                server_proof,
807                compatibility: Some(selected.clone()),
808            }),
809            MAX_C2_AUTH_FRAME_BYTES,
810        )
811        .await
812        .unwrap();
813        if corrupt_proof {
814            return None;
815        }
816        let _authenticate = read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
817            pipe,
818            MAX_C2_AUTH_FRAME_BYTES,
819            Duration::from_secs(5),
820        )
821        .await
822        .unwrap();
823        Some(selected)
824    }
825
826    /// A fully-authenticated fake C2 server: answers every
827    /// `NodeRequest::Snapshot` with a canned reply tagged `tag` (so a test
828    /// can prove which server instance actually answered a request), and
829    /// forwards any frame pushed on `to_client` to the wire in order.
830    ///
831    /// Runs entirely inside one spawned task, including the initial
832    /// `.connect()`/handshake -- `spawn_fake_server` itself returns
833    /// immediately (it is not `async` and is never awaited by the caller
834    /// before that), so a caller can spawn this and then start the CLIENT
835    /// side (`connect_local_reconnecting`) concurrently. Awaiting the
836    /// handshake to complete here first would deadlock: nothing would ever
837    /// drive the client side that this server is waiting to accept.
838    struct FakeServer {
839        to_client: mpsc::Sender<C2ServerFrame>,
840        task: JoinHandle<()>,
841    }
842
843    impl FakeServer {
844        /// Drops the pipe, simulating the relay dying mid-session, and
845        /// waits for the OS handle to be fully released so a new
846        /// `first_pipe_instance` server can bind the same endpoint next.
847        async fn kill(self) {
848            self.task.abort();
849            let _ = self.task.await;
850        }
851    }
852
853    fn spawn_fake_server(
854        endpoint: &str,
855        token: &str,
856        status: StatusResponse,
857        tag: &'static str,
858    ) -> FakeServer {
859        let pipe = ServerOptions::new().first_pipe_instance(true).create(endpoint).unwrap();
860        let token = token.to_owned();
861        let (to_client, mut to_client_rx) = mpsc::channel::<C2ServerFrame>(16);
862        let task = tokio::spawn(async move {
863            let mut pipe = pipe;
864            pipe.connect().await.unwrap();
865            let selected = accept_handshake(&mut pipe, &token, false).await.unwrap();
866            write_json_frame_limited(
867                &mut pipe,
868                &C2ServerFrame::Hello(C2Hello {
869                    build_stamp: BUILD_STAMP.to_owned(),
870                    connection_id: 1,
871                    status,
872                    compatibility: Some(selected),
873                }),
874                MAX_C2_HELLO_FRAME_BYTES,
875            )
876            .await
877            .unwrap();
878
879            let (mut reader, mut writer) = tokio::io::split(pipe);
880            loop {
881                tokio::select! {
882                    outgoing = to_client_rx.recv() => {
883                        let Some(frame) = outgoing else { return; };
884                        if write_json_frame_limited(&mut writer, &frame, MAX_C2_SERVER_FRAME_BYTES)
885                            .await
886                            .is_err()
887                        {
888                            return;
889                        }
890                    }
891                    incoming = read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
892                        &mut reader,
893                        MAX_C2_CLIENT_FRAME_BYTES,
894                        Duration::from_secs(60),
895                    ) => {
896                        match incoming {
897                            Ok(C2ClientFrame::Request(envelope)) => {
898                                let reply = C2ServerFrame::Reply(canned_reply(&envelope, tag));
899                                if write_json_frame_limited(&mut writer, &reply, MAX_C2_SERVER_FRAME_BYTES)
900                                    .await
901                                    .is_err()
902                                {
903                                    return;
904                                }
905                            }
906                            _ => return,
907                        }
908                    }
909                }
910            }
911        });
912        FakeServer { to_client, task }
913    }
914
915    /// One long-lived server, listening for the entire test: every
916    /// connection attempt that lands here (however many transient ones
917    /// happened before this existed, and every one after) authenticates
918    /// far enough to receive a corrupted Challenge -- which the client
919    /// rejects as `Authentication`, a hard reconnect failure. Every
920    /// connection AFTER that first one is only observed, not handshaken:
921    /// this fixture exists to measure WHEN a reconnect attempt lands
922    /// (cadence), not to re-prove the classification a second time, and a
923    /// bare accept avoids racing the client's own auth deadline under
924    /// paused time. A single persistent pipe instance (never re-created)
925    /// also avoids racing Windows's own pipe-name-reuse cleanup, unlike
926    /// creating a fresh `first_pipe_instance` server after each attempt
927    /// would.
928    fn spawn_looping_hard_failure_server(
929        endpoint: &str,
930        token: &str,
931    ) -> (JoinHandle<()>, watch::Receiver<usize>) {
932        let endpoint = endpoint.to_owned();
933        let token = token.to_owned();
934        let (count_tx, count_rx) = watch::channel(0_usize);
935        let task = tokio::spawn(async move {
936            let mut pipe = ServerOptions::new().first_pipe_instance(true).create(&endpoint).unwrap();
937            if pipe.connect().await.is_err() {
938                return;
939            }
940            accept_handshake(&mut pipe, &token, true).await;
941            let mut count = 1_usize;
942            count_tx.send_replace(count);
943            if pipe.disconnect().is_err() {
944                return;
945            }
946            loop {
947                if pipe.connect().await.is_err() {
948                    return;
949                }
950                count += 1;
951                count_tx.send_replace(count);
952                if pipe.disconnect().is_err() {
953                    return;
954                }
955            }
956        });
957        (task, count_rx)
958    }
959
960    async fn wait_for_link_state(receiver: &mut watch::Receiver<C2LinkState>, expected: C2LinkState) {
961        while *receiver.borrow() != expected {
962            receiver.changed().await.unwrap();
963        }
964    }
965
966    async fn wait_for_attempt_count(receiver: &mut watch::Receiver<usize>, expected: usize) {
967        while *receiver.borrow() < expected {
968            receiver.changed().await.unwrap();
969        }
970    }
971
972    /// `send_replace` always notifies, even when the republished value is
973    /// unchanged (e.g. the reconnect-time republish of an already-empty
974    /// topology) -- so a single `.changed()` call is not guaranteed to
975    /// land on the specific content a test is waiting for. Loops until the
976    /// held value actually matches, on the SAME pre-acquired receiver.
977    async fn wait_for_topology_node_count(
978        receiver: &mut watch::Receiver<Arc<C2Topology>>,
979        expected: usize,
980    ) {
981        while receiver.borrow().nodes.len() != expected {
982            receiver.changed().await.unwrap();
983        }
984    }
985
986    #[test]
987    fn reconnect_backoff_uses_the_transient_array_then_parks() {
988        assert_eq!(reconnect_backoff(1, false), RECONNECT_TRANSIENT_BACKOFFS[0]);
989        assert_eq!(reconnect_backoff(5, false), RECONNECT_TRANSIENT_BACKOFFS[4]);
990        assert_eq!(reconnect_backoff(6, false), RECONNECT_PARKED_BACKOFF);
991        assert_eq!(reconnect_backoff(1, true), RECONNECT_PARKED_BACKOFF);
992        assert_eq!(reconnect_backoff(0, false), RECONNECT_PARKED_BACKOFF);
993    }
994
995    #[tokio::test]
996    async fn reconnect_recovers_after_server_drops_the_pipe() {
997        let endpoint = unique_control_endpoint();
998        let token = "reconnect-token-1";
999        let server1 = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1000        let (handle, _events) =
1001            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1002        let mut link_state = handle.link_state_receiver();
1003
1004        let first = handle.request(test_route(), NodeRequest::Snapshot).await.unwrap();
1005        assert_snapshot_tag(&first, "server-1");
1006
1007        server1.kill().await;
1008        wait_for_link_state(&mut link_state, C2LinkState::Reconnecting).await;
1009
1010        let server2 = spawn_fake_server(&endpoint, token, empty_status(), "server-2");
1011        wait_for_link_state(&mut link_state, C2LinkState::Connected).await;
1012
1013        let second = handle.request(test_route(), NodeRequest::Snapshot).await.unwrap();
1014        assert_snapshot_tag(&second, "server-2");
1015
1016        server2.kill().await;
1017    }
1018
1019    #[tokio::test]
1020    async fn reconnect_keeps_the_original_event_and_topology_receivers_live() {
1021        let endpoint = unique_control_endpoint();
1022        let token = "reconnect-token-2";
1023        let server1 = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1024        let (handle, mut events) =
1025            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1026        let mut topology = handle.subscribe_topology();
1027        let mut link_state = handle.link_state_receiver();
1028
1029        server1.kill().await;
1030        wait_for_link_state(&mut link_state, C2LinkState::Reconnecting).await;
1031
1032        let server2 = spawn_fake_server(&endpoint, token, empty_status(), "server-2");
1033        wait_for_link_state(&mut link_state, C2LinkState::Connected).await;
1034
1035        let node_id = NodeId::new("node-a").unwrap();
1036        server2.to_client.send(C2ServerFrame::Event(routed_event(&node_id, 1))).await.unwrap();
1037        server2
1038            .to_client
1039            .send(C2ServerFrame::Topology(topology_with_one_node(&node_id)))
1040            .await
1041            .unwrap();
1042
1043        let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
1044            .await
1045            .expect("event must arrive on the receiver acquired before the outage")
1046            .expect("event stream must not have closed");
1047        assert_eq!(event.node_id, node_id);
1048
1049        tokio::time::timeout(Duration::from_secs(5), wait_for_topology_node_count(&mut topology, 1))
1050            .await
1051            .expect("topology change must arrive on the receiver acquired before the outage");
1052
1053        server2.kill().await;
1054    }
1055
1056    /// The bridge's split channels must stay genuinely independent, not
1057    /// merely `recv()`'s internal biased-merge order: pushes a regular
1058    /// event FIRST, then a harness_mcp event, both landing on the bridge
1059    /// before either is drained -- `recv_harness_mcp()` must still return
1060    /// the harness_mcp event without waiting on the regular one enqueued
1061    /// ahead of it, and `recv_regular()` must still return the regular
1062    /// event without ever seeing the harness_mcp one.
1063    #[tokio::test]
1064    async fn reconnecting_receiver_split_accessors_drain_only_their_own_channel() {
1065        let endpoint = unique_control_endpoint();
1066        let token = "reconnect-token-split-1";
1067        let server = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1068        let (_handle, mut events) =
1069            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1070
1071        let node_id = NodeId::new("node-a").unwrap();
1072        server.to_client.send(C2ServerFrame::Event(routed_event(&node_id, 1))).await.unwrap();
1073        server.to_client.send(C2ServerFrame::Event(harness_mcp_routed_event(&node_id, 1))).await.unwrap();
1074
1075        let mcp_event = tokio::time::timeout(Duration::from_secs(5), events.recv_harness_mcp())
1076            .await
1077            .expect("recv_harness_mcp must not block on the regular event queued ahead of it")
1078            .expect("harness_mcp channel must not have closed");
1079        assert!(matches!(mcp_event.event, C2NodeEvent::HarnessMcpReadCall { .. }));
1080
1081        let regular_event = tokio::time::timeout(Duration::from_secs(5), events.recv_regular())
1082            .await
1083            .expect("recv_regular must not block on the harness_mcp channel")
1084            .expect("regular channel must not have closed");
1085        assert!(matches!(regular_event.event, C2NodeEvent::ResyncRequired { .. }));
1086
1087        server.kill().await;
1088    }
1089
1090    /// Legacy callers that only ever call the merged `recv()` must see the
1091    /// exact same events, still harness-mcp-first when both are queued --
1092    /// the split must be invisible to them.
1093    #[tokio::test]
1094    async fn reconnecting_receiver_recv_still_merges_both_harness_mcp_first() {
1095        let endpoint = unique_control_endpoint();
1096        let token = "reconnect-token-split-2";
1097        let server = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1098        let (_handle, mut events) =
1099            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1100
1101        let node_id = NodeId::new("node-a").unwrap();
1102        server.to_client.send(C2ServerFrame::Event(routed_event(&node_id, 1))).await.unwrap();
1103        server.to_client.send(C2ServerFrame::Event(harness_mcp_routed_event(&node_id, 1))).await.unwrap();
1104
1105        // Both events must be sitting in their respective bridge channels
1106        // BEFORE `recv()` is called -- otherwise `select!`'s `biased`
1107        // ordering (which only matters when multiple arms are
1108        // simultaneously ready) is not actually exercised: the regular
1109        // event, sent first on the wire, is always classified and
1110        // forwarded strictly before the harness_mcp one that followed it,
1111        // so a `recv()` issued too early would just see the regular event
1112        // as the only one ready and return it -- not proof of anything.
1113        tokio::time::timeout(Duration::from_secs(5), async {
1114            while events.inner.len() != 1 || events.harness_mcp.len() != 1 {
1115                tokio::task::yield_now().await;
1116            }
1117        })
1118        .await
1119        .expect("both events must reach the bridge's own channels");
1120
1121        let first = tokio::time::timeout(Duration::from_secs(5), events.recv())
1122            .await
1123            .expect("recv must not hang")
1124            .expect("event stream must not have closed");
1125        assert!(matches!(first.event, C2NodeEvent::HarnessMcpReadCall { .. }));
1126
1127        let second = tokio::time::timeout(Duration::from_secs(5), events.recv())
1128            .await
1129            .expect("recv must not hang")
1130            .expect("event stream must not have closed");
1131        assert!(matches!(second.event, C2NodeEvent::ResyncRequired { .. }));
1132
1133        server.kill().await;
1134    }
1135
1136    #[tokio::test]
1137    async fn reconnect_requests_fail_fast_while_reconnecting() {
1138        let endpoint = unique_control_endpoint();
1139        let token = "reconnect-token-3";
1140        let server1 = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1141        let (handle, _events) =
1142            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1143        let mut link_state = handle.link_state_receiver();
1144
1145        server1.kill().await;
1146        wait_for_link_state(&mut link_state, C2LinkState::Reconnecting).await;
1147
1148        let outcome = tokio::time::timeout(
1149            Duration::from_millis(200),
1150            handle.request(test_route(), NodeRequest::Snapshot),
1151        )
1152        .await
1153        .expect("request must fail fast, not hang while reconnecting");
1154        assert!(matches!(outcome, Err(C2ControlError::Closed)));
1155
1156        assert!(matches!(
1157            handle.start_request(test_route(), NodeRequest::Snapshot),
1158            Err(C2ControlError::Closed)
1159        ));
1160    }
1161
1162    #[tokio::test(start_paused = true)]
1163    async fn reconnect_hard_failure_parks_without_hammering() {
1164        let endpoint = unique_control_endpoint();
1165        let token = "reconnect-token-4";
1166        let server1 = spawn_fake_server(&endpoint, token, empty_status(), "server-1");
1167        let (handle, _events) =
1168            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1169        let mut link_state = handle.link_state_receiver();
1170
1171        server1.kill().await;
1172        wait_for_link_state(&mut link_state, C2LinkState::Reconnecting).await;
1173
1174        // The reconnect loop's first attempt fires immediately (before any
1175        // backoff); this server is already listening by the time it can
1176        // possibly land, so it is the one that answers -- whether that is
1177        // attempt 1 or a later one is irrelevant here; paused time
1178        // auto-advances past any preceding transient backoff while this
1179        // bare `.await` is the only thing the runtime has left to do.
1180        let (_hard_server, mut attempts) = spawn_looping_hard_failure_server(&endpoint, token);
1181        wait_for_attempt_count(&mut attempts, 1).await;
1182
1183        // That attempt hit the hard-failure server and got
1184        // `Authentication`, so the supervisor is now parked for
1185        // `RECONNECT_PARKED_BACKOFF`, not the short transient array. Measure
1186        // the actual virtual-time gap to the next attempt -- rather than a
1187        // fixed `time::advance` window followed by a synchronous check --
1188        // because paused time's own auto-advance only guarantees the clock
1189        // reaches the next timer eventually, not that the resulting real
1190        // I/O (a fresh handshake) has already completed by some arbitrary
1191        // point partway through a manually bounded jump.
1192        let before = tokio::time::Instant::now();
1193        wait_for_attempt_count(&mut attempts, 2).await;
1194        let gap = tokio::time::Instant::now() - before;
1195        assert!(gap >= RECONNECT_PARKED_BACKOFF, "parked backoff must not fire early: gap was {gap:?}");
1196        // A generous sanity ceiling, not a tight bound: paused-time
1197        // auto-advance settling real I/O for the retry adds some slop on
1198        // top of the exact 30s deadline. What this actually rules out is
1199        // the supervisor stacking additional backoff on top of parking
1200        // (e.g. parking twice, or parking then also running the transient
1201        // array) -- either of which would clear this by a wide margin.
1202        assert!(
1203            gap < RECONNECT_PARKED_BACKOFF * 2,
1204            "parked backoff must fire close to {RECONNECT_PARKED_BACKOFF:?}, not stall further: gap was {gap:?}",
1205        );
1206    }
1207
1208    /// A fully-authenticated fake C2 server that drops the physical
1209    /// connection the instant the client's first `Request` frame arrives --
1210    /// never replies -- so the pending request fails with
1211    /// `C2ControlError::ConnectionLost`, exactly the failure mode the
1212    /// relay's own mid-flight disconnect produces
1213    /// (`control_owner`, `runtime.rs:3014-3024`). Returns an
1214    /// `AtomicUsize` the caller can read after the fact to prove how many
1215    /// physical `Request` frames this connection actually received --
1216    /// `request_until`'s own attempt cap and replay-safety gate are only
1217    /// provable by counting real sends, not just by timing.
1218    fn spawn_fake_server_drop_on_request(
1219        endpoint: &str,
1220        token: &str,
1221    ) -> (FakeServer, Arc<AtomicUsize>) {
1222        let pipe = ServerOptions::new().first_pipe_instance(true).create(endpoint).unwrap();
1223        let token = token.to_owned();
1224        let requests_seen = Arc::new(AtomicUsize::new(0));
1225        let requests_seen_in_task = Arc::clone(&requests_seen);
1226        let (to_client, _to_client_rx) = mpsc::channel::<C2ServerFrame>(16);
1227        let task = tokio::spawn(async move {
1228            let mut pipe = pipe;
1229            pipe.connect().await.unwrap();
1230            let Some(selected) = accept_handshake(&mut pipe, &token, false).await else { return; };
1231            write_json_frame_limited(
1232                &mut pipe,
1233                &C2ServerFrame::Hello(C2Hello {
1234                    build_stamp: BUILD_STAMP.to_owned(),
1235                    connection_id: 1,
1236                    status: empty_status(),
1237                    compatibility: Some(selected),
1238                }),
1239                MAX_C2_HELLO_FRAME_BYTES,
1240            )
1241            .await
1242            .unwrap();
1243            let (mut reader, _writer) = tokio::io::split(pipe);
1244            if read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
1245                &mut reader,
1246                MAX_C2_CLIENT_FRAME_BYTES,
1247                Duration::from_secs(60),
1248            )
1249            .await
1250            .is_ok()
1251            {
1252                requests_seen_in_task.fetch_add(1, Ordering::SeqCst);
1253            }
1254            // `reader`/`_writer`/`pipe` drop here, closing the physical
1255            // connection without ever sending a `Reply` -- the client's
1256            // pending request fails with `ConnectionLost`, not a clean
1257            // answer.
1258        });
1259        (FakeServer { to_client, task }, requests_seen)
1260    }
1261
1262    /// A replay-safe request (`NodeRequest::Snapshot`) whose first attempt
1263    /// fails with `ConnectionLost` (the fake server drops the connection
1264    /// the instant the request lands) must come back with the real answer
1265    /// once the link is reconnected -- `request_until` retries it against
1266    /// whichever physical connection the supervisor reconnects to, all
1267    /// within its own budget, without the caller ever seeing the
1268    /// intermediate failure.
1269    #[tokio::test]
1270    async fn request_until_survives_a_reconnect_for_a_replay_safe_request() {
1271        let endpoint = unique_control_endpoint();
1272        let token = "reconnect-token-request-until-1";
1273        let (server1, requests_seen) = spawn_fake_server_drop_on_request(&endpoint, token);
1274        let (handle, _events) =
1275            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1276
1277        let mut link_state_for_server2 = handle.link_state_receiver();
1278        let endpoint_for_server2 = endpoint.clone();
1279        let token_for_server2 = token.to_owned();
1280        let start_server2 = tokio::spawn(async move {
1281            wait_for_link_state(&mut link_state_for_server2, C2LinkState::Reconnecting).await;
1282            spawn_fake_server(&endpoint_for_server2, &token_for_server2, empty_status(), "server-2")
1283        });
1284
1285        let result = tokio::time::timeout(
1286            Duration::from_secs(5),
1287            handle.request_until(test_route(), NodeRequest::Snapshot, Duration::from_secs(5)),
1288        )
1289        .await
1290        .expect("a replay-safe request must not hang past its own budget");
1291        let response = result.expect("must succeed once the link comes back within the budget");
1292        assert_snapshot_tag(&response, "server-2");
1293        assert_eq!(
1294            requests_seen.load(Ordering::SeqCst),
1295            1,
1296            "the first attempt must have actually gone out on server-1's connection",
1297        );
1298
1299        let server2 = start_server2.await.unwrap();
1300        server1.kill().await;
1301        server2.kill().await;
1302    }
1303
1304    /// The same connection-drops-on-request failure as above, but with a
1305    /// request `NodeRequest::is_replay_safe` refuses
1306    /// (`AcquireController`): it must fail immediately with the original
1307    /// `ConnectionLost`, never wait for a reconnect, and never be resent --
1308    /// proven by an outer timeout far shorter than the budget it was given,
1309    /// and by the fake server's own attempt counter staying at exactly one.
1310    #[tokio::test]
1311    async fn request_until_never_retries_a_non_replay_safe_request() {
1312        let endpoint = unique_control_endpoint();
1313        let token = "reconnect-token-request-until-2";
1314        let (server1, requests_seen) = spawn_fake_server_drop_on_request(&endpoint, token);
1315        let (handle, _events) =
1316            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1317
1318        let result = tokio::time::timeout(
1319            Duration::from_secs(2),
1320            handle.request_until(
1321                test_route(),
1322                NodeRequest::AcquireController { lease_ms: 1_000 },
1323                Duration::from_secs(30),
1324            ),
1325        )
1326        .await
1327        .expect(
1328            "a non-replay-safe request must not wait on a reconnect it will never use, \
1329             no matter how generous its budget is",
1330        );
1331        assert!(matches!(result, Err(C2ControlError::ConnectionLost { .. })));
1332        assert_eq!(
1333            requests_seen.load(Ordering::SeqCst),
1334            1,
1335            "a non-replay-safe request must never be resent after its first attempt fails",
1336        );
1337
1338        server1.kill().await;
1339    }
1340
1341    /// A replay-safe request against a link that never comes back must
1342    /// still end -- bounded by its own budget, not by spinning forever
1343    /// waiting on a reconnect that never happens, and without exceeding
1344    /// `REQUEST_UNTIL_MAX_ATTEMPTS` real sends in the process.
1345    #[tokio::test]
1346    async fn request_until_ends_by_its_budget_when_the_link_never_returns() {
1347        let endpoint = unique_control_endpoint();
1348        let token = "reconnect-token-request-until-3";
1349        let (server1, requests_seen) = spawn_fake_server_drop_on_request(&endpoint, token);
1350        let (handle, _events) =
1351            connect_local_reconnecting(endpoint.clone(), token.to_owned()).await.unwrap();
1352
1353        let budget = Duration::from_millis(600);
1354        let before = tokio::time::Instant::now();
1355        let result = tokio::time::timeout(
1356            Duration::from_secs(10),
1357            handle.request_until(test_route(), NodeRequest::Snapshot, budget),
1358        )
1359        .await
1360        .expect("request_until must end on its own once its budget is exhausted, not hang");
1361        let elapsed = tokio::time::Instant::now() - before;
1362
1363        assert!(matches!(result, Err(C2ControlError::ConnectionLost { .. })));
1364        assert_eq!(
1365            requests_seen.load(Ordering::SeqCst),
1366            1,
1367            "no server ever reconnected, so only the first attempt could ever be sent",
1368        );
1369        assert!(
1370            elapsed + Duration::from_millis(50) >= budget,
1371            "must not return well before the budget it was given: elapsed {elapsed:?}",
1372        );
1373        assert!(
1374            elapsed < budget * 3,
1375            "must not spin well past its own budget waiting on a link that never returns: \
1376             elapsed {elapsed:?}",
1377        );
1378
1379        server1.kill().await;
1380    }
1381}