Skip to main content

bsv/auth/
session_manager.rs

1//! Session management for the BRC-31 authentication protocol.
2//!
3//! SessionManager tracks authenticated sessions by both session nonce
4//! (primary key) and peer identity key (secondary index), supporting
5//! multiple concurrent sessions per identity key.
6//!
7//! Translated from TS SDK SessionManager.ts and Go SDK session_manager.go.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10
11use super::types::PeerSession;
12
13/// Default idle TTL for a session: 15 minutes.
14///
15/// A session untouched for longer than this is considered dead: lookups honor
16/// it via [`SessionManager::get_active_session`] (returning `None` so the peer
17/// simply re-handshakes) and [`SessionManager::reap_idle`] physically evicts it
18/// and frees its replay seen-set. 15 min is *far* larger than the normal
19/// sub-second idle gap between authenticated messages, so a live client never
20/// trips it; a stale/captured `yourNonce` no longer resolves a session forever.
21pub const DEFAULT_SESSION_IDLE_TTL_MS: u64 = 15 * 60 * 1000;
22
23/// Default per-session cap on remembered message nonces (FIFO eviction).
24///
25/// Bounds memory for a long-lived busy session. Only signature-verified
26/// messages ever reach the seen-set, so an attacker cannot inflate it; a real
27/// peer would have to legitimately send this many messages to slide the window,
28/// and the idle TTL frees the whole set on reap.
29pub const DEFAULT_SEEN_NONCE_CAP: usize = 10_000;
30
31/// Outcome of an anti-replay check-and-insert.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum MarkSeen {
34    /// First time this `(session, message-nonce)` pair was seen — accept.
35    Fresh,
36    /// Byte-for-byte replay of an already-seen message nonce — reject.
37    Replay,
38    /// The session no longer exists (never added, or reaped) — reject.
39    SessionGone,
40}
41
42/// Per-session anti-replay + activity bookkeeping.
43///
44/// Kept out of [`PeerSession`] itself so the session stays cheap to `.clone()`
45/// out of the read lock on the hot verify path; this metadata is only touched
46/// under the (brief, `.await`-free) write lock during the replay check.
47struct SessionMeta {
48    /// Wall-clock ms of the last successful activity on this session.
49    last_used_ms: u64,
50    /// Membership set of message nonces for O(1) replay detection.
51    seen: HashSet<String>,
52    /// Insertion order, so the oldest nonce can be FIFO-evicted at the cap.
53    order: VecDeque<String>,
54}
55
56/// Manages authenticated peer sessions with dual-index tracking.
57///
58/// Sessions are indexed by:
59/// - Session nonce (primary key, unique per session)
60/// - Identity key (secondary index, one-to-many)
61///
62/// This allows lookup by either nonce or identity key, with the identity
63/// key lookup returning the "best" session (preferring authenticated ones).
64pub struct SessionManager {
65    /// Maps session_nonce -> PeerSession (primary index).
66    nonce_to_session: HashMap<String, PeerSession>,
67    /// Maps identity_key -> set of session nonces (secondary index).
68    identity_to_nonces: HashMap<String, HashSet<String>>,
69    /// Per-session replay + activity bookkeeping, keyed by session_nonce.
70    /// Created lazily on first `touch`/`mark_message_seen`; dropped whenever the
71    /// session is removed or reaped, which frees its seen-set (memory bound).
72    session_meta: HashMap<String, SessionMeta>,
73    /// Idle TTL in ms (see [`DEFAULT_SESSION_IDLE_TTL_MS`]).
74    idle_ttl_ms: u64,
75    /// Per-session remembered-nonce cap (see [`DEFAULT_SEEN_NONCE_CAP`]).
76    seen_nonce_cap: usize,
77}
78
79impl SessionManager {
80    /// Create a new empty SessionManager with default TTL / seen-set cap.
81    pub fn new() -> Self {
82        Self::with_config(DEFAULT_SESSION_IDLE_TTL_MS, DEFAULT_SEEN_NONCE_CAP)
83    }
84
85    /// Create a new empty SessionManager with an explicit idle TTL and
86    /// per-session replay seen-set cap. Used by tests and by callers that want
87    /// a tighter reaping policy.
88    pub fn with_config(idle_ttl_ms: u64, seen_nonce_cap: usize) -> Self {
89        SessionManager {
90            nonce_to_session: HashMap::new(),
91            identity_to_nonces: HashMap::new(),
92            session_meta: HashMap::new(),
93            idle_ttl_ms,
94            seen_nonce_cap,
95        }
96    }
97
98    /// The configured idle TTL in milliseconds.
99    pub fn idle_ttl_ms(&self) -> u64 {
100        self.idle_ttl_ms
101    }
102
103    /// Add a session to the manager.
104    ///
105    /// Indexes by session_nonce (primary) and peer_identity_key (secondary).
106    /// Does NOT overwrite existing sessions for the same identity key,
107    /// allowing multiple concurrent sessions per peer.
108    pub fn add_session(&mut self, session: PeerSession) {
109        let nonce = session.session_nonce.clone();
110        let identity = session.peer_identity_key.clone();
111
112        self.nonce_to_session.insert(nonce.clone(), session);
113
114        self.identity_to_nonces
115            .entry(identity)
116            .or_default()
117            .insert(nonce);
118    }
119
120    /// Get a session by nonce (immutable reference).
121    pub fn get_session(&self, nonce: &str) -> Option<&PeerSession> {
122        self.nonce_to_session.get(nonce)
123    }
124
125    /// Get a session by nonce (mutable reference).
126    pub fn get_session_mut(&mut self, nonce: &str) -> Option<&mut PeerSession> {
127        self.nonce_to_session.get_mut(nonce)
128    }
129
130    /// Get all sessions for a given identity key.
131    pub fn get_sessions_for_identity(&self, identity_key: &str) -> Vec<&PeerSession> {
132        match self.identity_to_nonces.get(identity_key) {
133            Some(nonces) => nonces
134                .iter()
135                .filter_map(|n| self.nonce_to_session.get(n))
136                .collect(),
137            None => Vec::new(),
138        }
139    }
140
141    /// Get the "best" session for an identity key (prefers authenticated).
142    ///
143    /// Matches TS SDK SessionManager.getSession() behavior: if the identifier
144    /// is a session nonce, returns that exact session. If it is an identity key,
145    /// returns the best (authenticated preferred) session.
146    pub fn get_session_by_identifier(&self, identifier: &str) -> Option<&PeerSession> {
147        // Try as direct nonce first
148        if let Some(session) = self.nonce_to_session.get(identifier) {
149            return Some(session);
150        }
151
152        // Try as identity key
153        let nonces = self.identity_to_nonces.get(identifier)?;
154        let mut best: Option<&PeerSession> = None;
155        for nonce in nonces {
156            if let Some(session) = self.nonce_to_session.get(nonce) {
157                match best {
158                    None => best = Some(session),
159                    Some(b) => {
160                        // Prefer authenticated sessions
161                        if session.is_authenticated && !b.is_authenticated {
162                            best = Some(session);
163                        }
164                    }
165                }
166            }
167        }
168        best
169    }
170
171    /// Check if a session exists for a given nonce.
172    pub fn has_session(&self, nonce: &str) -> bool {
173        self.nonce_to_session.contains_key(nonce)
174    }
175
176    /// Check if any session exists for a given identifier (nonce or identity key).
177    pub fn has_session_by_identifier(&self, identifier: &str) -> bool {
178        if self.nonce_to_session.contains_key(identifier) {
179            return true;
180        }
181        match self.identity_to_nonces.get(identifier) {
182            Some(nonces) => !nonces.is_empty(),
183            None => false,
184        }
185    }
186
187    /// Replace a session at the given nonce.
188    pub fn update_session(&mut self, nonce: &str, session: PeerSession) {
189        // Remove old identity mapping if the identity key changed
190        if let Some(old_session) = self.nonce_to_session.get(nonce) {
191            let old_identity = old_session.peer_identity_key.clone();
192            if old_identity != session.peer_identity_key {
193                if let Some(nonces) = self.identity_to_nonces.get_mut(&old_identity) {
194                    nonces.remove(nonce);
195                    if nonces.is_empty() {
196                        self.identity_to_nonces.remove(&old_identity);
197                    }
198                }
199            }
200        }
201
202        let new_identity = session.peer_identity_key.clone();
203        self.nonce_to_session.insert(nonce.to_string(), session);
204        self.identity_to_nonces
205            .entry(new_identity)
206            .or_default()
207            .insert(nonce.to_string());
208    }
209
210    /// Remove a session by nonce. Returns the removed session if found.
211    ///
212    /// Also drops the session's replay/activity metadata, freeing its seen-set.
213    pub fn remove_session(&mut self, nonce: &str) -> Option<PeerSession> {
214        // Drop replay/activity bookkeeping regardless of session presence.
215        self.session_meta.remove(nonce);
216        if let Some(session) = self.nonce_to_session.remove(nonce) {
217            // Clean up identity index
218            if let Some(nonces) = self.identity_to_nonces.get_mut(&session.peer_identity_key) {
219                nonces.remove(nonce);
220                if nonces.is_empty() {
221                    self.identity_to_nonces.remove(&session.peer_identity_key);
222                }
223            }
224            Some(session)
225        } else {
226            None
227        }
228    }
229
230    // -----------------------------------------------------------------------
231    // Anti-replay + idle-TTL eviction (receiver-side, wire-compatible).
232    //
233    // These are STRICTLY-BETTER additions over the TS reference, which tracks
234    // `lastUpdate` but never reaps and has no per-message replay set. The clock
235    // is injected (`now_ms`) so this module stays pure / wasm-safe — the
236    // network-gated `Peer` supplies the wall clock.
237    // -----------------------------------------------------------------------
238
239    /// Record activity on an existing session, creating its metadata if absent
240    /// and advancing `last_used_ms` to `now_ms`. No-op if the session is unknown.
241    ///
242    /// Call this right after a handshake adds/promotes a session so that idle
243    /// reaping has a baseline, and on every accepted message thereafter.
244    pub fn touch(&mut self, session_nonce: &str, now_ms: u64) {
245        if !self.nonce_to_session.contains_key(session_nonce) {
246            return;
247        }
248        self.session_meta
249            .entry(session_nonce.to_string())
250            .or_insert_with(|| SessionMeta {
251                last_used_ms: now_ms,
252                seen: HashSet::new(),
253                order: VecDeque::new(),
254            })
255            .last_used_ms = now_ms;
256    }
257
258    /// True if the session has recorded activity AND has been idle longer than
259    /// the configured TTL. A session with no recorded activity yet (freshly
260    /// added, not touched) is treated as fresh (not expired).
261    pub fn is_expired(&self, session_nonce: &str, now_ms: u64) -> bool {
262        match self.session_meta.get(session_nonce) {
263            Some(m) => now_ms.saturating_sub(m.last_used_ms) > self.idle_ttl_ms,
264            None => false,
265        }
266    }
267
268    /// Look up a session by identifier (nonce or identity key) honoring the
269    /// idle TTL: returns `None` if the session is missing OR has gone idle past
270    /// the TTL. The hot verify path uses this so a stale/captured `yourNonce`
271    /// stops resolving a live session once the TTL lapses.
272    pub fn get_active_session(&self, identifier: &str, now_ms: u64) -> Option<&PeerSession> {
273        let session = self.get_session_by_identifier(identifier)?;
274        if self.is_expired(&session.session_nonce, now_ms) {
275            return None;
276        }
277        Some(session)
278    }
279
280    /// Anti-replay check-and-insert for a single per-message nonce, scoped to a
281    /// session. Atomic under `&mut self` (the caller's brief write lock), so
282    /// concurrent verifies of the SAME captured message resolve to exactly one
283    /// [`MarkSeen::Fresh`] and the rest [`MarkSeen::Replay`].
284    ///
285    /// The effective replay key is `(peer_identity_key, session_nonce,
286    /// message_nonce)`: `session_nonce` selects the per-session set (which is
287    /// itself bound to one peer identity via the handshake), and `message_nonce`
288    /// is the fresh 32-byte random nonce an honest sender mints per outbound
289    /// message — so a legit sender never collides and only a byte-replay repeats.
290    ///
291    /// Advances `last_used_ms` and enforces the per-session FIFO cap. Only call
292    /// AFTER the message signature has verified, so the set is never poisoned by
293    /// unauthenticated input.
294    pub fn mark_message_seen(
295        &mut self,
296        session_nonce: &str,
297        message_nonce: &str,
298        now_ms: u64,
299    ) -> MarkSeen {
300        if !self.nonce_to_session.contains_key(session_nonce) {
301            return MarkSeen::SessionGone;
302        }
303        let cap = self.seen_nonce_cap;
304        let meta = self
305            .session_meta
306            .entry(session_nonce.to_string())
307            .or_insert_with(|| SessionMeta {
308                last_used_ms: now_ms,
309                seen: HashSet::new(),
310                order: VecDeque::new(),
311            });
312        meta.last_used_ms = now_ms;
313
314        if !meta.seen.insert(message_nonce.to_string()) {
315            return MarkSeen::Replay;
316        }
317        meta.order.push_back(message_nonce.to_string());
318        while meta.order.len() > cap {
319            if let Some(old) = meta.order.pop_front() {
320                meta.seen.remove(&old);
321            }
322        }
323        MarkSeen::Fresh
324    }
325
326    /// Evict every session idle longer than the configured TTL, freeing each
327    /// one's replay seen-set. Returns the number reaped.
328    ///
329    /// Intended to be called on the low-frequency handshake path (not the hot
330    /// verify path), which bounds memory without serializing message verifies.
331    pub fn reap_idle(&mut self, now_ms: u64) -> usize {
332        let ttl = self.idle_ttl_ms;
333        let expired: Vec<String> = self
334            .session_meta
335            .iter()
336            .filter(|(_, m)| now_ms.saturating_sub(m.last_used_ms) > ttl)
337            .map(|(nonce, _)| nonce.clone())
338            .collect();
339        let mut reaped = 0;
340        for nonce in expired {
341            if self.remove_session(&nonce).is_some() {
342                reaped += 1;
343            }
344        }
345        reaped
346    }
347
348    /// Test/introspection helper: number of remembered message nonces for a
349    /// session (0 if the session has no metadata).
350    #[doc(hidden)]
351    pub fn seen_nonce_count(&self, session_nonce: &str) -> usize {
352        self.session_meta
353            .get(session_nonce)
354            .map(|m| m.seen.len())
355            .unwrap_or(0)
356    }
357}
358
359impl Default for SessionManager {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn make_session(nonce: &str, identity: &str, authenticated: bool) -> PeerSession {
370        PeerSession {
371            session_nonce: nonce.to_string(),
372            peer_identity_key: identity.to_string(),
373            peer_nonce: format!("peer_{}", nonce),
374            is_authenticated: authenticated,
375        }
376    }
377
378    #[test]
379    fn test_add_and_get_session() {
380        let mut mgr = SessionManager::new();
381        let session = make_session("nonce1", "id_key_A", true);
382        mgr.add_session(session.clone());
383
384        let retrieved = mgr.get_session("nonce1").unwrap();
385        assert_eq!(retrieved.session_nonce, "nonce1");
386        assert_eq!(retrieved.peer_identity_key, "id_key_A");
387        assert!(retrieved.is_authenticated);
388    }
389
390    #[test]
391    fn test_has_session() {
392        let mut mgr = SessionManager::new();
393        assert!(!mgr.has_session("nonce1"));
394
395        mgr.add_session(make_session("nonce1", "id_key_A", true));
396        assert!(mgr.has_session("nonce1"));
397        assert!(!mgr.has_session("nonce2"));
398    }
399
400    #[test]
401    fn test_remove_session() {
402        let mut mgr = SessionManager::new();
403        mgr.add_session(make_session("nonce1", "id_key_A", true));
404
405        let removed = mgr.remove_session("nonce1").unwrap();
406        assert_eq!(removed.session_nonce, "nonce1");
407        assert!(!mgr.has_session("nonce1"));
408
409        // Identity index should also be cleaned up
410        let sessions = mgr.get_sessions_for_identity("id_key_A");
411        assert!(sessions.is_empty());
412    }
413
414    #[test]
415    fn test_get_sessions_for_identity() {
416        let mut mgr = SessionManager::new();
417        mgr.add_session(make_session("nonce1", "id_key_A", true));
418        mgr.add_session(make_session("nonce2", "id_key_A", false));
419        mgr.add_session(make_session("nonce3", "id_key_B", true));
420
421        let a_sessions = mgr.get_sessions_for_identity("id_key_A");
422        assert_eq!(a_sessions.len(), 2);
423
424        let b_sessions = mgr.get_sessions_for_identity("id_key_B");
425        assert_eq!(b_sessions.len(), 1);
426
427        let c_sessions = mgr.get_sessions_for_identity("id_key_C");
428        assert!(c_sessions.is_empty());
429    }
430
431    #[test]
432    fn test_get_session_by_identifier() {
433        let mut mgr = SessionManager::new();
434        mgr.add_session(make_session("nonce1", "id_key_A", false));
435        mgr.add_session(make_session("nonce2", "id_key_A", true));
436
437        // Direct nonce lookup
438        let s = mgr.get_session_by_identifier("nonce1").unwrap();
439        assert_eq!(s.session_nonce, "nonce1");
440
441        // Identity key lookup should prefer authenticated session
442        let best = mgr.get_session_by_identifier("id_key_A").unwrap();
443        assert!(best.is_authenticated);
444    }
445
446    #[test]
447    fn test_has_session_by_identifier() {
448        let mut mgr = SessionManager::new();
449        mgr.add_session(make_session("nonce1", "id_key_A", true));
450
451        assert!(mgr.has_session_by_identifier("nonce1"));
452        assert!(mgr.has_session_by_identifier("id_key_A"));
453        assert!(!mgr.has_session_by_identifier("unknown"));
454    }
455
456    #[test]
457    fn test_update_session() {
458        let mut mgr = SessionManager::new();
459        mgr.add_session(make_session("nonce1", "id_key_A", false));
460
461        // Update to authenticated
462        let updated = make_session("nonce1", "id_key_A", true);
463        mgr.update_session("nonce1", updated);
464
465        let s = mgr.get_session("nonce1").unwrap();
466        assert!(s.is_authenticated);
467    }
468
469    #[test]
470    fn test_get_session_mut() {
471        let mut mgr = SessionManager::new();
472        mgr.add_session(make_session("nonce1", "id_key_A", false));
473
474        let s = mgr.get_session_mut("nonce1").unwrap();
475        s.is_authenticated = true;
476
477        let s2 = mgr.get_session("nonce1").unwrap();
478        assert!(s2.is_authenticated);
479    }
480
481    #[test]
482    fn test_remove_nonexistent() {
483        let mut mgr = SessionManager::new();
484        assert!(mgr.remove_session("nonexistent").is_none());
485    }
486
487    #[test]
488    fn test_identity_cleanup_on_remove_last_session() {
489        let mut mgr = SessionManager::new();
490        mgr.add_session(make_session("nonce1", "id_key_A", true));
491        mgr.add_session(make_session("nonce2", "id_key_A", true));
492
493        mgr.remove_session("nonce1");
494        // Still has one session for identity
495        assert!(mgr.has_session_by_identifier("id_key_A"));
496
497        mgr.remove_session("nonce2");
498        // Now identity should be cleaned up
499        assert!(!mgr.has_session_by_identifier("id_key_A"));
500    }
501
502    // -----------------------------------------------------------------------
503    // Anti-replay + idle-TTL eviction tests
504    // -----------------------------------------------------------------------
505
506    /// Item 1: a replayed (same session + same message nonce) is REJECTED while
507    /// a fresh per-message nonce always passes.
508    #[test]
509    fn test_replay_seen_set_rejects_duplicate_but_accepts_fresh() {
510        let mut mgr = SessionManager::new();
511        mgr.add_session(make_session("sess1", "id_key_A", true));
512        mgr.touch("sess1", 1_000);
513
514        // First sighting of msg nonce "m1" is fresh.
515        assert_eq!(mgr.mark_message_seen("sess1", "m1", 1_000), MarkSeen::Fresh);
516        // Byte-replay of the exact same message nonce is rejected.
517        assert_eq!(mgr.mark_message_seen("sess1", "m1", 1_001), MarkSeen::Replay);
518        // A different fresh nonce still passes on the same session.
519        assert_eq!(mgr.mark_message_seen("sess1", "m2", 1_002), MarkSeen::Fresh);
520        // Unknown session cannot be marked.
521        assert_eq!(
522            mgr.mark_message_seen("ghost", "m1", 1_003),
523            MarkSeen::SessionGone
524        );
525    }
526
527    /// Item 2: a session past its TTL is evicted and its seen-set freed.
528    #[test]
529    fn test_reap_idle_evicts_expired_and_frees_seen_set() {
530        let ttl = 60_000; // 1 minute
531        let mut mgr = SessionManager::with_config(ttl, DEFAULT_SEEN_NONCE_CAP);
532        mgr.add_session(make_session("sess1", "id_key_A", true));
533        mgr.touch("sess1", 10_000);
534        mgr.mark_message_seen("sess1", "m1", 10_000);
535        mgr.mark_message_seen("sess1", "m2", 10_000);
536        assert_eq!(mgr.seen_nonce_count("sess1"), 2);
537
538        // Well within TTL: still present, still remembers nonces.
539        assert!(!mgr.is_expired("sess1", 10_000 + ttl));
540        assert_eq!(mgr.reap_idle(10_000 + ttl), 0);
541        assert!(mgr.has_session("sess1"));
542
543        // Past TTL: expired, reaped, seen-set freed.
544        assert!(mgr.is_expired("sess1", 10_000 + ttl + 1));
545        assert_eq!(mgr.reap_idle(10_000 + ttl + 1), 1);
546        assert!(!mgr.has_session("sess1"));
547        assert_eq!(mgr.seen_nonce_count("sess1"), 0);
548        // After eviction the captured nonce no longer resolves a session.
549        assert!(mgr.get_active_session("sess1", 10_000 + ttl + 2).is_none());
550    }
551
552    /// Item 3: two different sessions do not cross-contaminate their seen-sets.
553    #[test]
554    fn test_two_sessions_do_not_cross_contaminate() {
555        let mut mgr = SessionManager::new();
556        mgr.add_session(make_session("sessA", "id_key_A", true));
557        mgr.add_session(make_session("sessB", "id_key_B", true));
558
559        // Same message nonce value on two distinct sessions: both fresh.
560        assert_eq!(
561            mgr.mark_message_seen("sessA", "shared", 1_000),
562            MarkSeen::Fresh
563        );
564        assert_eq!(
565            mgr.mark_message_seen("sessB", "shared", 1_000),
566            MarkSeen::Fresh
567        );
568        // Replays are still caught per-session.
569        assert_eq!(
570            mgr.mark_message_seen("sessA", "shared", 1_001),
571            MarkSeen::Replay
572        );
573        assert_eq!(
574            mgr.mark_message_seen("sessB", "shared", 1_001),
575            MarkSeen::Replay
576        );
577    }
578
579    /// The per-session seen-set is bounded: it never exceeds the cap, and the
580    /// oldest nonce is FIFO-evicted (documents the finite replay window).
581    #[test]
582    fn test_seen_set_is_bounded_by_cap() {
583        let cap = 4;
584        let mut mgr = SessionManager::with_config(DEFAULT_SESSION_IDLE_TTL_MS, cap);
585        mgr.add_session(make_session("sess1", "id_key_A", true));
586
587        for i in 0..6 {
588            let n = format!("m{i}");
589            assert_eq!(mgr.mark_message_seen("sess1", &n, 1_000), MarkSeen::Fresh);
590        }
591        // Memory is bounded to `cap`.
592        assert_eq!(mgr.seen_nonce_count("sess1"), cap);
593        // The most recent `cap` nonces are still remembered (replay caught).
594        assert_eq!(
595            mgr.mark_message_seen("sess1", "m5", 1_000),
596            MarkSeen::Replay
597        );
598        // The oldest (m0, m1) fell out of the window (FIFO eviction).
599        assert_eq!(mgr.mark_message_seen("sess1", "m0", 1_000), MarkSeen::Fresh);
600    }
601
602    /// A freshly-added session with no recorded activity is never treated as
603    /// expired (avoids reaping a session before its first message).
604    #[test]
605    fn test_new_session_without_activity_is_not_expired() {
606        let mut mgr = SessionManager::with_config(1, DEFAULT_SEEN_NONCE_CAP);
607        mgr.add_session(make_session("sess1", "id_key_A", true));
608        // No touch yet -> no metadata -> not expired regardless of clock.
609        assert!(!mgr.is_expired("sess1", u64::MAX));
610        assert!(mgr.get_active_session("sess1", u64::MAX).is_some());
611        assert_eq!(mgr.reap_idle(u64::MAX), 0);
612    }
613}