Skip to main content

unb_server/
connection.rs

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)]
19/// A stable, cloneable failure category for initial connection establishment.
20pub 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)]
42/// Why a durable logical peer connection is currently disconnected.
43pub enum DisconnectReason {
44    ExplicitDisconnect,
45    NodeShutdown,
46    SessionRetired { reason: RetirementReason },
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50/// The current state of a durable logical peer connection.
51pub 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)]
97/// A cloneable handle to one verified direct peer identity.
98///
99/// Initial [`Node::connect`](crate::Node::connect) establishes the peer and
100/// synchronizes routes before returning. An unintentional loss is maintained in
101/// the background. [`disconnect`](Self::disconnect) is terminal for every clone
102/// of this handle generation.
103pub 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    /// Permanently disconnect this logical handle generation.
185    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}