Skip to main content

dig_peer_selector/
registry.rs

1//! The dynamic peer registry: `peer_id -> PeerEntry`, fed by churn + DHT candidates, bounded with
2//! lowest-value eviction (SPEC.md §2).
3//!
4//! The registry is **fed, not self-discovered** (SPEC §2.3): gossip pool churn upserts/marks entries,
5//! and DHT candidates passed to `select` upsert cold entries. A reconnecting peer keeps its learned
6//! history (its quality is retained across a disconnect — SPEC §2.3). The registry is bounded (a
7//! resource limit, not a behavior knob) and evicts the lowest-value entries first, preferring never to
8//! evict a connected peer or one with a range in flight — but the bound is a HARD limit (§2.5): once
9//! every remaining entry is connected or in-flight, capacity enforcement falls back to evicting the
10//! lowest-value non-connected entry regardless of its in-flight count, so a dispatch that never settles
11//! cannot pin a slot forever (#179 finding 1). `in_flight` itself is also TTL-reclaimed
12//! ([`Registry::reclaim_stale_in_flight`]) so a genuinely-abandoned dispatch stops being counted busy
13//! at all, independent of capacity pressure.
14
15use std::collections::HashMap;
16
17use dig_dht::CandidateAddr;
18use dig_nat::{PeerId, TraversalKind};
19
20use crate::quality::PeerQuality;
21use crate::types::{Candidate, Provenance};
22
23/// How long an unsettled dispatch may hold `in_flight > 0` before it is presumed abandoned and
24/// reclaimed (#179 finding 1). A `record_outcome` that never arrives (a peer that goes silent after
25/// being dispatched to) must not pin the entry as permanently unevictable (SPEC §2.5, §5.3). Chosen
26/// generously relative to any realistic single-range transfer deadline so a merely-slow-but-alive
27/// transfer is never reclaimed out from under it.
28pub const DISPATCH_TTL_SECS: u64 = 300;
29
30/// One registry entry: identity, reachability, live-link class, provenance, and the learned quality
31/// model (SPEC §2.1). Field names are the API contract; private bookkeeping fields are added below.
32#[derive(Debug, Clone)]
33pub struct PeerEntry {
34    /// The registry key: `peer_id = SHA-256(TLS SPKI DER)` (SPEC §1.3, re-used from `dig-nat`).
35    pub peer_id: PeerId,
36    /// Dial candidates, byte-compatible with `dig-dht` provider records / the L7 `dig.getPeers` shape.
37    pub addresses: Vec<CandidateAddr>,
38    /// The `dig-nat` tier of the live link, if connected — observational only (SPEC §2.1, §7.3).
39    pub connection_class: Option<TraversalKind>,
40    /// How this peer entered the registry (strongest-evidence provenance kept — SPEC §2.2).
41    pub provenance: Provenance,
42    /// The learned quality model (SPEC §3) — MEASURED-only.
43    pub quality: PeerQuality,
44    /// Unix seconds when first registered.
45    pub first_seen: u64,
46    /// Unix seconds of the most recent measured outcome, if any.
47    pub last_outcome_at: Option<u64>,
48    /// Whether the peer currently has a live pool link (SPEC §2.3). A disconnected peer is retained
49    /// (with its quality) but is a weaker eviction-protection candidate.
50    pub connected: bool,
51    /// Whether the peer is banned — ineligible for selection until re-added (SPEC §9.4).
52    pub banned: bool,
53    /// Unix seconds when `quality.in_flight` most recently rose from `0`, or `None` while it is `0`
54    /// (SPEC §2.5, §5.3). Drives TTL reclamation: a dispatch whose outcome never arrives must not pin
55    /// a capacity slot forever (#179 finding 1) — `Registry::reclaim_stale_in_flight` zeros `in_flight`
56    /// once this timestamp ages past [`DISPATCH_TTL_SECS`].
57    pub(crate) in_flight_since: Option<u64>,
58}
59
60impl PeerEntry {
61    /// A fresh, cold entry for `peer_id` first seen at `now` with the given provenance (SPEC §2.3,
62    /// §3.5). Its quality is cold, so its first selection is exploratory (SPEC §4.4-E).
63    pub fn cold(peer_id: PeerId, provenance: Provenance, now: u64) -> Self {
64        PeerEntry {
65            peer_id,
66            addresses: Vec::new(),
67            connection_class: None,
68            provenance,
69            quality: PeerQuality::cold(),
70            first_seen: now,
71            last_outcome_at: None,
72            connected: false,
73            banned: false,
74            in_flight_since: None,
75        }
76    }
77
78    /// Whether this peer is eligible for selection: not banned (SPEC §9.4). (Disconnected peers stay
79    /// eligible — they may be reachable via a fresh dial; only a ban excludes.)
80    pub fn is_eligible(&self) -> bool {
81        !self.banned
82    }
83
84    /// Whether this entry may be evicted under NORMAL pressure: never a connected peer or one with a
85    /// range in flight (SPEC §2.5). `Registry::enforce_capacity` prefers this, but falls back to
86    /// [`PeerEntry::is_force_evictable`] when nothing satisfies it, so the capacity bound always holds.
87    pub fn is_evictable(&self) -> bool {
88        !self.connected && self.quality.in_flight == 0
89    }
90
91    /// Whether this entry may be evicted as a LAST RESORT when no entry satisfies [`Self::is_evictable`]
92    /// (#179 finding 1): still never a currently-connected peer (a live link is never silently dropped),
93    /// but a disconnected peer's stuck `in_flight` no longer protects it. This is what keeps the
94    /// registry's capacity bound a hard limit even when every disconnected entry has an unsettled
95    /// dispatch (SPEC §2.5 "the capacity bound is always enforceable").
96    pub fn is_force_evictable(&self) -> bool {
97        !self.connected
98    }
99
100    /// The most-direct dial address (lowest [`dig_dht::AddressKind::rank`]), if any (SPEC §2.1). The
101    /// registry sorts most-direct-first on demand and never assumes wire order.
102    pub fn preferred_address(&self) -> Option<&CandidateAddr> {
103        self.addresses
104            .iter()
105            .filter(|a| a.kind.is_dialable())
106            .min_by_key(|a| a.kind.rank())
107    }
108
109    /// An eviction *value* score (higher = more valuable = evict later). Combines staleness (age of
110    /// the last measured outcome) with learned quality + confidence, per SPEC §2.5. A never-measured,
111    /// long-idle peer scores lowest and is shed first.
112    pub(crate) fn eviction_value(&self, now: u64) -> f64 {
113        // Quality contribution: measured throughput weighted by confidence + reliability.
114        let tput = self.quality.throughput.value().unwrap_or(0.0);
115        let conf = self.quality.confidence();
116        let rel = self.quality.reliability.rate().unwrap_or(0.0);
117        let quality_value = tput * conf * (0.5 + 0.5 * rel);
118        // Staleness penalty: older last-outcome => lower value. A peer never measured is maximally
119        // stale (uses first_seen as the reference so a fresh cold peer isn't instantly evicted).
120        let reference = self.last_outcome_at.unwrap_or(self.first_seen);
121        let age = now.saturating_sub(reference) as f64;
122        // Decay value by age (a soft, monotone penalty; a day-old idle peer is heavily discounted).
123        let recency = 1.0 / (1.0 + age / 3600.0);
124        quality_value * recency + recency // + recency so ties break toward the fresher peer
125    }
126}
127
128/// Outcome of feeding a churn/candidate event into the registry (for the engine + tests to assert on).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum FeedResult {
131    /// A brand-new (cold) entry was created.
132    Inserted,
133    /// An existing entry was updated (quality preserved).
134    Updated,
135    /// An existing entry was marked disconnected (its quality retained).
136    MarkedDisconnected,
137    /// The event referred to a peer that was not present (a no-op removal).
138    Absent,
139}
140
141/// The peer registry (SPEC §2). Owns the `peer_id -> PeerEntry` map and enforces the capacity bound.
142#[derive(Debug, Default)]
143pub struct Registry {
144    entries: HashMap<PeerId, PeerEntry>,
145    capacity: usize,
146    /// `peer_id`s removed since the last [`Registry::drain_evicted`] call — by capacity eviction
147    /// (§2.5) or explicit [`Registry::remove`] (#179 finding 2). The engine drains this after every
148    /// registry-mutating call and prunes its own side maps (`last_selected`, `dispatched`) for the
149    /// same keys, so a peer that leaves the registry leaves no residue elsewhere.
150    evicted: Vec<PeerId>,
151}
152
153impl Registry {
154    /// A registry bounded at `capacity` entries (a resource limit, SPEC §2.5).
155    pub fn new(capacity: usize) -> Self {
156        Registry {
157            entries: HashMap::new(),
158            capacity: capacity.max(1),
159            evicted: Vec::new(),
160        }
161    }
162
163    /// The number of entries currently held.
164    pub fn len(&self) -> usize {
165        self.entries.len()
166    }
167
168    /// Drain the set of `peer_id`s removed from the registry since the last drain (#179 finding 2):
169    /// by capacity eviction (§2.5) or explicit [`Registry::remove`]. The caller (the engine) MUST
170    /// prune any per-peer side state it keeps outside the registry for every id returned here, so
171    /// nothing outlives the peer's registry entry. Returns an empty `Vec` when nothing left.
172    pub fn drain_evicted(&mut self) -> Vec<PeerId> {
173        std::mem::take(&mut self.evicted)
174    }
175
176    /// Whether the registry is empty.
177    pub fn is_empty(&self) -> bool {
178        self.entries.is_empty()
179    }
180
181    /// A read-only view of an entry.
182    pub fn get(&self, peer: &PeerId) -> Option<&PeerEntry> {
183        self.entries.get(peer)
184    }
185
186    /// A mutable view of an entry (engine-internal).
187    pub(crate) fn get_mut(&mut self, peer: &PeerId) -> Option<&mut PeerEntry> {
188        self.entries.get_mut(peer)
189    }
190
191    /// Iterate all entries (read-only).
192    pub fn iter(&self) -> impl Iterator<Item = &PeerEntry> {
193        self.entries.values()
194    }
195
196    /// Upsert a peer from a **candidate** (DHT/manual feed, SPEC §2.3). A new peer is created cold
197    /// (its first selection is exploratory); an existing peer keeps its learned quality but refreshes
198    /// its addresses/class and, if the new provenance is stronger evidence, its provenance. Returns
199    /// whether an entry was created or updated. Enforces the capacity bound after an insert.
200    pub fn upsert_candidate(
201        &mut self,
202        candidate: &Candidate,
203        provenance: Provenance,
204        now: u64,
205    ) -> FeedResult {
206        let result = match self.entries.get_mut(&candidate.peer_id) {
207            Some(existing) => {
208                if !candidate.addresses.is_empty() {
209                    existing.addresses = candidate.addresses.clone();
210                }
211                if candidate.class.is_some() {
212                    existing.connection_class = candidate.class;
213                }
214                if provenance.evidence() > existing.provenance.evidence() {
215                    existing.provenance = provenance;
216                }
217                FeedResult::Updated
218            }
219            None => {
220                let mut entry = PeerEntry::cold(candidate.peer_id, provenance, now);
221                entry.addresses = candidate.addresses.clone();
222                entry.connection_class = candidate.class;
223                self.seed_class_prior(&mut entry);
224                self.entries.insert(candidate.peer_id, entry);
225                FeedResult::Inserted
226            }
227        };
228        if matches!(result, FeedResult::Inserted) {
229            self.enforce_capacity(now);
230        }
231        result
232    }
233
234    /// Mark a peer **connected** (a live pool link from `PeerAdded`), upserting a cold entry if new
235    /// and preserving any existing learned quality (SPEC §2.3). Provenance `Gossip`.
236    pub fn mark_connected(&mut self, peer: PeerId, provenance: Provenance, now: u64) -> FeedResult {
237        match self.entries.get_mut(&peer) {
238            Some(existing) => {
239                existing.connected = true;
240                existing.banned = false;
241                if provenance.evidence() > existing.provenance.evidence() {
242                    existing.provenance = provenance;
243                }
244                FeedResult::Updated
245            }
246            None => {
247                let mut entry = PeerEntry::cold(peer, provenance, now);
248                entry.connected = true;
249                self.entries.insert(peer, entry);
250                self.enforce_capacity(now);
251                FeedResult::Inserted
252            }
253        }
254    }
255
256    /// Mark a peer **disconnected** (`PeerRemoved`) — its entry AND learned quality are RETAINED so a
257    /// reconnect resumes from history (SPEC §2.3). A `banned` flag makes it ineligible until re-added
258    /// (SPEC §9.4). Returns `Absent` if the peer was unknown.
259    pub fn mark_disconnected(&mut self, peer: &PeerId, banned: bool) -> FeedResult {
260        match self.entries.get_mut(peer) {
261            Some(existing) => {
262                existing.connected = false;
263                if banned {
264                    existing.banned = true;
265                }
266                FeedResult::MarkedDisconnected
267            }
268            None => FeedResult::Absent,
269        }
270    }
271
272    /// Attach / update a live peer's connection class from `dig-nat` (SPEC §5.4, §7.3). Upserts a cold
273    /// entry (provenance `Nat`) if the peer is not yet known, seeding its class prior.
274    pub fn set_connection_class(
275        &mut self,
276        peer: PeerId,
277        class: TraversalKind,
278        now: u64,
279    ) -> FeedResult {
280        match self.entries.get_mut(&peer) {
281            Some(existing) => {
282                existing.connection_class = Some(class);
283                FeedResult::Updated
284            }
285            None => {
286                let mut entry = PeerEntry::cold(peer, Provenance::Nat, now);
287                entry.connection_class = Some(class);
288                self.seed_class_prior(&mut entry);
289                self.entries.insert(peer, entry);
290                self.enforce_capacity(now);
291                FeedResult::Inserted
292            }
293        }
294    }
295
296    /// Explicitly remove a peer (rare; churn usually drives this — SPEC §5.4). A peer with a range in
297    /// flight is NOT removed (removing it would corrupt in-flight accounting). Records the removal for
298    /// [`Registry::drain_evicted`] (#179 finding 2) so the engine prunes its side maps.
299    pub fn remove(&mut self, peer: &PeerId) -> FeedResult {
300        match self.entries.get(peer) {
301            Some(e) if e.quality.in_flight > 0 => FeedResult::Updated, // keep — busy
302            Some(_) => {
303                self.entries.remove(peer);
304                self.evicted.push(*peer);
305                FeedResult::MarkedDisconnected
306            }
307            None => FeedResult::Absent,
308        }
309    }
310
311    /// Record that `count` ranges were dispatched to `peer` (in-flight bump, SPEC §5.3). Stamps
312    /// `in_flight_since` when `in_flight` rises from `0` so a later sweep can tell how long the entry
313    /// has been pinned (#179 finding 1).
314    pub(crate) fn add_in_flight(&mut self, peer: &PeerId, count: u32, now: u64) {
315        if let Some(e) = self.entries.get_mut(peer) {
316            let was_idle = e.quality.in_flight == 0;
317            e.quality.in_flight = e.quality.in_flight.saturating_add(count);
318            if was_idle && e.quality.in_flight > 0 {
319                e.in_flight_since = Some(now);
320            }
321        }
322    }
323
324    /// Record that a range dispatched to `peer` settled (in-flight decrement, SPEC §5.3). Clears
325    /// `in_flight_since` once `in_flight` returns to `0`.
326    pub(crate) fn release_in_flight(&mut self, peer: &PeerId, count: u32) {
327        if let Some(e) = self.entries.get_mut(peer) {
328            e.quality.in_flight = e.quality.in_flight.saturating_sub(count);
329            if e.quality.in_flight == 0 {
330                e.in_flight_since = None;
331            }
332        }
333    }
334
335    /// Reclaim `in_flight` on every entry whose dispatch has aged past [`DISPATCH_TTL_SECS`] without
336    /// settling (#179 finding 1). A `record_outcome` that never arrives — a peer dispatched to and then
337    /// gone silent — must not pin the entry as busy forever: this makes the accounting self-healing
338    /// (independent of capacity pressure) rather than relying solely on force-eviction at capacity.
339    /// Called opportunistically by the engine on each `select`/`rebalance` (SPEC §5.3).
340    pub fn reclaim_stale_in_flight(&mut self, now: u64) {
341        for e in self.entries.values_mut() {
342            if let Some(since) = e.in_flight_since {
343                if now.saturating_sub(since) >= DISPATCH_TTL_SECS {
344                    e.quality.in_flight = 0;
345                    e.in_flight_since = None;
346                }
347            }
348        }
349    }
350
351    /// Seed the connection-class **prior** on a fresh cold entry (SPEC §3.3). A `Relayed` peer starts
352    /// no better than a direct peer (it is not *preferred* before measured); the prior is subordinate
353    /// to measured outcomes. We seed only reliability-neutral priors here — the magnitude of any
354    /// relayed throughput handicap is learned by the scorer (SPEC §4.2), not baked as a constant.
355    fn seed_class_prior(&self, entry: &mut PeerEntry) {
356        // Cold peers get a neutral reliability prior so exploration treats them as uncertain, not
357        // failed. No throughput prior is seeded (leaving throughput unmeasured => exploratory).
358        entry.quality.reliability.seed_prior(0.5);
359    }
360
361    /// Enforce the capacity bound: while over capacity, evict the lowest eviction-value evictable
362    /// entry (never a connected/in-flight peer — SPEC §2.5). Eviction discards learned quality; a
363    /// re-learned peer starts cold again.
364    ///
365    /// First reclaims any TTL-expired `in_flight` (#179 finding 1) so genuinely-abandoned dispatches
366    /// stop protecting their entry before eviction is even considered. If, after that, nothing
367    /// satisfies the normal [`PeerEntry::is_evictable`] (every remaining entry is connected or still
368    /// has a *live* in-flight dispatch), falls back to [`PeerEntry::is_force_evictable`] — shedding the
369    /// lowest-value non-connected entry regardless of in-flight — so the capacity bound is a HARD limit
370    /// that always holds (SPEC §2.5 "the capacity bound is always enforceable"), never merely a target
371    /// an attacker can pin the registry above by feeding unique cold peer_ids that go silent.
372    fn enforce_capacity(&mut self, now: u64) {
373        self.reclaim_stale_in_flight(now);
374        while self.entries.len() > self.capacity {
375            let victim = self
376                .entries
377                .values()
378                .filter(|e| e.is_evictable())
379                .min_by(|a, b| {
380                    a.eviction_value(now)
381                        .partial_cmp(&b.eviction_value(now))
382                        .unwrap_or(std::cmp::Ordering::Equal)
383                })
384                .map(|e| e.peer_id)
385                .or_else(|| {
386                    // Nothing is cleanly evictable — fall back to the lowest-value non-connected entry
387                    // even with a live in-flight count, so the bound is never bypassed (#179 finding 1).
388                    self.entries
389                        .values()
390                        .filter(|e| e.is_force_evictable())
391                        .min_by(|a, b| {
392                            a.eviction_value(now)
393                                .partial_cmp(&b.eviction_value(now))
394                                .unwrap_or(std::cmp::Ordering::Equal)
395                        })
396                        .map(|e| e.peer_id)
397                });
398            match victim {
399                Some(id) => {
400                    self.entries.remove(&id);
401                    self.evicted.push(id);
402                }
403                // Everything left is a currently-connected live link — cannot shed further; the
404                // capacity bound is only exceeded by the count of genuinely-connected peers, which is
405                // bounded by real network topology, not by attacker-fed candidate volume.
406                None => break,
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use dig_dht::AddressKind;
416
417    fn pid(b: u8) -> PeerId {
418        PeerId::from_bytes([b; 32])
419    }
420    fn cand(b: u8) -> Candidate {
421        Candidate::new(pid(b), vec![CandidateAddr::direct("10.0.0.1", 9444)])
422    }
423
424    #[test]
425    fn upsert_candidate_inserts_cold_then_updates_preserving_quality() {
426        let mut r = Registry::new(100);
427        assert_eq!(
428            r.upsert_candidate(&cand(1), Provenance::Dht, 100),
429            FeedResult::Inserted
430        );
431        assert!(r.get(&pid(1)).unwrap().quality.is_cold());
432        // Simulate learning.
433        r.get_mut(&pid(1))
434            .unwrap()
435            .quality
436            .observe_throughput(500.0);
437        r.get_mut(&pid(1)).unwrap().quality.bump_samples();
438        // A re-upsert keeps the learned quality.
439        assert_eq!(
440            r.upsert_candidate(&cand(1), Provenance::Dht, 200),
441            FeedResult::Updated
442        );
443        assert!(!r.get(&pid(1)).unwrap().quality.is_cold());
444    }
445
446    #[test]
447    fn disconnect_retains_entry_and_quality() {
448        let mut r = Registry::new(100);
449        r.mark_connected(pid(1), Provenance::Gossip, 100);
450        r.get_mut(&pid(1))
451            .unwrap()
452            .quality
453            .observe_throughput(700.0);
454        r.get_mut(&pid(1)).unwrap().quality.bump_samples();
455        assert_eq!(
456            r.mark_disconnected(&pid(1), false),
457            FeedResult::MarkedDisconnected
458        );
459        let e = r.get(&pid(1)).unwrap();
460        assert!(!e.connected);
461        assert!(!e.quality.is_cold(), "quality retained across disconnect");
462        assert!(e.is_eligible(), "a plain disconnect stays eligible");
463    }
464
465    #[test]
466    fn banned_peer_is_ineligible_until_reconnect() {
467        let mut r = Registry::new(100);
468        r.mark_connected(pid(1), Provenance::Gossip, 100);
469        r.mark_disconnected(&pid(1), true);
470        assert!(!r.get(&pid(1)).unwrap().is_eligible());
471        // Re-adding (reconnect) clears the ban.
472        r.mark_connected(pid(1), Provenance::Gossip, 200);
473        assert!(r.get(&pid(1)).unwrap().is_eligible());
474    }
475
476    #[test]
477    fn stronger_provenance_wins_weaker_ignored() {
478        let mut r = Registry::new(100);
479        r.upsert_candidate(&cand(1), Provenance::Pex, 100);
480        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
481        assert_eq!(r.get(&pid(1)).unwrap().provenance, Provenance::Dht);
482        // A weaker provenance does not downgrade.
483        r.upsert_candidate(&cand(1), Provenance::Pex, 100);
484        assert_eq!(r.get(&pid(1)).unwrap().provenance, Provenance::Dht);
485    }
486
487    #[test]
488    fn eviction_sheds_lowest_value_never_connected_or_in_flight() {
489        let mut r = Registry::new(2);
490        // Two connected, high-value peers.
491        r.mark_connected(pid(1), Provenance::Gossip, 100);
492        r.mark_connected(pid(2), Provenance::Gossip, 100);
493        // A third cold, disconnected candidate arrives → over capacity → the lowest-value evictable
494        // is shed. The two connected peers are protected, so the new cold one is evicted immediately.
495        r.upsert_candidate(&cand(3), Provenance::Dht, 100);
496        assert!(r.get(&pid(1)).is_some());
497        assert!(r.get(&pid(2)).is_some());
498        assert!(
499            r.get(&pid(3)).is_none(),
500            "connected peers protected; cold candidate evicted"
501        );
502    }
503
504    #[test]
505    fn eviction_prefers_shedding_stale_unmeasured_over_fresh_measured() {
506        let mut r = Registry::new(1);
507        // A measured, recently-active peer.
508        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
509        let e = r.get_mut(&pid(1)).unwrap();
510        e.quality.observe_throughput(900.0);
511        e.quality.bump_samples();
512        e.last_outcome_at = Some(1000);
513        // A stale cold peer arrives much later → over capacity → the stale cold one should go, not the
514        // valuable measured one.
515        r.upsert_candidate(&cand(2), Provenance::Dht, 5000);
516        assert!(
517            r.get(&pid(1)).is_some(),
518            "the valuable measured peer survives"
519        );
520        assert!(r.get(&pid(2)).is_none(), "the stale cold peer is evicted");
521    }
522
523    #[test]
524    fn in_flight_peer_is_not_removed() {
525        let mut r = Registry::new(100);
526        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
527        r.add_in_flight(&pid(1), 2, 100);
528        assert_eq!(r.remove(&pid(1)), FeedResult::Updated); // kept — busy
529        assert!(r.get(&pid(1)).is_some());
530        r.release_in_flight(&pid(1), 2);
531        assert_eq!(r.remove(&pid(1)), FeedResult::MarkedDisconnected);
532        assert!(r.get(&pid(1)).is_none());
533    }
534
535    #[test]
536    fn set_connection_class_upserts_and_attaches() {
537        let mut r = Registry::new(100);
538        assert_eq!(
539            r.set_connection_class(pid(1), TraversalKind::Relayed, 100),
540            FeedResult::Inserted
541        );
542        assert_eq!(
543            r.get(&pid(1)).unwrap().connection_class,
544            Some(TraversalKind::Relayed)
545        );
546    }
547
548    /// #179 HIGH finding 1: a dispatched-but-never-settled peer must not permanently pin a capacity
549    /// slot. Feed more unique cold peers than capacity, bump `in_flight` on each (as `select` does)
550    /// and never release it (the peer went silent) — the registry MUST stay bounded at `capacity`
551    /// because `enforce_capacity` MUST fall back to evicting a disconnected pinned entry when nothing
552    /// is cleanly evictable (SPEC §2.5 "the capacity bound is always enforceable").
553    #[test]
554    fn capacity_bound_holds_even_when_every_entry_is_pinned_by_stale_in_flight() {
555        let capacity = 4;
556        let mut r = Registry::new(capacity);
557        // Feed 3x capacity unique disconnected peers, each dispatched (in_flight bumped) and never
558        // settled — simulating an attacker feeding unique cold peer_ids that go silent post-dispatch.
559        for b in 0..(capacity as u8 * 3) {
560            r.upsert_candidate(&cand(b), Provenance::Dht, 100);
561            r.add_in_flight(&pid(b), 3, 100); // mirrors select_over's headroom-sized bump
562        }
563        assert!(
564            r.len() <= capacity,
565            "registry must stay bounded at {capacity} even when every entry has stuck in_flight; got {}",
566            r.len()
567        );
568    }
569
570    /// #179 HIGH finding 1: a dispatch that never settles must be reclaimable by TTL/epoch so the
571    /// entry becomes cleanly evictable again (not merely force-evicted at capacity, but genuinely
572    /// un-pinned) — SPEC §2.5's "capacity bound is always enforceable" via a real accounting fix, not
573    /// only a last-resort eviction override.
574    #[test]
575    fn stale_in_flight_is_reclaimed_after_ttl_making_the_entry_evictable_again() {
576        let mut r = Registry::new(100);
577        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
578        r.add_in_flight(&pid(1), 5, 100);
579        assert!(
580            !r.get(&pid(1)).unwrap().is_evictable(),
581            "pinned while in flight"
582        );
583        // Long after the dispatch, with no matching release, a TTL sweep reclaims the stale in_flight.
584        r.reclaim_stale_in_flight(100 + DISPATCH_TTL_SECS + 1);
585        assert_eq!(
586            r.get(&pid(1)).unwrap().quality.in_flight,
587            0,
588            "a dispatch that never settled must be reclaimed after its TTL"
589        );
590        assert!(
591            r.get(&pid(1)).unwrap().is_evictable(),
592            "reclaiming stale in_flight must make the entry evictable again"
593        );
594    }
595
596    /// #179 HIGH finding 2: the registry must surface which `peer_id`s left so the engine can prune
597    /// its own side maps (`last_selected`, `dispatched`). Eviction via `enforce_capacity` (triggered by
598    /// an over-capacity `upsert_candidate`) must be drainable.
599    #[test]
600    fn drain_evicted_reports_peers_shed_by_capacity_enforcement() {
601        let mut r = Registry::new(1);
602        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
603        assert!(r.drain_evicted().is_empty(), "no eviction yet");
604        // A second candidate over capacity evicts the first (lowest value, both cold/disconnected).
605        r.upsert_candidate(&cand(2), Provenance::Dht, 200);
606        let evicted = r.drain_evicted();
607        assert_eq!(evicted, vec![pid(1)], "capacity eviction must be reported");
608        // Draining again yields nothing until another eviction happens.
609        assert!(r.drain_evicted().is_empty());
610    }
611
612    /// #179 HIGH finding 2: an explicit `remove` that actually deletes the entry must also be reported
613    /// via `drain_evicted` so the engine prunes its side maps uniformly for every removal path.
614    #[test]
615    fn drain_evicted_reports_peers_shed_by_explicit_remove() {
616        let mut r = Registry::new(100);
617        r.upsert_candidate(&cand(1), Provenance::Dht, 100);
618        r.remove(&pid(1));
619        assert_eq!(r.drain_evicted(), vec![pid(1)]);
620    }
621
622    #[test]
623    fn preferred_address_is_most_direct() {
624        let mut r = Registry::new(100);
625        let c = Candidate::new(
626            pid(1),
627            vec![
628                CandidateAddr {
629                    host: "r".into(),
630                    port: 1,
631                    kind: AddressKind::Reflexive,
632                },
633                CandidateAddr::direct("d", 2),
634            ],
635        );
636        r.upsert_candidate(&c, Provenance::Dht, 100);
637        assert_eq!(
638            r.get(&pid(1)).unwrap().preferred_address().unwrap().kind,
639            AddressKind::Direct
640        );
641    }
642}