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}