1use std::collections::BTreeSet;
2use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3use std::sync::{Arc, Mutex, RwLock, Weak};
4use std::time::Duration;
5
6use tokio::sync::watch;
7use unb_client::{EndpointSet, TransportKind};
8use unb_core::{NodeIdentity, RetirementReason, RouteDelta, RouteSnapshot};
9use unb_runtime::{CancellationToken, Wire};
10
11use crate::connect::EndpointDialer;
12use crate::node::Node;
13
14const INITIAL_BACKOFF_MS: u64 = 100;
15const MAX_BACKOFF_MS: u64 = 5_000;
16const STABLE_HEALTH: Duration = Duration::from_secs(30);
17
18#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
19pub enum ConnectError {
21 #[error("no supported endpoint in set")]
22 NoSupportedEndpoint,
23 #[error("dial failed for {transport:?}: {message}")]
24 Dial {
25 transport: TransportKind,
26 message: String,
27 },
28 #[error("dial timed out for {transport:?}")]
29 DialTimedOut { transport: TransportKind },
30 #[error("peer establishment failed: {message}")]
31 Establishment { message: String },
32 #[error("peer identity mismatch: expected {expected:?}, got {actual:?}")]
33 IdentityMismatch {
34 expected: String,
35 actual: Option<String>,
36 },
37 #[error("the owning node is shut down")]
38 NodeShutdown,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum DisconnectReason {
44 ExplicitDisconnect,
45 NodeShutdown,
46 SessionRetired { reason: RetirementReason },
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum ConnectionStatus {
52 Connecting,
53 Connected,
54 Disconnected { reason: DisconnectReason },
55}
56
57#[derive(Clone)]
58struct SessionBinding {
59 session_id: String,
60 wire: Arc<Wire>,
61}
62
63#[derive(Clone)]
64struct MaintenanceTask {
65 generation: u64,
66 cancellation: CancellationToken,
67}
68
69pub(crate) struct ReadinessWait {
70 status: watch::Receiver<ConnectionStatus>,
71 wait_for_change: bool,
72}
73
74impl ReadinessWait {
75 pub(crate) async fn wait(mut self) -> Result<(), DisconnectReason> {
76 if self.wait_for_change {
77 self.status
78 .changed()
79 .await
80 .map_err(|_| DisconnectReason::NodeShutdown)?;
81 }
82 loop {
83 let status = self.status.borrow_and_update().clone();
84 match status {
85 ConnectionStatus::Connected => return Ok(()),
86 ConnectionStatus::Disconnected { reason } => return Err(reason),
87 ConnectionStatus::Connecting => {}
88 }
89 if self.status.changed().await.is_err() {
90 return Err(DisconnectReason::NodeShutdown);
91 }
92 }
93 }
94}
95
96#[derive(Clone)]
97pub struct PeerConnection {
104 node: Weak<Node>,
105 peer: Arc<str>,
106 endpoints: Arc<RwLock<EndpointSet>>,
107 dialer: Arc<RwLock<Option<Arc<dyn EndpointDialer>>>>,
108 identity: Arc<RwLock<NodeIdentity>>,
109 session: Arc<Mutex<Option<SessionBinding>>>,
110 status: watch::Sender<ConnectionStatus>,
111 generation: Arc<AtomicU64>,
112 terminal: Arc<AtomicBool>,
113 maintenance: Arc<Mutex<Option<MaintenanceTask>>>,
114 stability: Arc<Mutex<Option<CancellationToken>>>,
115 backoff_ms: Arc<AtomicU64>,
116 destinations: Arc<RwLock<BTreeSet<String>>>,
117}
118
119impl PeerConnection {
120 pub(crate) fn new(
121 node: Weak<Node>,
122 identity: NodeIdentity,
123 endpoints: EndpointSet,
124 session_id: String,
125 wire: Arc<Wire>,
126 dialer: Option<Arc<dyn EndpointDialer>>,
127 ) -> PeerConnection {
128 Self::from_binding(node, identity, endpoints, session_id, wire, dialer)
129 }
130
131 pub(crate) fn passive(
132 node: Weak<Node>,
133 identity: NodeIdentity,
134 session_id: String,
135 wire: Arc<Wire>,
136 ) -> PeerConnection {
137 Self::from_binding(node, identity, EndpointSet::new(), session_id, wire, None)
138 }
139
140 fn from_binding(
141 node: Weak<Node>,
142 identity: NodeIdentity,
143 endpoints: EndpointSet,
144 session_id: String,
145 wire: Arc<Wire>,
146 dialer: Option<Arc<dyn EndpointDialer>>,
147 ) -> PeerConnection {
148 let (status, _) = watch::channel(ConnectionStatus::Connected);
149 PeerConnection {
150 node,
151 peer: Arc::from(identity.node_id.as_str()),
152 endpoints: Arc::new(RwLock::new(endpoints)),
153 dialer: Arc::new(RwLock::new(dialer)),
154 identity: Arc::new(RwLock::new(identity)),
155 session: Arc::new(Mutex::new(Some(SessionBinding { session_id, wire }))),
156 status,
157 generation: Arc::new(AtomicU64::new(0)),
158 terminal: Arc::new(AtomicBool::new(false)),
159 maintenance: Arc::new(Mutex::new(None)),
160 stability: Arc::new(Mutex::new(None)),
161 backoff_ms: Arc::new(AtomicU64::new(INITIAL_BACKOFF_MS)),
162 destinations: Arc::new(RwLock::new(BTreeSet::new())),
163 }
164 }
165
166 pub fn peer(&self) -> &str {
167 &self.peer
168 }
169
170 pub fn status(&self) -> ConnectionStatus {
171 self.status.borrow().clone()
172 }
173
174 pub fn changed(&self) -> impl std::future::Future<Output = ConnectionStatus> + Send + 'static {
175 let mut receiver = self.status.subscribe();
176 async move {
177 if receiver.changed().await.is_err() {
178 return receiver.borrow().clone();
179 }
180 receiver.borrow_and_update().clone()
181 }
182 }
183
184 pub fn disconnect(&self) {
186 let binding = self.terminate(DisconnectReason::ExplicitDisconnect);
187 if let Some(binding) = binding {
188 binding.wire.shutdown();
189 }
190 }
191
192 pub(crate) fn owner(&self) -> Option<Arc<Node>> {
193 self.node.upgrade()
194 }
195
196 pub(crate) fn readiness_wait(&self) -> Option<ReadinessWait> {
197 let status = self.status.subscribe();
198 let wait_for_change = match status.borrow().clone() {
199 ConnectionStatus::Connected => true,
200 ConnectionStatus::Connecting => false,
201 ConnectionStatus::Disconnected { .. } => return None,
202 };
203 Some(ReadinessWait {
204 status,
205 wait_for_change,
206 })
207 }
208
209 pub(crate) fn carried_destination(&self, destination: &str) -> bool {
210 self.destinations
211 .read()
212 .unwrap_or_else(|poisoned| poisoned.into_inner())
213 .contains(destination)
214 }
215
216 pub(crate) fn replace_destinations(&self, snapshot: &RouteSnapshot) {
217 *self
218 .destinations
219 .write()
220 .unwrap_or_else(|poisoned| poisoned.into_inner()) = snapshot
221 .routes
222 .iter()
223 .map(|route| route.destination.clone())
224 .collect();
225 }
226
227 pub(crate) fn apply_destination_delta(&self, delta: &RouteDelta) {
228 let mut destinations = self
229 .destinations
230 .write()
231 .unwrap_or_else(|poisoned| poisoned.into_inner());
232 for withdrawal in &delta.withdraw {
233 destinations.remove(&withdrawal.destination);
234 }
235 destinations.extend(delta.upsert.iter().map(|route| route.destination.clone()));
236 }
237
238 pub(crate) fn is_terminal(&self) -> bool {
239 self.terminal.load(Ordering::Acquire)
240 }
241
242 pub(crate) fn endpoints(&self) -> EndpointSet {
243 self.endpoints
244 .read()
245 .unwrap_or_else(|poisoned| poisoned.into_inner())
246 .clone()
247 }
248
249 pub(crate) fn replace_endpoints(&self, endpoints: EndpointSet) {
250 *self
251 .endpoints
252 .write()
253 .unwrap_or_else(|poisoned| poisoned.into_inner()) = endpoints;
254 }
255
256 pub(crate) fn replace_dialer(&self, dialer: Option<Arc<dyn EndpointDialer>>) {
257 *self
258 .dialer
259 .write()
260 .unwrap_or_else(|poisoned| poisoned.into_inner()) = dialer;
261 }
262
263 fn dialer(&self) -> Option<Arc<dyn EndpointDialer>> {
264 self.dialer
265 .read()
266 .unwrap_or_else(|poisoned| poisoned.into_inner())
267 .clone()
268 }
269
270 fn can_dial(&self) -> bool {
271 !self
272 .endpoints
273 .read()
274 .unwrap_or_else(|poisoned| poisoned.into_inner())
275 .is_empty()
276 }
277
278 pub(crate) fn bind(&self, identity: NodeIdentity, session_id: String, wire: Arc<Wire>) -> bool {
279 let mut maintenance = self
280 .maintenance
281 .lock()
282 .unwrap_or_else(|poisoned| poisoned.into_inner());
283 if self.is_terminal() {
284 return false;
285 }
286 self.generation.fetch_add(1, Ordering::AcqRel);
287 if let Some(task) = maintenance.take() {
288 task.cancellation.cancel();
289 }
290 self.install(identity, session_id, wire)
291 }
292
293 fn install(&self, identity: NodeIdentity, session_id: String, wire: Arc<Wire>) -> bool {
294 let mut session = self
295 .session
296 .lock()
297 .unwrap_or_else(|poisoned| poisoned.into_inner());
298 if wire.is_closed() {
299 return false;
300 }
301 *session = Some(SessionBinding {
302 session_id: session_id.clone(),
303 wire,
304 });
305 drop(session);
306 *self
307 .identity
308 .write()
309 .unwrap_or_else(|poisoned| poisoned.into_inner()) = identity;
310 self.publish(ConnectionStatus::Connected);
311 self.arm_stability(session_id);
312 true
313 }
314
315 pub(crate) fn retire(&self, session_id: &str, reason: RetirementReason) {
316 let should_start = {
317 let _maintenance = self
318 .maintenance
319 .lock()
320 .unwrap_or_else(|poisoned| poisoned.into_inner());
321 let mut session = self
322 .session
323 .lock()
324 .unwrap_or_else(|poisoned| poisoned.into_inner());
325 let removed = if session
326 .as_ref()
327 .is_some_and(|binding| binding.session_id == session_id)
328 {
329 session.take()
330 } else {
331 None
332 };
333 drop(session);
334 if removed.is_none() || self.is_terminal() {
335 return;
336 }
337 self.cancel_stability();
338 if self.can_dial() {
339 self.publish(ConnectionStatus::Connecting);
340 true
341 } else {
342 self.publish(ConnectionStatus::Disconnected {
343 reason: DisconnectReason::SessionRetired { reason },
344 });
345 false
346 }
347 };
348 if should_start {
349 self.start_maintenance();
350 }
351 }
352
353 pub(crate) fn node_shutdown(&self) {
354 let binding = self.terminate(DisconnectReason::NodeShutdown);
355 if let Some(binding) = binding {
356 binding.wire.shutdown();
357 }
358 }
359
360 pub(crate) fn publish(&self, status: ConnectionStatus) {
361 if *self.status.borrow() != status {
362 self.status.send_replace(status);
363 }
364 }
365
366 fn start_maintenance(&self) {
367 if self.is_terminal() || !self.can_dial() {
368 return;
369 }
370 let task = {
371 let mut maintenance = self
372 .maintenance
373 .lock()
374 .unwrap_or_else(|poisoned| poisoned.into_inner());
375 if maintenance.is_some() || self.status() != ConnectionStatus::Connecting {
376 return;
377 }
378 let task = MaintenanceTask {
379 generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1,
380 cancellation: CancellationToken::new(),
381 };
382 *maintenance = Some(task.clone());
383 task
384 };
385 let connection = self.clone();
386 unb_runtime::RuntimeHandle::current().spawn(async move {
387 connection.run_maintenance(task).await;
388 });
389 }
390
391 async fn run_maintenance(&self, task: MaintenanceTask) {
392 loop {
393 if !self.maintenance_is_current(&task) {
394 return;
395 }
396 let Some(node) = self.owner() else {
397 self.node_shutdown();
398 return;
399 };
400 if node.cancellation().is_cancelled() {
401 self.node_shutdown();
402 return;
403 }
404 let result = node
405 .reconnect_peer(self.peer(), &self.endpoints(), self.dialer())
406 .await;
407 if !self.maintenance_is_current(&task) {
408 if let Ok(candidate) = result {
409 candidate.candidate_wire.shutdown();
410 }
411 return;
412 }
413 match result {
414 Ok(candidate) => {
415 let installed = self.install_maintenance_candidate(
416 &task,
417 candidate.identity,
418 candidate.selected.session_id,
419 candidate.selected.wire,
420 );
421 if !installed {
422 candidate.candidate_wire.shutdown();
423 }
424 return;
425 }
426 Err(_) => {
427 let base = self
428 .backoff_ms
429 .load(Ordering::Acquire)
430 .clamp(INITIAL_BACKOFF_MS, MAX_BACKOFF_MS);
431 self.backoff_ms.store(
432 base.saturating_mul(2).min(MAX_BACKOFF_MS),
433 Ordering::Release,
434 );
435 let delay = jittered_delay(base);
436 tokio::select! {
437 biased;
438 () = task.cancellation.cancelled() => return,
439 () = node.cancellation().cancelled() => {
440 self.node_shutdown();
441 return;
442 }
443 () = n0_future::time::sleep(delay) => {}
444 }
445 }
446 }
447 }
448 }
449
450 fn maintenance_is_current(&self, task: &MaintenanceTask) -> bool {
451 !self.is_terminal()
452 && !task.cancellation.is_cancelled()
453 && self.generation.load(Ordering::Acquire) == task.generation
454 && self
455 .maintenance
456 .lock()
457 .unwrap_or_else(|poisoned| poisoned.into_inner())
458 .as_ref()
459 .is_some_and(|current| current.generation == task.generation)
460 }
461
462 fn install_maintenance_candidate(
463 &self,
464 task: &MaintenanceTask,
465 identity: NodeIdentity,
466 session_id: String,
467 wire: Arc<Wire>,
468 ) -> bool {
469 let mut maintenance = self
470 .maintenance
471 .lock()
472 .unwrap_or_else(|poisoned| poisoned.into_inner());
473 if self.is_terminal()
474 || task.cancellation.is_cancelled()
475 || self.generation.load(Ordering::Acquire) != task.generation
476 || !maintenance
477 .as_ref()
478 .is_some_and(|current| current.generation == task.generation)
479 {
480 return false;
481 }
482 if !self.install(identity, session_id, wire) {
483 return false;
484 }
485 maintenance.take();
486 true
487 }
488
489 fn arm_stability(&self, session_id: String) {
490 self.cancel_stability();
491 let cancellation = CancellationToken::new();
492 *self
493 .stability
494 .lock()
495 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(cancellation.clone());
496 let connection = self.clone();
497 unb_runtime::RuntimeHandle::current().spawn(async move {
498 tokio::select! {
499 biased;
500 () = cancellation.cancelled() => return,
501 () = n0_future::time::sleep(STABLE_HEALTH) => {}
502 }
503 if connection.is_terminal() || connection.status() != ConnectionStatus::Connected {
504 return;
505 }
506 let exact = connection
507 .session
508 .lock()
509 .unwrap_or_else(|poisoned| poisoned.into_inner())
510 .as_ref()
511 .is_some_and(|binding| binding.session_id == session_id);
512 if exact {
513 connection
514 .backoff_ms
515 .store(INITIAL_BACKOFF_MS, Ordering::Release);
516 }
517 });
518 }
519
520 fn cancel_stability(&self) {
521 if let Some(cancellation) = self
522 .stability
523 .lock()
524 .unwrap_or_else(|poisoned| poisoned.into_inner())
525 .take()
526 {
527 cancellation.cancel();
528 }
529 }
530
531 fn terminate(&self, reason: DisconnectReason) -> Option<SessionBinding> {
532 let mut maintenance = self
533 .maintenance
534 .lock()
535 .unwrap_or_else(|poisoned| poisoned.into_inner());
536 if self.terminal.swap(true, Ordering::AcqRel) {
537 return None;
538 }
539 self.generation.fetch_add(1, Ordering::AcqRel);
540 if let Some(task) = maintenance.take() {
541 task.cancellation.cancel();
542 }
543 self.cancel_stability();
544 let binding = self
545 .session
546 .lock()
547 .unwrap_or_else(|poisoned| poisoned.into_inner())
548 .take();
549 self.publish(ConnectionStatus::Disconnected { reason });
550 binding
551 }
552}
553
554fn jittered_delay(base_ms: u64) -> Duration {
555 let spread = base_ms / 5;
556 let minimum = base_ms.saturating_sub(spread);
557 let maximum = base_ms.saturating_add(spread);
558 Duration::from_millis(fastrand::u64(minimum..=maximum))
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 fn test_connection() -> PeerConnection {
566 let node = Node::builder("local")
567 .insecure_accept_declared_peer_identities()
568 .build()
569 .unwrap();
570 let (pipe, _remote) = unb_client::pair();
571 PeerConnection::passive(
572 Arc::downgrade(&node),
573 NodeIdentity {
574 node_id: "peer".into(),
575 instance_id: "peer-instance".into(),
576 epoch: 1,
577 proof: serde_json::Value::Null,
578 },
579 "session-1".into(),
580 Arc::new(Wire::open(pipe)),
581 )
582 }
583
584 #[test]
585 fn jitter_stays_within_twenty_percent_at_every_backoff_edge() {
586 for base in [
587 INITIAL_BACKOFF_MS,
588 200,
589 400,
590 800,
591 1_600,
592 3_200,
593 MAX_BACKOFF_MS,
594 ] {
595 for _ in 0..128 {
596 let delay = jittered_delay(base).as_millis() as u64;
597 assert!(delay >= base - base / 5);
598 assert!(delay <= base + base / 5);
599 }
600 }
601 }
602
603 #[tokio::test(start_paused = true)]
604 async fn backoff_resets_only_after_thirty_seconds_on_the_exact_healthy_binding() {
605 let connection = test_connection();
606 connection.backoff_ms.store(800, Ordering::Release);
607 connection.arm_stability("session-1".into());
608 tokio::task::yield_now().await;
609 tokio::time::advance(Duration::from_secs(29)).await;
610 tokio::task::yield_now().await;
611 assert_eq!(connection.backoff_ms.load(Ordering::Acquire), 800);
612 tokio::time::advance(Duration::from_secs(1)).await;
613 tokio::task::yield_now().await;
614 assert_eq!(
615 connection.backoff_ms.load(Ordering::Acquire),
616 INITIAL_BACKOFF_MS
617 );
618 }
619
620 #[tokio::test(start_paused = true)]
621 async fn a_retired_brief_binding_does_not_reset_accumulated_backoff() {
622 let connection = test_connection();
623 connection.backoff_ms.store(800, Ordering::Release);
624 connection.arm_stability("session-1".into());
625 tokio::task::yield_now().await;
626 connection.retire("session-1", RetirementReason::TransportFailed);
627 tokio::time::advance(STABLE_HEALTH).await;
628 tokio::task::yield_now().await;
629 assert_eq!(connection.backoff_ms.load(Ordering::Acquire), 800);
630 }
631
632 #[tokio::test(start_paused = true)]
633 async fn maintenance_election_keeps_exactly_one_supervisor_generation() {
634 let connection = test_connection();
635 connection.replace_endpoints(EndpointSet::from(crate::Endpoint {
636 kind: crate::TransportKind::WebSocket,
637 address: "ws://127.0.0.1:9".into(),
638 cert_hash: None,
639 }));
640 connection.publish(ConnectionStatus::Connecting);
641
642 connection.start_maintenance();
643 let generation = connection
644 .maintenance
645 .lock()
646 .unwrap_or_else(|poisoned| poisoned.into_inner())
647 .as_ref()
648 .expect("one maintenance supervisor")
649 .generation;
650 connection.start_maintenance();
651
652 assert_eq!(
653 connection
654 .maintenance
655 .lock()
656 .unwrap_or_else(|poisoned| poisoned.into_inner())
657 .as_ref()
658 .expect("the original supervisor remains elected")
659 .generation,
660 generation
661 );
662 connection.disconnect();
663 tokio::task::yield_now().await;
664 assert!(connection
665 .maintenance
666 .lock()
667 .unwrap_or_else(|poisoned| poisoned.into_inner())
668 .is_none());
669 }
670
671 #[tokio::test]
672 async fn a_connected_recovery_hint_waits_for_the_next_recovery_cycle() {
673 let connection = test_connection();
674 let waiter = connection.readiness_wait().unwrap();
675 let waiting = tokio::spawn(waiter.wait());
676 tokio::task::yield_now().await;
677 assert!(!waiting.is_finished());
678
679 connection.publish(ConnectionStatus::Connecting);
680 connection.publish(ConnectionStatus::Connected);
681 assert!(waiting.await.unwrap().is_ok());
682 connection.disconnect();
683 }
684
685 #[tokio::test]
686 async fn stale_session_retirement_cannot_replace_or_retire_the_current_binding() {
687 let connection = test_connection();
688 let (pipe, _remote) = unb_client::pair();
689 assert!(connection.bind(
690 NodeIdentity {
691 node_id: "peer".into(),
692 instance_id: "peer-instance-2".into(),
693 epoch: 2,
694 proof: serde_json::Value::Null,
695 },
696 "session-2".into(),
697 Arc::new(Wire::open(pipe)),
698 ));
699
700 connection.retire("session-1", RetirementReason::TransportFailed);
701
702 assert_eq!(connection.status(), ConnectionStatus::Connected);
703 assert_eq!(
704 connection
705 .session
706 .lock()
707 .unwrap_or_else(|poisoned| poisoned.into_inner())
708 .as_ref()
709 .map(|binding| binding.session_id.as_str()),
710 Some("session-2")
711 );
712 connection.disconnect();
713 }
714
715 #[tokio::test]
716 async fn terminal_or_closed_bindings_cannot_be_installed() {
717 let connection = test_connection();
718 connection.disconnect();
719 let (late_pipe, _late_remote) = unb_client::pair();
720 assert!(!connection.bind(
721 NodeIdentity {
722 node_id: "peer".into(),
723 instance_id: "peer-instance-2".into(),
724 epoch: 2,
725 proof: serde_json::Value::Null,
726 },
727 "session-2".into(),
728 Arc::new(Wire::open(late_pipe)),
729 ));
730 assert_eq!(
731 connection.status(),
732 ConnectionStatus::Disconnected {
733 reason: DisconnectReason::ExplicitDisconnect,
734 }
735 );
736
737 let connection = test_connection();
738 let (closed_pipe, _closed_remote) = unb_client::pair();
739 let closed = Arc::new(Wire::open(closed_pipe));
740 closed.shutdown();
741 assert!(!connection.bind(
742 NodeIdentity {
743 node_id: "peer".into(),
744 instance_id: "peer-instance-2".into(),
745 epoch: 2,
746 proof: serde_json::Value::Null,
747 },
748 "session-2".into(),
749 closed,
750 ));
751 connection.disconnect();
752 }
753}