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