Skip to main content

net/adapter/net/
failure.rs

1//! Failure detection and recovery for Net.
2//!
3//! This module provides:
4//! - `FailureDetector` - Heartbeat-based failure detection
5//! - `LossSimulator` - Packet loss simulation for testing
6//! - `RecoveryManager` - Route recovery and failover
7//! - `CircuitBreaker` - Prevent cascading failures
8
9use dashmap::DashMap;
10use parking_lot::{Mutex, RwLock};
11use std::collections::VecDeque;
12use std::net::SocketAddr;
13use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17/// Failure detector configuration
18#[derive(Debug, Clone)]
19pub struct FailureDetectorConfig {
20    /// Heartbeat timeout before considering node failed
21    pub timeout: Duration,
22    /// Number of missed heartbeats before declaring failure
23    pub miss_threshold: u32,
24    /// Suspicion threshold (soft failure)
25    pub suspicion_threshold: u32,
26    /// Cleanup interval for stale entries
27    pub cleanup_interval: Duration,
28}
29
30impl Default for FailureDetectorConfig {
31    fn default() -> Self {
32        Self {
33            timeout: Duration::from_secs(5),
34            miss_threshold: 3,
35            suspicion_threshold: 2,
36            cleanup_interval: Duration::from_secs(30),
37        }
38    }
39}
40
41/// Node health status
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum NodeStatus {
44    /// Node is healthy (receiving heartbeats)
45    Healthy,
46    /// Node is suspected (missed some heartbeats)
47    Suspected,
48    /// Node is considered failed
49    Failed,
50    /// Node status is unknown (never seen)
51    Unknown,
52}
53
54/// Per-node failure tracking state
55#[derive(Debug)]
56struct NodeState {
57    /// Last heartbeat timestamp
58    last_heartbeat: Instant,
59    /// Number of consecutive missed heartbeats
60    missed_count: u32,
61    /// Current status
62    status: NodeStatus,
63    /// Node address
64    #[allow(dead_code)]
65    addr: SocketAddr,
66    /// Total heartbeats received
67    total_heartbeats: u64,
68    /// Time node was first seen
69    #[allow(dead_code)]
70    first_seen: Instant,
71}
72
73impl NodeState {
74    fn new(addr: SocketAddr) -> Self {
75        let now = Instant::now();
76        Self {
77            last_heartbeat: now,
78            missed_count: 0,
79            status: NodeStatus::Healthy,
80            addr,
81            total_heartbeats: 1,
82            first_seen: now,
83        }
84    }
85
86    fn on_heartbeat(&mut self) {
87        self.last_heartbeat = Instant::now();
88        self.missed_count = 0;
89        self.status = NodeStatus::Healthy;
90        self.total_heartbeats += 1;
91    }
92
93    fn check(
94        &mut self,
95        now: Instant,
96        timeout: Duration,
97        suspicion_threshold: u32,
98        miss_threshold: u32,
99    ) {
100        // `now` is read once by the caller and shared across the whole
101        // check_all sweep instead of a per-node clock read.
102        let elapsed = now.saturating_duration_since(self.last_heartbeat);
103
104        if elapsed > timeout {
105            // Compute how many heartbeat intervals have been missed based on
106            // actual elapsed time, not just how many times check() was called.
107            // This prevents both under- and over-counting when check_all()
108            // runs at a different frequency than the heartbeat interval.
109            let timeout_nanos = timeout.as_nanos().max(1);
110            self.missed_count = (elapsed.as_nanos() / timeout_nanos) as u32;
111
112            if self.missed_count >= miss_threshold {
113                self.status = NodeStatus::Failed;
114            } else if self.missed_count >= suspicion_threshold {
115                self.status = NodeStatus::Suspected;
116            }
117        }
118    }
119}
120
121/// Failure detection statistics
122#[derive(Debug, Clone, Default)]
123pub struct FailureStats {
124    /// Total nodes tracked
125    pub nodes_tracked: usize,
126    /// Healthy nodes
127    pub nodes_healthy: usize,
128    /// Suspected nodes
129    pub nodes_suspected: usize,
130    /// Failed nodes
131    pub nodes_failed: usize,
132    /// Total failures detected
133    pub total_failures: u64,
134    /// Total recoveries
135    pub total_recoveries: u64,
136}
137
138/// Heartbeat-based failure detector.
139///
140/// Tracks node health via heartbeat messages and detects failures.
141pub struct FailureDetector {
142    /// Configuration
143    config: FailureDetectorConfig,
144    /// Per-node state
145    nodes: DashMap<u64, NodeState>,
146    /// Failure callback (node_id)
147    on_failure: Option<Arc<dyn Fn(u64) + Send + Sync>>,
148    /// Recovery callback (node_id)
149    on_recovery: Option<Arc<dyn Fn(u64) + Send + Sync>>,
150    /// Total failures detected
151    total_failures: AtomicU64,
152    /// Total recoveries
153    total_recoveries: AtomicU64,
154    /// O(1) tracked-node count. `DashMap::len()` walks every shard (~1us);
155    /// node_count()/stats().nodes_tracked read this instead. Maintained on the
156    /// insert (heartbeat) / remove / cleanup paths — the only ones that change
157    /// map size. See docs/misc/PERF_AUDIT_2026_06_08_BENCHMARK_WINS.md §4.
158    num_nodes: AtomicUsize,
159    /// Last cleanup time
160    last_cleanup: Mutex<Instant>,
161}
162
163impl FailureDetector {
164    /// Create a new failure detector with default config
165    pub fn new() -> Self {
166        Self::with_config(FailureDetectorConfig::default())
167    }
168
169    /// Create with custom config
170    pub fn with_config(config: FailureDetectorConfig) -> Self {
171        Self {
172            config,
173            nodes: DashMap::new(),
174            on_failure: None,
175            on_recovery: None,
176            total_failures: AtomicU64::new(0),
177            total_recoveries: AtomicU64::new(0),
178            num_nodes: AtomicUsize::new(0),
179            last_cleanup: Mutex::new(Instant::now()),
180        }
181    }
182
183    /// Set failure callback
184    pub fn on_failure<F>(mut self, f: F) -> Self
185    where
186        F: Fn(u64) + Send + Sync + 'static,
187    {
188        self.on_failure = Some(Arc::new(f));
189        self
190    }
191
192    /// Set recovery callback
193    pub fn on_recovery<F>(mut self, f: F) -> Self
194    where
195        F: Fn(u64) + Send + Sync + 'static,
196    {
197        self.on_recovery = Some(Arc::new(f));
198        self
199    }
200
201    /// Record a heartbeat from a node
202    ///
203    /// Previously the recovery callback was invoked inside
204    /// `entry().and_modify(...)`, which holds the DashMap shard's
205    /// write lock. A user-supplied callback that re-entered the same
206    /// shard (or any structure ordered against it) deadlocked; even
207    /// without deadlock, every concurrent `heartbeat` hashing to the
208    /// same shard stalled while the callback ran. The fix collects a
209    /// "should I notify?" flag inside the closure and fires the
210    /// callback *after* the `and_modify` returns, releasing the
211    /// shard lock.
212    pub fn heartbeat(&self, node_id: u64, addr: SocketAddr) {
213        let mut should_notify_recovery = false;
214        let mut node_inserted = false;
215        self.nodes
216            .entry(node_id)
217            .and_modify(|state| {
218                let was_failed = state.status == NodeStatus::Failed;
219                state.on_heartbeat();
220
221                if was_failed {
222                    self.total_recoveries.fetch_add(1, Ordering::Relaxed);
223                    should_notify_recovery = true;
224                }
225            })
226            .or_insert_with(|| {
227                node_inserted = true;
228                NodeState::new(addr)
229            });
230        if node_inserted {
231            self.num_nodes.fetch_add(1, Ordering::Relaxed);
232        }
233
234        if should_notify_recovery {
235            if let Some(ref cb) = self.on_recovery {
236                cb(node_id);
237            }
238        }
239    }
240
241    /// Check all nodes for failures
242    ///
243    /// Callbacks are now invoked *after* the `iter_mut` loop has
244    /// dropped its shard locks. Previously `cb(*entry.key())` ran
245    /// inside the iteration, with the per-shard write lock still
246    /// held — a user-supplied callback that touched another DashMap
247    /// entry on the same shard (or re-entered the failure detector
248    /// itself via `heartbeat` / `status`) would deadlock. We collect
249    /// the failed ids first, release the iteration locks, then fire
250    /// the callbacks.
251    pub fn check_all(&self) -> Vec<u64> {
252        let mut newly_failed = Vec::new();
253
254        // Read the clock once for the whole sweep instead of per node.
255        let now = Instant::now();
256        for mut entry in self.nodes.iter_mut() {
257            let prev_status = entry.status;
258            entry.check(
259                now,
260                self.config.timeout,
261                self.config.suspicion_threshold,
262                self.config.miss_threshold,
263            );
264
265            if entry.status == NodeStatus::Failed && prev_status != NodeStatus::Failed {
266                newly_failed.push(*entry.key());
267                self.total_failures.fetch_add(1, Ordering::Relaxed);
268            }
269        }
270
271        if let Some(ref cb) = self.on_failure {
272            for id in &newly_failed {
273                cb(*id);
274            }
275        }
276
277        newly_failed
278    }
279
280    /// Get node status
281    pub fn status(&self, node_id: u64) -> NodeStatus {
282        self.nodes
283            .get(&node_id)
284            .map(|s| s.status)
285            .unwrap_or(NodeStatus::Unknown)
286    }
287
288    /// Get all failed nodes
289    pub fn failed_nodes(&self) -> Vec<u64> {
290        self.nodes
291            .iter()
292            .filter(|r| r.status == NodeStatus::Failed)
293            .map(|r| *r.key())
294            .collect()
295    }
296
297    /// Get all suspected nodes
298    pub fn suspected_nodes(&self) -> Vec<u64> {
299        self.nodes
300            .iter()
301            .filter(|r| r.status == NodeStatus::Suspected)
302            .map(|r| *r.key())
303            .collect()
304    }
305
306    /// Get all healthy nodes
307    pub fn healthy_nodes(&self) -> Vec<u64> {
308        self.nodes
309            .iter()
310            .filter(|r| r.status == NodeStatus::Healthy)
311            .map(|r| *r.key())
312            .collect()
313    }
314
315    /// Remove a node from tracking
316    pub fn remove(&self, node_id: u64) {
317        if self.nodes.remove(&node_id).is_some() {
318            self.num_nodes.fetch_sub(1, Ordering::Relaxed);
319        }
320    }
321
322    /// Clean up stale entries (nodes that have been failed for too long)
323    pub fn cleanup(&self) -> usize {
324        // Recover from poisoning rather than panic. A panic
325        // anywhere holding this mutex would otherwise turn every
326        // subsequent `cleanup()` call into a runtime panic that
327        // takes the failure-detection loop down with it. Matches
328        // the recovery pattern used elsewhere in the crate
329        // (e.g. `crypto.rs::sliding_window`).
330        let mut last = self.last_cleanup.lock();
331        if last.elapsed() < self.config.cleanup_interval {
332            return 0;
333        }
334        *last = Instant::now();
335        drop(last);
336
337        let stale_threshold = self.config.timeout * 10; // 10x timeout = stale
338        let mut removed = 0;
339
340        self.nodes.retain(|_, state| {
341            if state.status == NodeStatus::Failed
342                && state.last_heartbeat.elapsed() > stale_threshold
343            {
344                removed += 1;
345                false
346            } else {
347                true
348            }
349        });
350
351        self.num_nodes.fetch_sub(removed, Ordering::Relaxed);
352        removed
353    }
354
355    /// Get statistics
356    ///
357    /// `nodes_tracked` reads the O(1) counter; the per-status tally is still
358    /// a single pass over the entries. That scan is observability-only (not on
359    /// any hot path) and is deliberately NOT replaced by per-status counters:
360    /// node status is mutated in-place via `get_mut().status = ...` in tests
361    /// and could be elsewhere, which would silently drift maintained counters.
362    /// The scan is always exact.
363    pub fn stats(&self) -> FailureStats {
364        let mut healthy = 0;
365        let mut suspected = 0;
366        let mut failed = 0;
367
368        for entry in self.nodes.iter() {
369            match entry.status {
370                NodeStatus::Healthy => healthy += 1,
371                NodeStatus::Suspected => suspected += 1,
372                NodeStatus::Failed => failed += 1,
373                NodeStatus::Unknown => {}
374            }
375        }
376
377        FailureStats {
378            nodes_tracked: self.num_nodes.load(Ordering::Relaxed),
379            nodes_healthy: healthy,
380            nodes_suspected: suspected,
381            nodes_failed: failed,
382            total_failures: self.total_failures.load(Ordering::Relaxed),
383            total_recoveries: self.total_recoveries.load(Ordering::Relaxed),
384        }
385    }
386
387    /// Get node count
388    pub fn node_count(&self) -> usize {
389        self.num_nodes.load(Ordering::Relaxed)
390    }
391}
392
393impl Default for FailureDetector {
394    fn default() -> Self {
395        Self::new()
396    }
397}
398
399/// Packet loss simulator for testing.
400///
401/// Simulates various network failure conditions.
402pub struct LossSimulator {
403    /// Base loss rate (0.0 - 1.0)
404    loss_rate: f32,
405    /// Burst loss state
406    in_burst: AtomicBool,
407    /// Burst probability
408    burst_prob: f32,
409    /// Burst length (packets)
410    burst_length: u32,
411    /// Current burst remaining
412    burst_remaining: AtomicU64,
413    /// Random state (simple LCG)
414    rng_state: AtomicU64,
415    /// Total packets seen
416    total_packets: AtomicU64,
417    /// Total packets dropped
418    total_dropped: AtomicU64,
419}
420
421impl LossSimulator {
422    /// Create a new loss simulator with given loss rate
423    pub fn new(loss_rate: f32) -> Self {
424        Self {
425            loss_rate: loss_rate.clamp(0.0, 1.0),
426            in_burst: AtomicBool::new(false),
427            burst_prob: 0.0,
428            burst_length: 0,
429            burst_remaining: AtomicU64::new(0),
430            rng_state: AtomicU64::new(
431                std::time::SystemTime::now()
432                    .duration_since(std::time::UNIX_EPOCH)
433                    .unwrap_or_default()
434                    .as_nanos() as u64,
435            ),
436            total_packets: AtomicU64::new(0),
437            total_dropped: AtomicU64::new(0),
438        }
439    }
440
441    /// Create with burst loss behavior
442    pub fn with_bursts(mut self, burst_prob: f32, burst_length: u32) -> Self {
443        self.burst_prob = burst_prob.clamp(0.0, 1.0);
444        self.burst_length = burst_length;
445        self
446    }
447
448    /// Check if a packet should be dropped
449    pub fn should_drop(&self) -> bool {
450        self.total_packets.fetch_add(1, Ordering::Relaxed);
451
452        // Check burst state — use compare-and-swap to avoid underflow wrapping
453        // to u64::MAX when multiple threads race on the last remaining count.
454        loop {
455            let remaining = self.burst_remaining.load(Ordering::Relaxed);
456            if remaining == 0 {
457                break;
458            }
459            match self.burst_remaining.compare_exchange_weak(
460                remaining,
461                remaining - 1,
462                Ordering::Relaxed,
463                Ordering::Relaxed,
464            ) {
465                Ok(_) => {
466                    self.total_dropped.fetch_add(1, Ordering::Relaxed);
467                    return true;
468                }
469                Err(_) => continue, // Retry CAS
470            }
471        }
472
473        // Generate random value
474        let r = self.next_random();
475
476        // Check for burst start
477        if self.burst_prob > 0.0 && r < self.burst_prob {
478            // The triggering packet counts as the first drop in the burst,
479            // so only burst_length - 1 additional packets remain.
480            self.burst_remaining.store(
481                self.burst_length.saturating_sub(1) as u64,
482                Ordering::Relaxed,
483            );
484            self.in_burst.store(true, Ordering::Relaxed);
485            self.total_dropped.fetch_add(1, Ordering::Relaxed);
486            return true;
487        }
488
489        // Normal loss
490        if r < self.loss_rate {
491            self.total_dropped.fetch_add(1, Ordering::Relaxed);
492            return true;
493        }
494
495        false
496    }
497
498    /// Get current effective loss rate
499    pub fn effective_loss_rate(&self) -> f32 {
500        let total = self.total_packets.load(Ordering::Relaxed);
501        let dropped = self.total_dropped.load(Ordering::Relaxed);
502        if total == 0 {
503            return 0.0;
504        }
505        dropped as f32 / total as f32
506    }
507
508    /// Reset statistics
509    pub fn reset(&self) {
510        self.total_packets.store(0, Ordering::Relaxed);
511        self.total_dropped.store(0, Ordering::Relaxed);
512        self.burst_remaining.store(0, Ordering::Relaxed);
513        self.in_burst.store(false, Ordering::Relaxed);
514    }
515
516    /// Get statistics
517    pub fn stats(&self) -> (u64, u64) {
518        (
519            self.total_packets.load(Ordering::Relaxed),
520            self.total_dropped.load(Ordering::Relaxed),
521        )
522    }
523
524    // Simple LCG random number generator (0.0 - 1.0).
525    // Uses CAS loop so concurrent threads don't get identical random values.
526    // fetch_update returns Ok(previous_value); derive the output from the
527    // new state (prev * M + 1) which the closure already stored atomically.
528    #[expect(
529        clippy::unwrap_used,
530        reason = "closure always returns Some, so fetch_update never returns Err"
531    )]
532    fn next_random(&self) -> f32 {
533        let prev = self
534            .rng_state
535            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |s| {
536                Some(s.wrapping_mul(6364136223846793005).wrapping_add(1))
537            })
538            .unwrap();
539        let new_state = prev.wrapping_mul(6364136223846793005).wrapping_add(1);
540        (new_state >> 33) as f32 / (1u64 << 31) as f32
541    }
542}
543
544/// Circuit breaker state
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub enum CircuitState {
547    /// Circuit is closed (normal operation)
548    Closed,
549    /// Circuit is open (blocking requests)
550    Open,
551    /// Circuit is half-open (testing recovery)
552    HalfOpen,
553}
554
555/// Circuit breaker for preventing cascading failures.
556pub struct CircuitBreaker {
557    /// Current state
558    state: RwLock<CircuitState>,
559    /// Failure count in current window
560    failure_count: AtomicU64,
561    /// Success count in current window
562    success_count: AtomicU64,
563    /// Failure threshold to trip
564    failure_threshold: u64,
565    /// Success threshold to close
566    success_threshold: u64,
567    /// Time to wait before half-open
568    reset_timeout: Duration,
569    /// Last state change time
570    last_state_change: Mutex<Instant>,
571    /// Total trips
572    total_trips: AtomicU64,
573}
574
575impl CircuitBreaker {
576    /// Create a new circuit breaker
577    pub fn new(failure_threshold: u64, success_threshold: u64, reset_timeout: Duration) -> Self {
578        Self {
579            state: RwLock::new(CircuitState::Closed),
580            failure_count: AtomicU64::new(0),
581            success_count: AtomicU64::new(0),
582            failure_threshold,
583            success_threshold,
584            reset_timeout,
585            last_state_change: Mutex::new(Instant::now()),
586            total_trips: AtomicU64::new(0),
587        }
588    }
589
590    /// Check if request should be allowed
591    pub fn allow(&self) -> bool {
592        // Fast path: read lock for the common Closed/HalfOpen case so
593        // typical allow() calls don't contend on the writer lock.
594        {
595            let state = *self.state.read();
596            match state {
597                CircuitState::Closed | CircuitState::HalfOpen => return true,
598                CircuitState::Open => {} // fall through to slow path
599            }
600        }
601        // Slow path: when the fast path observed Open, hold the write
602        // lock across the entire read-decide-transition. Dropping it
603        // between the read and the transition (the previous
604        // implementation) lets a concurrent reset() — which transitions
605        // Open → Closed — be silently undone by this method's
606        // transition Open → HalfOpen layered on top of the Closed
607        // state. record_success/record_failure deliberately hold the
608        // write lock throughout for the same reason; allow() was the
609        // outlier.
610        let mut state = self.state.write();
611        match *state {
612            CircuitState::Closed | CircuitState::HalfOpen => true,
613            CircuitState::Open => {
614                let elapsed = self.last_state_change.lock().elapsed();
615                if elapsed >= self.reset_timeout {
616                    Self::transition_locked(
617                        &mut state,
618                        CircuitState::HalfOpen,
619                        &self.failure_count,
620                        &self.success_count,
621                        &self.last_state_change,
622                        &self.total_trips,
623                    );
624                    true
625                } else {
626                    false
627                }
628            }
629        }
630    }
631
632    /// Record a success
633    pub fn record_success(&self) {
634        // Hold write lock through the entire read-decide-transition path
635        // to prevent TOCTOU races where concurrent threads undo each other's
636        // state transitions.
637        let mut state = self.state.write();
638        match *state {
639            CircuitState::Closed => {
640                // Reset failure count on success
641                self.failure_count.store(0, Ordering::Relaxed);
642            }
643            CircuitState::HalfOpen => {
644                let count = self.success_count.fetch_add(1, Ordering::Relaxed) + 1;
645                if count >= self.success_threshold {
646                    Self::transition_locked(
647                        &mut state,
648                        CircuitState::Closed,
649                        &self.failure_count,
650                        &self.success_count,
651                        &self.last_state_change,
652                        &self.total_trips,
653                    );
654                }
655            }
656            CircuitState::Open => {}
657        }
658    }
659
660    /// Record a failure
661    pub fn record_failure(&self) {
662        // Hold write lock through the entire read-decide-transition path
663        // to prevent TOCTOU races where concurrent threads undo each other's
664        // state transitions.
665        let mut state = self.state.write();
666        match *state {
667            CircuitState::Closed => {
668                let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1;
669                if count >= self.failure_threshold {
670                    Self::transition_locked(
671                        &mut state,
672                        CircuitState::Open,
673                        &self.failure_count,
674                        &self.success_count,
675                        &self.last_state_change,
676                        &self.total_trips,
677                    );
678                }
679            }
680            CircuitState::HalfOpen => {
681                // Single failure in half-open trips back to open
682                Self::transition_locked(
683                    &mut state,
684                    CircuitState::Open,
685                    &self.failure_count,
686                    &self.success_count,
687                    &self.last_state_change,
688                    &self.total_trips,
689                );
690            }
691            CircuitState::Open => {}
692        }
693    }
694
695    /// Get current state
696    pub fn state(&self) -> CircuitState {
697        *self.state.read()
698    }
699
700    /// Get total trip count
701    pub fn total_trips(&self) -> u64 {
702        self.total_trips.load(Ordering::Relaxed)
703    }
704
705    /// Reset the circuit breaker
706    pub fn reset(&self) {
707        self.transition_to(CircuitState::Closed);
708        self.failure_count.store(0, Ordering::Relaxed);
709        self.success_count.store(0, Ordering::Relaxed);
710    }
711
712    fn transition_to(&self, new_state: CircuitState) {
713        let mut state = self.state.write();
714        Self::transition_locked(
715            &mut state,
716            new_state,
717            &self.failure_count,
718            &self.success_count,
719            &self.last_state_change,
720            &self.total_trips,
721        );
722    }
723
724    /// Transition while already holding the write lock (avoids deadlock
725    /// when called from record_success/record_failure which hold the lock).
726    fn transition_locked(
727        state: &mut CircuitState,
728        new_state: CircuitState,
729        failure_count: &AtomicU64,
730        success_count: &AtomicU64,
731        last_state_change: &Mutex<Instant>,
732        total_trips: &AtomicU64,
733    ) {
734        let old_state = *state;
735        if old_state != new_state {
736            *state = new_state;
737            *last_state_change.lock() = Instant::now();
738
739            // Reset counters on transition
740            failure_count.store(0, Ordering::Relaxed);
741            success_count.store(0, Ordering::Relaxed);
742
743            // Track trips
744            if new_state == CircuitState::Open {
745                total_trips.fetch_add(1, Ordering::Relaxed);
746            }
747        }
748    }
749}
750
751/// Recovery action for a failed node
752#[derive(Debug, Clone)]
753pub enum RecoveryAction {
754    /// Reroute traffic through alternate path
755    Reroute {
756        /// Node IDs forming the alternate path
757        via: Vec<u64>,
758    },
759    /// Retry with backoff
760    Retry {
761        /// Delay before retry in milliseconds
762        delay_ms: u64,
763    },
764    /// Drop and notify
765    Drop {
766        /// Reason for dropping the message
767        reason: String,
768    },
769    /// Queue for later delivery
770    Queue,
771}
772
773/// Recovery statistics
774#[derive(Debug, Clone, Default)]
775pub struct RecoveryStats {
776    /// Reroutes performed
777    pub reroutes: u64,
778    /// Retries performed
779    pub retries: u64,
780    /// Packets dropped
781    pub dropped: u64,
782    /// Packets queued
783    pub queued: u64,
784    /// Average recovery time (ms)
785    pub avg_recovery_ms: u64,
786}
787
788/// Recovery manager for handling node failures.
789pub struct RecoveryManager {
790    /// Failed nodes and their recovery state
791    failed_nodes: DashMap<u64, FailedNodeState>,
792    /// Pending recovery queue
793    recovery_queue: Mutex<VecDeque<(u64, Instant)>>,
794    /// Stats
795    reroutes: AtomicU64,
796    retries: AtomicU64,
797    dropped: AtomicU64,
798    queued: AtomicU64,
799    total_recovery_time_ms: AtomicU64,
800    recovery_count: AtomicU64,
801}
802
803#[derive(Debug)]
804struct FailedNodeState {
805    /// When failure was detected
806    failed_at: Instant,
807    /// Retry count
808    retry_count: u32,
809    /// Alternate routes
810    alternates: Vec<u64>,
811}
812
813impl RecoveryManager {
814    /// Create a new recovery manager
815    pub fn new() -> Self {
816        Self {
817            failed_nodes: DashMap::new(),
818            recovery_queue: Mutex::new(VecDeque::new()),
819            reroutes: AtomicU64::new(0),
820            retries: AtomicU64::new(0),
821            dropped: AtomicU64::new(0),
822            queued: AtomicU64::new(0),
823            total_recovery_time_ms: AtomicU64::new(0),
824            recovery_count: AtomicU64::new(0),
825        }
826    }
827
828    /// Handle a node failure
829    pub fn on_failure(&self, node_id: u64, alternates: Vec<u64>) -> RecoveryAction {
830        // Repeat failures must NOT reset `failed_at` or
831        // `retry_count`. A flapping peer that fails, gets one or
832        // more retries, then fails again would otherwise have its
833        // retry budget restored from zero each time and never
834        // reach `max_retries` in `get_action`. Preserve the
835        // existing state on a repeat; refresh `alternates` so a
836        // newly-discovered reroute path takes effect.
837        self.failed_nodes
838            .entry(node_id)
839            .and_modify(|s| {
840                if !alternates.is_empty() {
841                    s.alternates = alternates.clone();
842                }
843            })
844            .or_insert_with(|| FailedNodeState {
845                failed_at: Instant::now(),
846                retry_count: 0,
847                alternates: alternates.clone(),
848            });
849
850        if !alternates.is_empty() {
851            self.reroutes.fetch_add(1, Ordering::Relaxed);
852            RecoveryAction::Reroute { via: alternates }
853        } else {
854            self.queued.fetch_add(1, Ordering::Relaxed);
855            self.recovery_queue
856                .lock()
857                .push_back((node_id, Instant::now()));
858            RecoveryAction::Queue
859        }
860    }
861
862    /// Handle node recovery
863    pub fn on_recovery(&self, node_id: u64) {
864        if let Some((_, state)) = self.failed_nodes.remove(&node_id) {
865            let recovery_time = state.failed_at.elapsed().as_millis() as u64;
866            self.total_recovery_time_ms
867                .fetch_add(recovery_time, Ordering::Relaxed);
868            self.recovery_count.fetch_add(1, Ordering::Relaxed);
869        }
870    }
871
872    /// Get recovery action for a node
873    pub fn get_action(&self, node_id: u64, max_retries: u32) -> RecoveryAction {
874        if let Some(mut state) = self.failed_nodes.get_mut(&node_id) {
875            if !state.alternates.is_empty() {
876                return RecoveryAction::Reroute {
877                    via: state.alternates.clone(),
878                };
879            }
880
881            if state.retry_count < max_retries {
882                state.retry_count += 1;
883                self.retries.fetch_add(1, Ordering::Relaxed);
884                let delay = 100 * (1 << state.retry_count.min(6)); // Exponential backoff
885                return RecoveryAction::Retry { delay_ms: delay };
886            }
887
888            self.dropped.fetch_add(1, Ordering::Relaxed);
889            RecoveryAction::Drop {
890                reason: "max retries exceeded".into(),
891            }
892        } else {
893            // Node not in failed list — caller asked for an action
894            // on a node we don't track as failed. Pre-fix this
895            // returned `Retry { delay_ms: 0 }`, which a caller
896            // dutifully respecting the delay would busy-loop on.
897            // The semantically-cleanest answer is "no action
898            // needed, treat as healthy," but the variant doesn't
899            // exist. Return the same 100ms first-backoff step the
900            // failed-node path uses on its first retry, so the
901            // caller paces itself even when get_action was called
902            // by mistake on a healthy node.
903            RecoveryAction::Retry { delay_ms: 100 }
904        }
905    }
906
907    /// Check if a node is failed
908    pub fn is_failed(&self, node_id: u64) -> bool {
909        self.failed_nodes.contains_key(&node_id)
910    }
911
912    /// Get statistics
913    pub fn stats(&self) -> RecoveryStats {
914        let count = self.recovery_count.load(Ordering::Relaxed);
915        let total_time = self.total_recovery_time_ms.load(Ordering::Relaxed);
916        let avg = total_time.checked_div(count).unwrap_or(0);
917
918        RecoveryStats {
919            reroutes: self.reroutes.load(Ordering::Relaxed),
920            retries: self.retries.load(Ordering::Relaxed),
921            dropped: self.dropped.load(Ordering::Relaxed),
922            queued: self.queued.load(Ordering::Relaxed),
923            avg_recovery_ms: avg,
924        }
925    }
926
927    /// Get failed node count
928    pub fn failed_count(&self) -> usize {
929        self.failed_nodes.len()
930    }
931}
932
933impl Default for RecoveryManager {
934    fn default() -> Self {
935        Self::new()
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    #[test]
944    fn test_failure_detector_basic() {
945        let detector = FailureDetector::with_config(FailureDetectorConfig {
946            timeout: Duration::from_millis(100),
947            miss_threshold: 2,
948            suspicion_threshold: 1,
949            cleanup_interval: Duration::from_secs(60),
950        });
951
952        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
953        detector.heartbeat(0x1234, addr);
954
955        assert_eq!(detector.status(0x1234), NodeStatus::Healthy);
956        assert_eq!(detector.node_count(), 1);
957    }
958
959    /// node_count() / stats().nodes_tracked read an O(1) counter that must
960    /// track the map across new heartbeats, duplicate heartbeats (no growth),
961    /// and removal.
962    #[test]
963    fn node_count_tracks_heartbeats_and_removal() {
964        let detector = FailureDetector::new();
965        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
966
967        detector.heartbeat(1, addr);
968        detector.heartbeat(2, addr);
969        detector.heartbeat(3, addr);
970        assert_eq!(detector.node_count(), 3);
971        assert_eq!(detector.stats().nodes_tracked, 3);
972
973        // Duplicate heartbeat for an existing node must not grow the count.
974        detector.heartbeat(1, addr);
975        assert_eq!(detector.node_count(), 3, "re-heartbeat must not grow count");
976
977        detector.remove(2);
978        assert_eq!(detector.node_count(), 2);
979        assert_eq!(detector.stats().nodes_tracked, 2);
980
981        // Removing an absent node is a no-op for the counter.
982        detector.remove(999);
983        assert_eq!(detector.node_count(), 2);
984    }
985
986    #[test]
987    fn test_failure_detector_failure() {
988        // Timings: timeout=100ms, sleeps=150ms. `missed_count`
989        // computes `elapsed / timeout`, so after 150ms we
990        // expect 1 miss → Suspected. After 300ms we expect 3
991        // misses → Failed. Wider ratio than the original
992        // (10ms / 15ms) because OS scheduler slippage + deps
993        // that pull in larger runtimes (hyper / igd-next for
994        // the `port-mapping` feature) can add several-ms jitter
995        // on top of a 15ms sleep, which was enough to push
996        // `missed_count` from 1 into 2 (i.e. Failed) after the
997        // first sleep — false positive on the Suspected assert.
998        let detector = FailureDetector::with_config(FailureDetectorConfig {
999            timeout: Duration::from_millis(100),
1000            miss_threshold: 2,
1001            suspicion_threshold: 1,
1002            cleanup_interval: Duration::from_secs(60),
1003        });
1004
1005        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1006        detector.heartbeat(0x1234, addr);
1007
1008        // Wait for timeout (~1.5× the timeout → 1 miss).
1009        std::thread::sleep(Duration::from_millis(150));
1010
1011        // First check - should be suspected
1012        detector.check_all();
1013        assert_eq!(detector.status(0x1234), NodeStatus::Suspected);
1014
1015        // Wait more (total ~300ms → 3 misses → Failed).
1016        std::thread::sleep(Duration::from_millis(150));
1017
1018        // Second check - should be failed
1019        let failed = detector.check_all();
1020        assert_eq!(failed.len(), 1);
1021        assert_eq!(failed[0], 0x1234);
1022        assert_eq!(detector.status(0x1234), NodeStatus::Failed);
1023    }
1024
1025    #[test]
1026    fn test_failure_detector_recovery() {
1027        let detector = FailureDetector::with_config(FailureDetectorConfig {
1028            timeout: Duration::from_millis(10),
1029            miss_threshold: 1,
1030            suspicion_threshold: 1,
1031            cleanup_interval: Duration::from_secs(60),
1032        });
1033
1034        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1035        detector.heartbeat(0x1234, addr);
1036
1037        std::thread::sleep(Duration::from_millis(15));
1038        detector.check_all();
1039        assert_eq!(detector.status(0x1234), NodeStatus::Failed);
1040
1041        // Recovery
1042        detector.heartbeat(0x1234, addr);
1043        assert_eq!(detector.status(0x1234), NodeStatus::Healthy);
1044
1045        let stats = detector.stats();
1046        assert_eq!(stats.total_failures, 1);
1047        assert_eq!(stats.total_recoveries, 1);
1048    }
1049
1050    #[test]
1051    fn test_failure_detector_elapsed_based_missed_count() {
1052        // Regression: check() incremented missed_count by 1 per call regardless
1053        // of elapsed time. If check_all() ran infrequently, a node could stay
1054        // healthy much longer than the configured timeout. Now missed_count is
1055        // computed from elapsed / timeout.
1056        let detector = FailureDetector::with_config(FailureDetectorConfig {
1057            timeout: Duration::from_millis(10),
1058            miss_threshold: 3,
1059            suspicion_threshold: 2,
1060            cleanup_interval: Duration::from_secs(60),
1061        });
1062
1063        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1064        detector.heartbeat(0x1234, addr);
1065
1066        // Wait long enough that multiple timeouts have elapsed
1067        std::thread::sleep(Duration::from_millis(35));
1068
1069        // A single check_all() call should detect the node as failed
1070        // because ~35ms / 10ms = 3 missed heartbeats >= miss_threshold(3).
1071        // With the old code (increment by 1), this would only be missed_count=1.
1072        let failed = detector.check_all();
1073        assert_eq!(
1074            detector.status(0x1234),
1075            NodeStatus::Failed,
1076            "node should be Failed after 3+ timeout intervals, even with one check call"
1077        );
1078        assert_eq!(failed.len(), 1);
1079    }
1080
1081    #[test]
1082    fn test_loss_simulator() {
1083        let sim = LossSimulator::new(0.5);
1084
1085        let mut dropped = 0;
1086        for _ in 0..1000 {
1087            if sim.should_drop() {
1088                dropped += 1;
1089            }
1090        }
1091
1092        // Should be roughly 50% (allow wide margin for randomness)
1093        assert!(dropped > 300 && dropped < 700);
1094    }
1095
1096    #[test]
1097    fn test_loss_simulator_burst() {
1098        let sim = LossSimulator::new(0.0).with_bursts(0.1, 5);
1099
1100        let mut total_bursts = 0;
1101        let mut in_burst = false;
1102        for _ in 0..1000 {
1103            if sim.should_drop() {
1104                if !in_burst {
1105                    in_burst = true;
1106                    total_bursts += 1;
1107                }
1108            } else {
1109                in_burst = false;
1110            }
1111        }
1112
1113        // Should have had some bursts
1114        assert!(total_bursts > 0);
1115    }
1116
1117    #[test]
1118    fn test_burst_drops_exactly_burst_length_packets() {
1119        // Regression: a burst starting dropped the triggering packet AND then
1120        // burst_length more, for burst_length + 1 total drops per burst.
1121        //
1122        // We verify by directly inspecting burst_remaining after triggering.
1123        // With burst_prob = 1.0, the first call always starts a burst.
1124        let burst_len = 5u32;
1125        let sim = LossSimulator::new(0.0).with_bursts(1.0, burst_len);
1126
1127        // First call: triggers burst, drops the triggering packet.
1128        assert!(sim.should_drop());
1129        // burst_remaining should be burst_length - 1 (since the trigger was the 1st drop)
1130        let remaining = sim.burst_remaining.load(Ordering::Relaxed);
1131        assert_eq!(
1132            remaining,
1133            (burst_len - 1) as u64,
1134            "after trigger, burst_remaining should be burst_length - 1, \
1135             not burst_length (which would cause burst_length + 1 total drops)"
1136        );
1137
1138        // Drain the remaining burst
1139        for _ in 0..remaining {
1140            assert!(sim.should_drop());
1141        }
1142
1143        // After exactly burst_length total drops, burst_remaining should be 0
1144        assert_eq!(sim.burst_remaining.load(Ordering::Relaxed), 0);
1145        assert_eq!(sim.total_dropped.load(Ordering::Relaxed), burst_len as u64);
1146    }
1147
1148    #[test]
1149    fn test_circuit_breaker() {
1150        let cb = CircuitBreaker::new(3, 2, Duration::from_millis(50));
1151
1152        assert_eq!(cb.state(), CircuitState::Closed);
1153        assert!(cb.allow());
1154
1155        // Trip the breaker
1156        cb.record_failure();
1157        cb.record_failure();
1158        cb.record_failure();
1159
1160        assert_eq!(cb.state(), CircuitState::Open);
1161        assert!(!cb.allow());
1162
1163        // Wait for reset timeout
1164        std::thread::sleep(Duration::from_millis(60));
1165
1166        // Should transition to half-open
1167        assert!(cb.allow());
1168        assert_eq!(cb.state(), CircuitState::HalfOpen);
1169
1170        // Successes should close it
1171        cb.record_success();
1172        cb.record_success();
1173        assert_eq!(cb.state(), CircuitState::Closed);
1174    }
1175
1176    #[test]
1177    fn test_regression_loss_simulator_burst_no_underflow() {
1178        // Regression: concurrent should_drop() calls could race on
1179        // burst_remaining decrement, wrapping u64 to MAX. The fix uses
1180        // compare_exchange_weak (CAS loop) instead of fetch_sub.
1181        use std::sync::Arc;
1182
1183        let sim = Arc::new(LossSimulator::new(0.0).with_bursts(0.3, 10));
1184        let threads: Vec<_> = (0..8)
1185            .map(|_| {
1186                let sim = Arc::clone(&sim);
1187                std::thread::spawn(move || {
1188                    for _ in 0..5_000 {
1189                        sim.should_drop();
1190                    }
1191                })
1192            })
1193            .collect();
1194
1195        for t in threads {
1196            t.join().unwrap();
1197        }
1198
1199        let (total, dropped) = sim.stats();
1200        // burst_remaining should never have wrapped to u64::MAX, so
1201        // dropped can never exceed total.
1202        assert!(
1203            dropped <= total,
1204            "dropped ({dropped}) must not exceed total ({total}) — \
1205             would indicate burst_remaining underflow"
1206        );
1207        // Sanity: we actually ran packets
1208        assert_eq!(total, 8 * 5_000);
1209    }
1210
1211    #[test]
1212    fn test_regression_circuit_breaker_concurrent_transitions() {
1213        // Regression: record_failure/record_success read state then
1214        // transitioned without holding the lock, allowing TOCTOU races
1215        // that could corrupt state. The fix holds the write lock across
1216        // the entire read-decide-transition path.
1217        use std::sync::Arc;
1218
1219        let cb = Arc::new(CircuitBreaker::new(3, 2, Duration::from_millis(10)));
1220
1221        let threads: Vec<_> = (0..8)
1222            .map(|i| {
1223                let cb = Arc::clone(&cb);
1224                std::thread::spawn(move || {
1225                    for _ in 0..2_000 {
1226                        if i % 2 == 0 {
1227                            cb.record_failure();
1228                        } else {
1229                            cb.record_success();
1230                        }
1231                    }
1232                })
1233            })
1234            .collect();
1235
1236        for t in threads {
1237            t.join().unwrap();
1238        }
1239
1240        // State must be one of the valid variants (not corrupted)
1241        let state = cb.state();
1242        assert!(
1243            state == CircuitState::Closed
1244                || state == CircuitState::Open
1245                || state == CircuitState::HalfOpen,
1246            "circuit breaker state is invalid after concurrent access"
1247        );
1248        // total_trips should be reasonable (not wildly inflated)
1249        let trips = cb.total_trips();
1250        // With 4 failure threads * 2000 calls, at most 8000 trips possible
1251        assert!(
1252            trips <= 8_000,
1253            "total_trips ({trips}) is unreasonably high, suggests corruption"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_regression_allow_does_not_undo_reset() {
1259        // Regression: allow() previously read state under the read lock,
1260        // dropped it, then called transition_to(HalfOpen) without
1261        // re-checking. A reset() that ran in that gap (transition_to
1262        // Closed) was silently overwritten when allow()'s transition_to
1263        // re-acquired the write lock and stamped HalfOpen on top.
1264        //
1265        // Fix: allow() holds the write lock across the read-decide-
1266        // transition path, so a state change between the fast-path read
1267        // and the slow-path write lock is observed before any
1268        // transition runs.
1269        //
1270        // The test repeatedly trips the breaker to Open, then races
1271        // allow() (in an observer thread) against reset() (on the main
1272        // thread). The reset_timeout is 1ns so allow() always sees the
1273        // timeout as elapsed and would transition to HalfOpen if it
1274        // could. Final state should always be Closed: either reset()
1275        // ran "after" allow()'s transition (write-lock serialization
1276        // guarantees Closed wins), or it ran "before" and allow()
1277        // observed Closed under the write lock and skipped the
1278        // transition. With the bug, some trials end in HalfOpen.
1279        use std::sync::atomic::{AtomicU8, Ordering};
1280        use std::sync::Arc;
1281        use std::thread;
1282
1283        const TRIALS: u32 = 5_000;
1284
1285        let cb = Arc::new(CircuitBreaker::new(1, 1, Duration::from_nanos(1)));
1286        let signal = Arc::new(AtomicU8::new(0)); // 0=idle, 1=run, 2=stop
1287
1288        let cb_observer = cb.clone();
1289        let signal_observer = signal.clone();
1290        let observer = thread::spawn(move || loop {
1291            match signal_observer.load(Ordering::Acquire) {
1292                0 => std::hint::spin_loop(),
1293                1 => {
1294                    cb_observer.allow();
1295                    signal_observer.store(0, Ordering::Release);
1296                }
1297                _ => return,
1298            }
1299        });
1300
1301        let mut bug_count = 0u32;
1302        for _ in 0..TRIALS {
1303            // Trip Closed → Open (failure_threshold = 1).
1304            cb.record_failure();
1305            assert_eq!(cb.state(), CircuitState::Open);
1306
1307            // Hand off to observer; race reset() against its allow().
1308            signal.store(1, Ordering::Release);
1309            cb.reset();
1310            while signal.load(Ordering::Acquire) != 0 {
1311                std::hint::spin_loop();
1312            }
1313
1314            if cb.state() != CircuitState::Closed {
1315                bug_count += 1;
1316                // Recover for the next trial so the assertion below
1317                // surfaces the race count, not a stuck state.
1318                cb.reset();
1319            }
1320        }
1321
1322        signal.store(2, Ordering::Release);
1323        observer.join().unwrap();
1324
1325        assert_eq!(
1326            bug_count, 0,
1327            "{bug_count} of {TRIALS} trials ended in non-Closed state — \
1328             allow() transitioned to HalfOpen on top of a fresh reset()"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_recovery_manager() {
1334        let mgr = RecoveryManager::new();
1335
1336        // Failure with alternates
1337        let action = mgr.on_failure(0x1234, vec![0x5678, 0x9ABC]);
1338        match action {
1339            RecoveryAction::Reroute { via } => {
1340                assert_eq!(via, vec![0x5678, 0x9ABC]);
1341            }
1342            _ => panic!("expected reroute"),
1343        }
1344
1345        // Failure without alternates
1346        let action = mgr.on_failure(0x2222, vec![]);
1347        match action {
1348            RecoveryAction::Queue => {}
1349            _ => panic!("expected queue"),
1350        }
1351
1352        assert!(mgr.is_failed(0x1234));
1353        assert!(mgr.is_failed(0x2222));
1354
1355        // Recovery
1356        mgr.on_recovery(0x1234);
1357        assert!(!mgr.is_failed(0x1234));
1358
1359        let stats = mgr.stats();
1360        assert_eq!(stats.reroutes, 1);
1361        assert_eq!(stats.queued, 1);
1362    }
1363
1364    /// Pin: a flapping peer (fail, retry, fail, retry, ...) must
1365    /// reach `max_retries` and be dropped. Pre-fix `on_failure`
1366    /// unconditionally re-`insert`-ed the node, resetting
1367    /// `retry_count` to 0 every time, so `get_action` never saw
1368    /// the count climb past 1 and the node was retried forever.
1369    #[test]
1370    fn on_failure_preserves_retry_count_on_repeat() {
1371        let mgr = RecoveryManager::new();
1372        let node = 0x42u64;
1373        let max_retries = 3u32;
1374
1375        // Failure 1 → enters the failed list with retry_count=0,
1376        // no alternates so action is Queue.
1377        let action = mgr.on_failure(node, vec![]);
1378        assert!(matches!(action, RecoveryAction::Queue));
1379
1380        // Drive `get_action` to bump retry_count up to the cap.
1381        for expected_count in 1..=max_retries {
1382            match mgr.get_action(node, max_retries) {
1383                RecoveryAction::Retry { .. } => {}
1384                other => panic!(
1385                    "expected Retry on attempt {} (count would become {}), got {:?}",
1386                    expected_count, expected_count, other
1387                ),
1388            }
1389        }
1390
1391        // Now simulate a re-failure WITHOUT recovery in between
1392        // (the flapping case). Pre-fix this re-`insert`-ed and
1393        // wiped `retry_count` back to 0, restoring an unbounded
1394        // retry budget.
1395        let _ = mgr.on_failure(node, vec![]);
1396
1397        // The very next `get_action` must return Drop — the
1398        // budget set by the prior Retries should still apply.
1399        match mgr.get_action(node, max_retries) {
1400            RecoveryAction::Drop { .. } => {}
1401            other => panic!(
1402                "expected Drop after exhausting retries across a flap; got {:?} \
1403                 (pre-fix on_failure reset retry_count to 0 on repeat)",
1404                other
1405            ),
1406        }
1407    }
1408
1409    /// Pin: a repeat `on_failure` carrying newly-discovered
1410    /// alternates must update the alternates list (so a node
1411    /// that was unreachable can become reroutable when topology
1412    /// changes), but must NOT reset `retry_count`.
1413    #[test]
1414    fn on_failure_repeat_updates_alternates_without_resetting_count() {
1415        let mgr = RecoveryManager::new();
1416        let node = 0x99u64;
1417        let max_retries = 2u32;
1418
1419        // First failure with no alternates → Queue.
1420        let _ = mgr.on_failure(node, vec![]);
1421        // Bump the retry count once via get_action.
1422        let _ = mgr.get_action(node, max_retries);
1423
1424        // Second failure now learns of an alternate — semantics
1425        // should switch to Reroute, but the prior retry_count
1426        // must be preserved.
1427        let action = mgr.on_failure(node, vec![0xDEAD]);
1428        match action {
1429            RecoveryAction::Reroute { via } => assert_eq!(via, vec![0xDEAD]),
1430            other => panic!("expected Reroute, got {:?}", other),
1431        }
1432
1433        // One more get_action without alternates path: clear
1434        // alternates and confirm retry budget is exhausted at
1435        // max_retries (count was 1 after first get_action; one
1436        // more retry brings it to 2; the next call must Drop).
1437        if let Some(mut s) = mgr.failed_nodes.get_mut(&node) {
1438            s.alternates.clear();
1439        }
1440        let _ = mgr.get_action(node, max_retries); // count → 2 (== max)
1441        match mgr.get_action(node, max_retries) {
1442            RecoveryAction::Drop { .. } => {}
1443            other => panic!("expected Drop after exhausting retries; got {:?}", other),
1444        }
1445    }
1446
1447    /// Regression: BUG_REPORT.md #14 — `heartbeat` and `check_all`
1448    /// previously invoked the user-supplied recovery / failure
1449    /// callbacks while still holding the DashMap shard's write
1450    /// lock (`and_modify` / `iter_mut` respectively). A callback
1451    /// that re-entered the failure detector — calling
1452    /// `heartbeat` / `status` / `is_failed` for *any* node — could
1453    /// deadlock if it hashed to the same shard, and at minimum
1454    /// serialized concurrent heartbeats hashing to that shard
1455    /// behind the user code.
1456    ///
1457    /// The fix: collect the "should I notify?" signal inside the
1458    /// closure / loop, drop the shard locks, then fire the
1459    /// callbacks. We pin this by setting a callback that calls
1460    /// back into the detector's `status()` (which acquires a
1461    /// read lock on the same shard). With the bug present, this
1462    /// deadlocks; with the fix, it returns successfully.
1463    #[test]
1464    fn callbacks_run_after_shard_lock_release() {
1465        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
1466        use std::sync::Arc;
1467
1468        let detector = Arc::new(FailureDetector::with_config(FailureDetectorConfig {
1469            timeout: Duration::from_millis(10),
1470            miss_threshold: 1,
1471            suspicion_threshold: 1,
1472            cleanup_interval: Duration::from_secs(60),
1473        }));
1474
1475        let detector_for_cb = Arc::clone(&detector);
1476        let observed = Arc::new(AtomicBool::new(false));
1477        let observed_clone = Arc::clone(&observed);
1478
1479        // The recovery callback re-enters `status()`, which must
1480        // be able to acquire a read lock on the same DashMap
1481        // shard the recovery path is mutating. With the pre-fix
1482        // code (callback under `and_modify`'s write lock), this
1483        // would deadlock on a single-shard DashMap.
1484        let detector_arc = Arc::new(
1485            // Re-create using the constructor that accepts a
1486            // callback. We'll thread it via the public setter.
1487            FailureDetector::with_config(FailureDetectorConfig {
1488                timeout: Duration::from_millis(10),
1489                miss_threshold: 1,
1490                suspicion_threshold: 1,
1491                cleanup_interval: Duration::from_secs(60),
1492            })
1493            .on_recovery(move |id| {
1494                // Re-enter the same detector; observable proof
1495                // we got here without a deadlock.
1496                let _ = detector_for_cb.status(id);
1497                observed_clone.store(true, AtomicOrdering::SeqCst);
1498            }),
1499        );
1500
1501        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1502        // Drive node into Failed state, then heartbeat to recover.
1503        detector_arc.heartbeat(0x4242, addr);
1504        std::thread::sleep(Duration::from_millis(25));
1505        let _ = detector_arc.check_all();
1506        assert_eq!(detector_arc.status(0x4242), NodeStatus::Failed);
1507
1508        // This call would deadlock under the pre-fix code.
1509        detector_arc.heartbeat(0x4242, addr);
1510
1511        assert!(
1512            observed.load(AtomicOrdering::SeqCst),
1513            "recovery callback must have run (and re-entered status()) — \
1514             a deadlock here would manifest as the test hanging (#14)"
1515        );
1516        let _ = detector;
1517    }
1518
1519    /// Pin: `get_action` on a node not in the failed list must
1520    /// return a non-zero retry delay. Pre-fix the unfailed-node
1521    /// branch returned `Retry { delay_ms: 0 }`, which a caller
1522    /// dutifully respecting the delay would busy-loop on,
1523    /// pegging a CPU. The fix returns the same first-step
1524    /// backoff (100ms) the failed-node path uses on retry 1, so
1525    /// the caller paces itself even when `get_action` was
1526    /// called by mistake on a healthy node.
1527    #[test]
1528    fn get_action_on_unfailed_node_does_not_busy_loop() {
1529        let mgr = RecoveryManager::new();
1530        let untracked = 0xDEAD_BEEFu64;
1531
1532        // Sanity: node is not in the failed list.
1533        assert!(
1534            !mgr.is_failed(untracked),
1535            "precondition: node must not be tracked as failed"
1536        );
1537
1538        let action = mgr.get_action(untracked, 3);
1539        match action {
1540            RecoveryAction::Retry { delay_ms } => {
1541                assert!(
1542                    delay_ms > 0,
1543                    "regression: get_action on an unfailed node returned \
1544                     Retry {{ delay_ms: 0 }} — a delay-respecting caller \
1545                     would busy-loop on this and saturate a CPU"
1546                );
1547                assert_eq!(
1548                    delay_ms, 100,
1549                    "first-step backoff should match the failed-node \
1550                     path's retry-1 delay (100ms) so callers pace \
1551                     consistently across both branches"
1552                );
1553            }
1554            other => panic!("unfailed-node branch must return Retry, got {:?}", other),
1555        }
1556    }
1557
1558    /// Circuit-breaker HalfOpen → Open on a single failure.
1559    ///
1560    /// The existing `test_circuit_breaker` covers Closed → Open
1561    /// → HalfOpen → Closed, but never the HalfOpen → Open arm
1562    /// at L646-L656. That arm is the "probe failed, snap back to
1563    /// open" path. A regression here means a half-open probe
1564    /// that fails would NOT trip back to open — a known-broken
1565    /// backend would continue receiving probe traffic indefinitely
1566    /// instead of waiting another reset_timeout cycle.
1567    #[test]
1568    fn circuit_breaker_half_open_failure_trips_back_to_open() {
1569        // `from_nanos(1)` lets `allow()` see the reset_timeout as
1570        // already elapsed without a real-time sleep — same trick
1571        // as `test_regression_allow_does_not_undo_reset`. The
1572        // trade-off is that after the HalfOpen → Open snap-back,
1573        // any further `allow()` call would also see the timeout
1574        // elapsed and immediately transition back to HalfOpen.
1575        // The decisive observable for this regression is the
1576        // state right after `record_failure()`, not what `allow()`
1577        // returns on the next call.
1578        let cb = CircuitBreaker::new(2, 2, Duration::from_nanos(1));
1579
1580        // Open the breaker.
1581        cb.record_failure();
1582        cb.record_failure();
1583        assert_eq!(cb.state(), CircuitState::Open);
1584
1585        // Probe — moves to HalfOpen.
1586        assert!(
1587            cb.allow(),
1588            "expected allow() to admit a probe after reset_timeout"
1589        );
1590        assert_eq!(cb.state(), CircuitState::HalfOpen);
1591
1592        // Single failure in HalfOpen must trip back to Open.
1593        cb.record_failure();
1594        assert_eq!(
1595            cb.state(),
1596            CircuitState::Open,
1597            "HalfOpen + failure must snap back to Open; \
1598             a regression here keeps probing a broken backend",
1599        );
1600    }
1601
1602    /// Pin: the `Default` for `FailureDetectorConfig` is what
1603    /// `FailureDetector::new()` installs, and downstream timing
1604    /// (heartbeat windows, suspicion → failure escalation, the
1605    /// 30s cleanup cadence that frees stale-node memory) depends
1606    /// on the specific values. A refactor that bumps `timeout`
1607    /// to 50s or drops `cleanup_interval` to 3s would silently
1608    /// change failure-detection latency in every default-config
1609    /// caller — pin the load-bearing values.
1610    #[test]
1611    fn failure_detector_config_default_values() {
1612        let cfg = FailureDetectorConfig::default();
1613        assert_eq!(cfg.timeout, Duration::from_secs(5));
1614        assert_eq!(cfg.miss_threshold, 3);
1615        assert_eq!(cfg.suspicion_threshold, 2);
1616        assert_eq!(cfg.cleanup_interval, Duration::from_secs(30));
1617    }
1618
1619    /// `suspected_nodes` / `healthy_nodes` filter the tracked
1620    /// nodes by `NodeStatus`. A regression that swaps the two
1621    /// (or aliases either to `failed_nodes`) would mis-route
1622    /// every operator query — dashboards would render Suspected
1623    /// nodes as Healthy or vice versa.
1624    #[test]
1625    fn suspected_and_healthy_nodes_filter_by_status() {
1626        let detector = FailureDetector::new();
1627        // Seed three nodes in distinct states by manipulating
1628        // the internal `nodes` map directly (the same trick
1629        // existing tests use to stage failure-detector state
1630        // without sleeping out real timeouts).
1631        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1632        detector.heartbeat(1, addr);
1633        detector.heartbeat(2, addr);
1634        detector.heartbeat(3, addr);
1635        // Drop their statuses into known classes.
1636        detector.nodes.get_mut(&2).unwrap().status = NodeStatus::Suspected;
1637        detector.nodes.get_mut(&3).unwrap().status = NodeStatus::Failed;
1638
1639        let mut healthy = detector.healthy_nodes();
1640        healthy.sort_unstable();
1641        assert_eq!(healthy, vec![1]);
1642
1643        let suspected = detector.suspected_nodes();
1644        assert_eq!(suspected, vec![2]);
1645    }
1646
1647    /// `cleanup` is rate-limited by `cleanup_interval`. Two
1648    /// calls within the interval must return early (no nodes
1649    /// scanned, no removals) so a hot loop can't pin the mutex
1650    /// or thrash DashMap iteration.
1651    #[test]
1652    fn cleanup_returns_zero_within_cleanup_interval() {
1653        let detector = FailureDetector::with_config(FailureDetectorConfig {
1654            cleanup_interval: Duration::from_secs(60),
1655            ..Default::default()
1656        });
1657        // Force `last_cleanup` to "just now" so the rate-limit
1658        // gate fires.
1659        *detector.last_cleanup.lock() = Instant::now();
1660        assert_eq!(
1661            detector.cleanup(),
1662            0,
1663            "cleanup called inside the rate-limit window must return 0 without scanning",
1664        );
1665    }
1666
1667    /// `LossSimulator::effective_loss_rate` returns `0.0` on the
1668    /// divide-by-zero guard (no packets observed) and a real
1669    /// ratio once packets have flowed. Operator dashboards
1670    /// reading this on a freshly-started simulator must see 0,
1671    /// not `NaN` from `0/0`.
1672    #[test]
1673    fn loss_simulator_effective_loss_rate_handles_div_by_zero_and_ratio() {
1674        let sim = LossSimulator::new(1.0); // drop everything
1675        assert_eq!(sim.effective_loss_rate(), 0.0, "no packets → 0, not NaN");
1676
1677        // Drive 4 should_drop() calls — with prob 1.0 every
1678        // packet is dropped, so the ratio reads 4/4 = 1.0.
1679        for _ in 0..4 {
1680            let _ = sim.should_drop();
1681        }
1682        let rate = sim.effective_loss_rate();
1683        assert!(
1684            (rate - 1.0).abs() < 1e-6,
1685            "expected loss_rate ≈ 1.0 after 4 drops; got {rate}",
1686        );
1687
1688        // reset() zeroes the counters AND the burst state. The
1689        // post-reset rate is 0/0 → the div-by-zero guard fires
1690        // again.
1691        sim.reset();
1692        assert_eq!(sim.total_packets.load(Ordering::Relaxed), 0);
1693        assert_eq!(sim.total_dropped.load(Ordering::Relaxed), 0);
1694        assert_eq!(sim.burst_remaining.load(Ordering::Relaxed), 0);
1695        assert!(!sim.in_burst.load(Ordering::Relaxed));
1696        assert_eq!(sim.effective_loss_rate(), 0.0);
1697    }
1698}