Skip to main content

ipfrs_network/
bootstrap_coordinator.rs

1//! Bootstrap coordinator for systematic peer discovery
2//!
3//! This module provides production-grade peer discovery beyond the initial
4//! bootstrap list, featuring:
5//! - Priority-based bootstrap peer selection
6//! - Exponential backoff on failures with configurable caps
7//! - Random walk and closest-peer query coordination
8//! - Discovery record tracking with ping-based ranking
9//! - Atomic statistics for lock-free monitoring
10
11use parking_lot::RwLock;
12use std::cmp::Reverse;
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tracing::{debug, info, warn};
18
19// ---------------------------------------------------------------------------
20// BootstrapPeer
21// ---------------------------------------------------------------------------
22
23/// A single bootstrap peer with priority, backoff state, and attempt history.
24#[derive(Debug, Clone)]
25pub struct BootstrapPeer {
26    /// Opaque string peer identifier (e.g. libp2p PeerId base58)
27    pub peer_id: String,
28    /// Multiaddr string for dialling (e.g. "/ip4/1.2.3.4/tcp/4001")
29    pub multiaddr: String,
30    /// Scheduling priority: 255 = highest, 0 = lowest
31    pub priority: u8,
32    /// Wall-clock time of the most recent dial attempt
33    pub last_attempt: Option<Instant>,
34    /// Wall-clock time of the most recent successful connection
35    pub last_success: Option<Instant>,
36    /// Number of consecutive failures since the last success
37    pub consecutive_failures: u32,
38}
39
40impl BootstrapPeer {
41    /// Create a new bootstrap peer with no history.
42    pub fn new(peer_id: impl Into<String>, multiaddr: impl Into<String>, priority: u8) -> Self {
43        Self {
44            peer_id: peer_id.into(),
45            multiaddr: multiaddr.into(),
46            priority,
47            last_attempt: None,
48            last_success: None,
49            consecutive_failures: 0,
50        }
51    }
52
53    /// Returns `true` when the peer has not yet accumulated five consecutive failures.
54    ///
55    /// Peers with five or more consecutive failures are considered unhealthy and
56    /// will not be returned by [`BootstrapCoordinator::next_bootstrap_peer`].
57    #[inline]
58    pub fn is_available(&self) -> bool {
59        self.consecutive_failures < 5
60    }
61
62    /// Computes the minimum wait time before the next dial attempt.
63    ///
64    /// The formula is `min(30s, 2^consecutive_failures seconds)`, giving:
65    /// 0 failures → 1 s, 1 → 2 s, 2 → 4 s, 3 → 8 s, 4 → 16 s, ≥5 → 30 s (unavailable).
66    pub fn backoff_duration(&self) -> Duration {
67        if self.consecutive_failures == 0 {
68            return Duration::from_secs(1);
69        }
70        // 2^n seconds, capped at 30 s.  Use saturating_pow to avoid overflow.
71        let secs = 2u64.saturating_pow(self.consecutive_failures);
72        Duration::from_secs(secs.min(30))
73    }
74
75    /// Returns `true` when the backoff window has elapsed and the peer may be
76    /// attempted again.
77    fn backoff_elapsed(&self) -> bool {
78        match self.last_attempt {
79            None => true,
80            Some(t) => t.elapsed() >= self.backoff_duration(),
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// DiscoveryRecord
87// ---------------------------------------------------------------------------
88
89/// A record for a peer discovered through any mechanism.
90#[derive(Debug, Clone)]
91pub struct DiscoveryRecord {
92    /// Opaque peer identifier string
93    pub peer_id: String,
94    /// All known multiaddrs for this peer
95    pub multiaddrs: Vec<String>,
96    /// How this peer was found (e.g. "bootstrap", "dht", "gossip")
97    pub discovered_via: String,
98    /// Monotonic timestamp of discovery
99    pub discovered_at: Instant,
100    /// Round-trip latency in milliseconds, if measured
101    pub ping_ms: Option<u64>,
102}
103
104impl DiscoveryRecord {
105    /// Create a new discovery record with the current timestamp.
106    pub fn new(
107        peer_id: impl Into<String>,
108        multiaddrs: Vec<String>,
109        discovered_via: impl Into<String>,
110        ping_ms: Option<u64>,
111    ) -> Self {
112        Self {
113            peer_id: peer_id.into(),
114            multiaddrs,
115            discovered_via: discovered_via.into(),
116            discovered_at: Instant::now(),
117            ping_ms,
118        }
119    }
120}
121
122// ---------------------------------------------------------------------------
123// BootstrapStats — atomic counters
124// ---------------------------------------------------------------------------
125
126/// Atomic counters for bootstrap coordinator activity.
127///
128/// All fields are `AtomicU64` so they can be updated from multiple threads
129/// without acquiring a lock.
130#[derive(Debug, Default)]
131pub struct BootstrapStats {
132    /// Total number of dial attempts recorded
133    pub total_attempts: AtomicU64,
134    /// Total number of successful connections
135    pub total_successes: AtomicU64,
136    /// Total number of failed connection attempts
137    pub total_failures: AtomicU64,
138    /// Total number of distinct peers discovered
139    pub total_discovered: AtomicU64,
140}
141
142impl BootstrapStats {
143    /// Create zeroed statistics.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Returns a point-in-time snapshot of all counters.
149    pub fn snapshot(&self) -> BootstrapStatsSnapshot {
150        BootstrapStatsSnapshot {
151            total_attempts: self.total_attempts.load(Ordering::Relaxed),
152            total_successes: self.total_successes.load(Ordering::Relaxed),
153            total_failures: self.total_failures.load(Ordering::Relaxed),
154            total_discovered: self.total_discovered.load(Ordering::Relaxed),
155        }
156    }
157}
158
159/// Owned, copyable snapshot of [`BootstrapStats`].
160#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub struct BootstrapStatsSnapshot {
162    /// Total dial attempts
163    pub total_attempts: u64,
164    /// Total successes
165    pub total_successes: u64,
166    /// Total failures
167    pub total_failures: u64,
168    /// Total distinct peers discovered
169    pub total_discovered: u64,
170}
171
172// ---------------------------------------------------------------------------
173// BootstrapCoordinator
174// ---------------------------------------------------------------------------
175
176/// Coordinates systematic peer discovery for production IPFRS nodes.
177///
178/// The coordinator maintains two distinct data sets:
179/// 1. **Bootstrap peers** — a curated, priority-sorted list of well-known nodes
180///    used for initial and recovery dialling.
181/// 2. **Discovered peers** — all peers learned through any mechanism (DHT
182///    walks, gossip, direct introduction), keyed by peer ID.
183///
184/// # Thread safety
185///
186/// All mutable state is guarded by `parking_lot::RwLock`.  Statistics use
187/// `AtomicU64` for contention-free updates.
188pub struct BootstrapCoordinator {
189    /// Priority-sorted bootstrap peers (descending).
190    bootstrap_peers: RwLock<Vec<BootstrapPeer>>,
191    /// All known peers indexed by peer ID string.
192    discovered: RwLock<HashMap<String, DiscoveryRecord>>,
193    /// Desired minimum number of known peers.
194    pub target_peer_count: usize,
195    /// Atomic counters.
196    stats: Arc<BootstrapStats>,
197}
198
199impl BootstrapCoordinator {
200    /// Create a coordinator with the given target peer count.
201    pub fn new(target_peer_count: usize) -> Self {
202        Self {
203            bootstrap_peers: RwLock::new(Vec::new()),
204            discovered: RwLock::new(HashMap::new()),
205            target_peer_count,
206            stats: Arc::new(BootstrapStats::new()),
207        }
208    }
209
210    /// Create a coordinator with the default target of 20 peers.
211    pub fn with_defaults() -> Self {
212        Self::new(20)
213    }
214
215    // -----------------------------------------------------------------------
216    // Bootstrap peer management
217    // -----------------------------------------------------------------------
218
219    /// Add a bootstrap peer, maintaining descending priority order.
220    ///
221    /// If a peer with the same `peer_id` already exists it is replaced so
222    /// that the caller can update priority or multiaddr without removing it
223    /// first.
224    pub fn add_bootstrap_peer(&self, peer: BootstrapPeer) {
225        let mut peers = self.bootstrap_peers.write();
226        // Replace existing entry with same peer_id if present.
227        if let Some(pos) = peers.iter().position(|p| p.peer_id == peer.peer_id) {
228            peers[pos] = peer;
229        } else {
230            peers.push(peer);
231        }
232        // Re-sort descending by priority so the highest-priority peer comes first.
233        peers.sort_unstable_by_key(|p| Reverse(p.priority));
234        debug!(
235            "bootstrap_coordinator: bootstrap peers count={}",
236            peers.len()
237        );
238    }
239
240    /// Update attempt history for a bootstrap peer identified by `peer_id`.
241    ///
242    /// - On **success**: `consecutive_failures` is reset to 0 and
243    ///   `last_success` is recorded.
244    /// - On **failure**: `consecutive_failures` is incremented.
245    ///
246    /// `last_attempt` is always updated to `Instant::now()`.
247    pub fn record_attempt(&self, peer_id: &str, success: bool) {
248        let mut peers = self.bootstrap_peers.write();
249        if let Some(peer) = peers.iter_mut().find(|p| p.peer_id == peer_id) {
250            peer.last_attempt = Some(Instant::now());
251            if success {
252                peer.consecutive_failures = 0;
253                peer.last_success = Some(Instant::now());
254                self.stats.total_successes.fetch_add(1, Ordering::Relaxed);
255                info!(
256                    "bootstrap_coordinator: success peer_id={} failures_reset",
257                    peer_id
258                );
259            } else {
260                peer.consecutive_failures = peer.consecutive_failures.saturating_add(1);
261                self.stats.total_failures.fetch_add(1, Ordering::Relaxed);
262                warn!(
263                    "bootstrap_coordinator: failure peer_id={} consecutive={}",
264                    peer_id, peer.consecutive_failures
265                );
266            }
267            self.stats.total_attempts.fetch_add(1, Ordering::Relaxed);
268        } else {
269            debug!(
270                "bootstrap_coordinator: record_attempt called for unknown peer_id={}",
271                peer_id
272            );
273        }
274    }
275
276    /// Return the highest-priority bootstrap peer that is both available
277    /// (fewer than 5 consecutive failures) and not currently in its backoff
278    /// window.
279    ///
280    /// Returns `None` when no such peer exists.
281    pub fn next_bootstrap_peer(&self) -> Option<BootstrapPeer> {
282        let peers = self.bootstrap_peers.read();
283        // The list is kept sorted highest priority first.
284        peers
285            .iter()
286            .find(|p| p.is_available() && p.backoff_elapsed())
287            .cloned()
288    }
289
290    // -----------------------------------------------------------------------
291    // Discovery record management
292    // -----------------------------------------------------------------------
293
294    /// Store or update the discovery record for a peer.
295    ///
296    /// If the peer was previously unknown `total_discovered` is incremented.
297    pub fn record_discovery(&self, record: DiscoveryRecord) {
298        let mut discovered = self.discovered.write();
299        let is_new = !discovered.contains_key(&record.peer_id);
300        if is_new {
301            self.stats.total_discovered.fetch_add(1, Ordering::Relaxed);
302            debug!(
303                "bootstrap_coordinator: new peer peer_id={} via={}",
304                record.peer_id, record.discovered_via
305            );
306        }
307        discovered.insert(record.peer_id.clone(), record);
308    }
309
310    /// Return the number of distinct peers currently in the discovery table.
311    pub fn known_peer_count(&self) -> usize {
312        self.discovered.read().len()
313    }
314
315    /// Returns `true` when the known peer count is below `target_peer_count`.
316    pub fn needs_more_peers(&self) -> bool {
317        self.known_peer_count() < self.target_peer_count
318    }
319
320    /// Return up to `n` candidates for dialling, sorted by ascending ping
321    /// (unmeasured peers sort last).
322    ///
323    /// Candidates are selected from the discovery table; the sort key is
324    /// `ping_ms` ascending with `None` treated as `u64::MAX`.
325    pub fn candidates_for_dial(&self, n: usize) -> Vec<DiscoveryRecord> {
326        let discovered = self.discovered.read();
327        let mut candidates: Vec<DiscoveryRecord> = discovered.values().cloned().collect();
328        // Sort ascending ping; None (unmeasured) sorts to the end.
329        candidates.sort_unstable_by_key(|r| r.ping_ms.unwrap_or(u64::MAX));
330        candidates.truncate(n);
331        candidates
332    }
333
334    // -----------------------------------------------------------------------
335    // Statistics
336    // -----------------------------------------------------------------------
337
338    /// Return a consistent point-in-time snapshot of all counters.
339    pub fn stats(&self) -> BootstrapStatsSnapshot {
340        self.stats.snapshot()
341    }
342}
343
344impl Default for BootstrapCoordinator {
345    fn default() -> Self {
346        Self::with_defaults()
347    }
348}
349
350// ---------------------------------------------------------------------------
351// Tests
352// ---------------------------------------------------------------------------
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::thread;
358
359    // Helper: build a simple BootstrapPeer
360    fn peer(id: &str, multiaddr: &str, priority: u8) -> BootstrapPeer {
361        BootstrapPeer::new(id, multiaddr, priority)
362    }
363
364    // Helper: build a DiscoveryRecord with optional ping
365    fn discovery(id: &str, via: &str, ping: Option<u64>) -> DiscoveryRecord {
366        DiscoveryRecord::new(id, vec!["/ip4/127.0.0.1/tcp/4001".to_string()], via, ping)
367    }
368
369    // -----------------------------------------------------------------------
370    // BootstrapPeer unit tests
371    // -----------------------------------------------------------------------
372
373    #[test]
374    fn test_backoff_duration_zero_failures() {
375        let p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
376        assert_eq!(p.backoff_duration(), Duration::from_secs(1));
377    }
378
379    #[test]
380    fn test_backoff_duration_exponential() {
381        let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
382        // 2^1 = 2, 2^2 = 4, 2^3 = 8
383        for (failures, expected_secs) in [(1u32, 2u64), (2, 4), (3, 8), (4, 16)] {
384            p.consecutive_failures = failures;
385            assert_eq!(
386                p.backoff_duration(),
387                Duration::from_secs(expected_secs),
388                "failures={failures}"
389            );
390        }
391    }
392
393    #[test]
394    fn test_backoff_duration_capped_at_30s() {
395        let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
396        // 2^5 = 32 > 30, should be capped
397        p.consecutive_failures = 5;
398        assert_eq!(p.backoff_duration(), Duration::from_secs(30));
399        // Large value should also be capped
400        p.consecutive_failures = 100;
401        assert_eq!(p.backoff_duration(), Duration::from_secs(30));
402    }
403
404    #[test]
405    fn test_is_available_true_below_threshold() {
406        let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
407        for failures in 0u32..5 {
408            p.consecutive_failures = failures;
409            assert!(p.is_available(), "failures={failures} should be available");
410        }
411    }
412
413    #[test]
414    fn test_is_available_false_at_threshold() {
415        let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
416        p.consecutive_failures = 5;
417        assert!(!p.is_available());
418        p.consecutive_failures = 10;
419        assert!(!p.is_available());
420    }
421
422    // -----------------------------------------------------------------------
423    // BootstrapCoordinator structural tests
424    // -----------------------------------------------------------------------
425
426    #[test]
427    fn test_add_bootstrap_peer_single() {
428        let coord = BootstrapCoordinator::with_defaults();
429        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
430        let next = coord.next_bootstrap_peer().expect("should have a peer");
431        assert_eq!(next.peer_id, "p1");
432    }
433
434    #[test]
435    fn test_add_bootstrap_peer_ordering_by_priority() {
436        let coord = BootstrapCoordinator::with_defaults();
437        // Insert in low-to-high order; coordinator must return highest first.
438        coord.add_bootstrap_peer(peer("low", "/ip4/1.1.1.1/tcp/4001", 10));
439        coord.add_bootstrap_peer(peer("high", "/ip4/2.2.2.2/tcp/4001", 200));
440        coord.add_bootstrap_peer(peer("mid", "/ip4/3.3.3.3/tcp/4001", 100));
441
442        let next = coord.next_bootstrap_peer().expect("should return peer");
443        assert_eq!(next.peer_id, "high", "highest priority must come first");
444    }
445
446    #[test]
447    fn test_add_bootstrap_peer_replaces_existing() {
448        let coord = BootstrapCoordinator::with_defaults();
449        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 50));
450        // Replace with higher priority
451        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 200));
452        let next = coord.next_bootstrap_peer().expect("should have peer");
453        assert_eq!(next.priority, 200);
454    }
455
456    #[test]
457    fn test_record_attempt_success_resets_failures() {
458        let coord = BootstrapCoordinator::with_defaults();
459        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
460        // Accumulate some failures
461        coord.record_attempt("p1", false);
462        coord.record_attempt("p1", false);
463        coord.record_attempt("p1", false);
464        // Now succeed
465        coord.record_attempt("p1", true);
466
467        let peers = coord.bootstrap_peers.read();
468        let p = peers.iter().find(|p| p.peer_id == "p1").expect("found");
469        assert_eq!(p.consecutive_failures, 0, "success must reset failures");
470        assert!(p.last_success.is_some(), "last_success must be set");
471    }
472
473    #[test]
474    fn test_record_attempt_failure_increments_counter() {
475        let coord = BootstrapCoordinator::with_defaults();
476        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
477        coord.record_attempt("p1", false);
478        coord.record_attempt("p1", false);
479
480        let peers = coord.bootstrap_peers.read();
481        let p = peers.iter().find(|p| p.peer_id == "p1").expect("found");
482        assert_eq!(p.consecutive_failures, 2);
483    }
484
485    #[test]
486    fn test_next_bootstrap_peer_skips_unavailable() {
487        let coord = BootstrapCoordinator::with_defaults();
488        // Add a high-priority peer that will become unavailable
489        coord.add_bootstrap_peer(peer("bad", "/ip4/1.1.1.1/tcp/4001", 255));
490        coord.add_bootstrap_peer(peer("good", "/ip4/2.2.2.2/tcp/4001", 100));
491
492        // Drive "bad" past the 5-failure threshold
493        for _ in 0..5 {
494            coord.record_attempt("bad", false);
495        }
496        // Ensure "bad" is unavailable
497        {
498            let peers = coord.bootstrap_peers.read();
499            let bad = peers.iter().find(|p| p.peer_id == "bad").expect("found");
500            assert!(!bad.is_available());
501        }
502
503        let next = coord
504            .next_bootstrap_peer()
505            .expect("coordinator must skip bad peer");
506        assert_eq!(next.peer_id, "good", "unavailable peer must be skipped");
507    }
508
509    #[test]
510    fn test_next_bootstrap_peer_skips_within_backoff() {
511        let coord = BootstrapCoordinator::with_defaults();
512        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
513        // Record a failure to start the backoff clock (backoff = 2^1 = 2 s)
514        coord.record_attempt("p1", false);
515
516        // Immediately try again — should be skipped because backoff hasn't elapsed
517        // (The last_attempt was set moments ago and backoff is ≥ 2 s)
518        let next = coord.next_bootstrap_peer();
519        assert!(
520            next.is_none(),
521            "peer should be skipped while in backoff window"
522        );
523    }
524
525    #[test]
526    fn test_next_bootstrap_peer_available_after_backoff() {
527        let coord = BootstrapCoordinator::with_defaults();
528        // Use a fresh peer with no history — backoff is 1 s but last_attempt is None,
529        // so it should be immediately available.
530        coord.add_bootstrap_peer(peer("fresh", "/ip4/1.1.1.1/tcp/4001", 100));
531        let next = coord.next_bootstrap_peer();
532        assert!(next.is_some(), "fresh peer with no history should be ready");
533    }
534
535    // -----------------------------------------------------------------------
536    // DiscoveryRecord and known peer count
537    // -----------------------------------------------------------------------
538
539    #[test]
540    fn test_record_discovery_stores_record() {
541        let coord = BootstrapCoordinator::with_defaults();
542        coord.record_discovery(discovery("peer-abc", "bootstrap", Some(12)));
543
544        let discovered = coord.discovered.read();
545        let rec = discovered.get("peer-abc").expect("record should be stored");
546        assert_eq!(rec.discovered_via, "bootstrap");
547        assert_eq!(rec.ping_ms, Some(12));
548    }
549
550    #[test]
551    fn test_known_peer_count() {
552        let coord = BootstrapCoordinator::with_defaults();
553        assert_eq!(coord.known_peer_count(), 0);
554        coord.record_discovery(discovery("p1", "dht", None));
555        coord.record_discovery(discovery("p2", "gossip", Some(5)));
556        assert_eq!(coord.known_peer_count(), 2);
557    }
558
559    #[test]
560    fn test_needs_more_peers_threshold() {
561        let coord = BootstrapCoordinator::new(3);
562        assert!(coord.needs_more_peers());
563        coord.record_discovery(discovery("p1", "dht", None));
564        coord.record_discovery(discovery("p2", "dht", None));
565        assert!(coord.needs_more_peers());
566        coord.record_discovery(discovery("p3", "dht", None));
567        assert!(
568            !coord.needs_more_peers(),
569            "exactly at target — no longer needed"
570        );
571    }
572
573    #[test]
574    fn test_candidates_for_dial_sorted_by_ping() {
575        let coord = BootstrapCoordinator::with_defaults();
576        coord.record_discovery(discovery("slow", "dht", Some(200)));
577        coord.record_discovery(discovery("fast", "dht", Some(10)));
578        coord.record_discovery(discovery("unknown", "dht", None));
579        coord.record_discovery(discovery("medium", "dht", Some(50)));
580
581        let candidates = coord.candidates_for_dial(10);
582        // Verify ascending ping order; None should be last
583        let pings: Vec<Option<u64>> = candidates.iter().map(|r| r.ping_ms).collect();
584        assert_eq!(pings, vec![Some(10), Some(50), Some(200), None]);
585    }
586
587    #[test]
588    fn test_candidates_for_dial_respects_limit() {
589        let coord = BootstrapCoordinator::with_defaults();
590        for i in 0u64..10 {
591            coord.record_discovery(discovery(&format!("peer-{i}"), "dht", Some(i * 10)));
592        }
593        let candidates = coord.candidates_for_dial(3);
594        assert_eq!(candidates.len(), 3);
595    }
596
597    // -----------------------------------------------------------------------
598    // Statistics accumulation
599    // -----------------------------------------------------------------------
600
601    #[test]
602    fn test_stats_accumulation() {
603        let coord = BootstrapCoordinator::with_defaults();
604        coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
605        coord.add_bootstrap_peer(peer("p2", "/ip4/2.2.2.2/tcp/4001", 90));
606
607        coord.record_attempt("p1", true);
608        coord.record_attempt("p1", false);
609        coord.record_attempt("p2", false);
610
611        coord.record_discovery(discovery("d1", "dht", None));
612        coord.record_discovery(discovery("d2", "gossip", Some(5)));
613
614        let snap = coord.stats();
615        assert_eq!(snap.total_attempts, 3);
616        assert_eq!(snap.total_successes, 1);
617        assert_eq!(snap.total_failures, 2);
618        assert_eq!(snap.total_discovered, 2);
619    }
620
621    #[test]
622    fn test_stats_discovery_dedup() {
623        // Recording the same peer twice must only count one new discovery.
624        let coord = BootstrapCoordinator::with_defaults();
625        coord.record_discovery(discovery("p1", "bootstrap", Some(5)));
626        coord.record_discovery(discovery("p1", "dht", Some(3)));
627
628        let snap = coord.stats();
629        assert_eq!(snap.total_discovered, 1);
630        assert_eq!(coord.known_peer_count(), 1);
631    }
632
633    // -----------------------------------------------------------------------
634    // Concurrency smoke test
635    // -----------------------------------------------------------------------
636
637    #[test]
638    fn test_concurrent_record_discovery() {
639        let coord = Arc::new(BootstrapCoordinator::new(1000));
640        let mut handles = Vec::with_capacity(8);
641
642        for thread_idx in 0usize..8 {
643            let c = Arc::clone(&coord);
644            handles.push(thread::spawn(move || {
645                for i in 0u64..50 {
646                    let id = format!("peer-{thread_idx}-{i}");
647                    c.record_discovery(DiscoveryRecord::new(&id, vec![], "gossip", Some(i)));
648                }
649            }));
650        }
651        for h in handles {
652            h.join().expect("thread panicked");
653        }
654
655        assert_eq!(coord.known_peer_count(), 400);
656        assert_eq!(coord.stats().total_discovered, 400);
657    }
658}