1use 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
39const 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
54const RECONNECT_PARKED_BACKOFF: Duration = Duration::from_secs(30);
58
59const REQUEST_UNTIL_MAX_ATTEMPTS: u32 = 3;
65
66fn 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
79fn 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum C2LinkState {
97 Connected,
98 Reconnecting,
99}
100
101struct 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
113struct SupervisorShutdownGuard(watch::Sender<bool>);
117
118impl Drop for SupervisorShutdownGuard {
119 fn drop(&mut self) {
120 let _ = self.0.send(true);
121 }
122}
123
124#[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 pub fn link_state(&self) -> C2LinkState {
140 *self.link_state.borrow()
141 }
142
143 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 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 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
252async 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
266pub struct C2ReconnectingEventReceiver {
275 inner: mpsc::Receiver<RoutedNodeEvent>,
276 harness_mcp: mpsc::Receiver<RoutedNodeEvent>,
277 browser: mpsc::Receiver<BrowserStreamEvent>,
278}
279
280impl C2ReconnectingEventReceiver {
281 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 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 pub async fn recv_harness_mcp(&mut self) -> Option<RoutedNodeEvent> {
320 self.harness_mcp.recv().await
321 }
322
323 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
350struct 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
362enum ConnectionEnded {
364 Lost,
367 ShuttingDown,
369 ConsumerGone,
372}
373
374async 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
431async 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
464async 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
521pub 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
533pub 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 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 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 struct FakeServer {
839 to_client: mpsc::Sender<C2ServerFrame>,
840 task: JoinHandle<()>,
841 }
842
843 impl FakeServer {
844 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 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 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 #[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 #[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 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 let (_hard_server, mut attempts) = spawn_looping_hard_failure_server(&endpoint, token);
1181 wait_for_attempt_count(&mut attempts, 1).await;
1182
1183 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 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 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 });
1259 (FakeServer { to_client, task }, requests_seen)
1260 }
1261
1262 #[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 #[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 #[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}