Skip to main content

ipfrs_network/
session_manager.rs

1//! Peer session lifecycle management with per-peer caps, state machine, and idle eviction.
2//!
3//! This module provides [`PeerSessionManager`], which tracks active sessions per peer,
4//! manages session lifecycle state transitions, enforces per-peer session caps, and
5//! applies global idle timeouts based on logical tick counts rather than wall-clock time.
6//!
7//! # Session Lifecycle
8//!
9//! ```text
10//! Initiated → Established → Closing → Closed
11//!     ↓                        ↑
12//!     └────────────────────────┘  (idle eviction marks Closed directly)
13//! ```
14//!
15//! # Example
16//!
17//! ```
18//! use ipfrs_network::session_manager::{
19//!     PeerSessionManager, SessionManagerConfig, SessionDirection,
20//! };
21//!
22//! let config = SessionManagerConfig::default();
23//! let mut mgr = PeerSessionManager::new(config);
24//!
25//! let sid = mgr
26//!     .open_session("peer-alpha", SessionDirection::Inbound, 0)
27//!     .expect("open session");
28//!
29//! assert!(mgr.get_session(sid).is_some());
30//! ```
31
32use std::collections::HashMap;
33
34// ─────────────────────────────────────────────────────────────────────────────
35// SessionState
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// Lifecycle state of a single peer session.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40pub enum PeerSessionState {
41    /// Session created, awaiting confirmation from the remote peer.
42    Initiated,
43    /// Session is active and usable for data exchange.
44    Established,
45    /// Graceful shutdown is in progress; no new data should be sent.
46    Closing,
47    /// Terminal state — the session has been fully torn down.
48    Closed,
49}
50
51impl PeerSessionState {
52    /// Returns `true` for the terminal [`PeerSessionState::Closed`] state.
53    #[inline]
54    pub fn is_terminal(self) -> bool {
55        self == Self::Closed
56    }
57
58    /// Returns `true` for states that count as "active" in metrics.
59    ///
60    /// Active = `Initiated | Established | Closing`.
61    #[inline]
62    pub fn is_active(self) -> bool {
63        !self.is_terminal()
64    }
65}
66
67// ─────────────────────────────────────────────────────────────────────────────
68// SessionDirection
69// ─────────────────────────────────────────────────────────────────────────────
70
71/// Direction in which a session was initiated.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum SessionDirection {
74    /// The remote peer opened this session.
75    Inbound,
76    /// We opened this session to the remote peer.
77    Outbound,
78}
79
80// ─────────────────────────────────────────────────────────────────────────────
81// PeerSessionEntry
82// ─────────────────────────────────────────────────────────────────────────────
83
84/// A single tracked peer session.
85#[derive(Clone, Debug)]
86pub struct PeerSessionEntry {
87    /// Unique identifier for this session.
88    pub session_id: u64,
89    /// Identifier of the remote peer.
90    pub peer_id: String,
91    /// Current lifecycle state.
92    pub state: PeerSessionState,
93    /// Whether this session was inbound or outbound.
94    pub direction: SessionDirection,
95    /// Logical tick when this session was created.
96    pub created_at_tick: u64,
97    /// Logical tick of the most recent recorded activity.
98    pub last_active_tick: u64,
99    /// Cumulative bytes sent to the peer.
100    pub bytes_sent: u64,
101    /// Cumulative bytes received from the peer.
102    pub bytes_received: u64,
103}
104
105impl PeerSessionEntry {
106    /// Returns `true` if this session is in the terminal [`PeerSessionState::Closed`] state.
107    #[inline]
108    pub fn is_terminal(&self) -> bool {
109        self.state.is_terminal()
110    }
111
112    /// Number of ticks elapsed since session creation.
113    #[inline]
114    pub fn duration_ticks(&self, current_tick: u64) -> u64 {
115        current_tick.saturating_sub(self.created_at_tick)
116    }
117
118    /// Number of ticks elapsed since the last recorded activity.
119    #[inline]
120    pub fn idle_ticks(&self, current_tick: u64) -> u64 {
121        current_tick.saturating_sub(self.last_active_tick)
122    }
123}
124
125// ─────────────────────────────────────────────────────────────────────────────
126// SessionManagerConfig
127// ─────────────────────────────────────────────────────────────────────────────
128
129/// Configuration for [`PeerSessionManager`].
130#[derive(Clone, Debug)]
131pub struct SessionManagerConfig {
132    /// Maximum number of concurrent *active* (non-Closed) sessions per peer.
133    pub max_sessions_per_peer: usize,
134    /// Maximum total number of *active* (non-Closed) sessions across all peers.
135    pub global_max_sessions: usize,
136    /// Number of idle ticks before a session is forcibly closed by [`PeerSessionManager::evict_idle`].
137    pub session_idle_timeout_ticks: u64,
138}
139
140impl Default for SessionManagerConfig {
141    fn default() -> Self {
142        Self {
143            max_sessions_per_peer: 8,
144            global_max_sessions: 256,
145            session_idle_timeout_ticks: 300,
146        }
147    }
148}
149
150// ─────────────────────────────────────────────────────────────────────────────
151// SessionManagerStats
152// ─────────────────────────────────────────────────────────────────────────────
153
154/// Aggregated statistics snapshot for [`PeerSessionManager`].
155#[derive(Clone, Debug, Default)]
156pub struct SessionManagerStats {
157    /// Total number of sessions ever created (including closed ones).
158    pub total_sessions: usize,
159    /// Number of currently active sessions (`Initiated + Established + Closing`).
160    pub active_sessions: usize,
161    /// Number of sessions in the `Closed` terminal state.
162    pub closed_sessions: usize,
163    /// Per-state breakdown.
164    pub by_state: HashMap<PeerSessionState, usize>,
165    /// Cumulative bytes sent across all sessions.
166    pub total_bytes_sent: u64,
167    /// Cumulative bytes received across all sessions.
168    pub total_bytes_received: u64,
169}
170
171// ─────────────────────────────────────────────────────────────────────────────
172// PeerSessionManager
173// ─────────────────────────────────────────────────────────────────────────────
174
175/// Manages the lifecycle of peer sessions with per-peer caps and idle eviction.
176///
177/// ## Index maintenance
178///
179/// `peer_sessions` is a secondary index mapping each `peer_id` to its list of
180/// `session_id`s.  Closed entries are **not** removed from this index; instead,
181/// query methods filter by session state on the fly.  This makes the index
182/// append-only and avoids the bookkeeping cost of removal.
183pub struct PeerSessionManager {
184    /// Primary store: session_id → session data.
185    sessions: HashMap<u64, PeerSessionEntry>,
186    /// Secondary index: peer_id → list of session_ids (may include closed sessions).
187    peer_sessions: HashMap<String, Vec<u64>>,
188    /// Monotonically increasing counter used to generate unique session IDs.
189    next_session_id: u64,
190    /// Manager configuration.
191    config: SessionManagerConfig,
192}
193
194impl PeerSessionManager {
195    /// Create a new manager with the supplied configuration.
196    pub fn new(config: SessionManagerConfig) -> Self {
197        Self {
198            sessions: HashMap::new(),
199            peer_sessions: HashMap::new(),
200            next_session_id: 1,
201            config,
202        }
203    }
204
205    // ── Capacity helpers ─────────────────────────────────────────────────────
206
207    /// Count of all non-Closed sessions across every peer.
208    fn global_active_count(&self) -> usize {
209        self.sessions
210            .values()
211            .filter(|s| s.state.is_active())
212            .count()
213    }
214
215    /// Count of all non-Closed sessions for a specific peer.
216    fn peer_active_count(&self, peer_id: &str) -> usize {
217        match self.peer_sessions.get(peer_id) {
218            None => 0,
219            Some(ids) => ids
220                .iter()
221                .filter_map(|id| self.sessions.get(id))
222                .filter(|s| s.state.is_active())
223                .count(),
224        }
225    }
226
227    // ── Public API ───────────────────────────────────────────────────────────
228
229    /// Open a new session for `peer_id`.
230    ///
231    /// Checks global active capacity first, then per-peer active capacity.
232    ///
233    /// # Errors
234    ///
235    /// * `"global session limit reached"` — the total number of active sessions
236    ///   would exceed [`SessionManagerConfig::global_max_sessions`].
237    /// * `"per-peer session limit reached"` — this peer already has
238    ///   [`SessionManagerConfig::max_sessions_per_peer`] active sessions.
239    pub fn open_session(
240        &mut self,
241        peer_id: &str,
242        direction: SessionDirection,
243        current_tick: u64,
244    ) -> Result<u64, String> {
245        // Global cap checked first.
246        if self.global_active_count() >= self.config.global_max_sessions {
247            return Err("global session limit reached".to_string());
248        }
249
250        // Per-peer cap.
251        if self.peer_active_count(peer_id) >= self.config.max_sessions_per_peer {
252            return Err("per-peer session limit reached".to_string());
253        }
254
255        let session_id = self.next_session_id;
256        self.next_session_id = self.next_session_id.wrapping_add(1);
257
258        let entry = PeerSessionEntry {
259            session_id,
260            peer_id: peer_id.to_string(),
261            state: PeerSessionState::Initiated,
262            direction,
263            created_at_tick: current_tick,
264            last_active_tick: current_tick,
265            bytes_sent: 0,
266            bytes_received: 0,
267        };
268
269        self.sessions.insert(session_id, entry);
270        self.peer_sessions
271            .entry(peer_id.to_string())
272            .or_default()
273            .push(session_id);
274
275        Ok(session_id)
276    }
277
278    /// Advance a session's lifecycle state.
279    ///
280    /// # Errors
281    ///
282    /// * `"session not found"` — no session with `session_id` exists.
283    /// * `"invalid transition"` — the session is already in the `Closed` terminal
284    ///   state and cannot transition further.
285    pub fn transition(
286        &mut self,
287        session_id: u64,
288        new_state: PeerSessionState,
289    ) -> Result<(), String> {
290        let session = self
291            .sessions
292            .get_mut(&session_id)
293            .ok_or_else(|| "session not found".to_string())?;
294
295        if session.state == PeerSessionState::Closed {
296            return Err("invalid transition".to_string());
297        }
298
299        session.state = new_state;
300        Ok(())
301    }
302
303    /// Record I/O activity for a session.
304    ///
305    /// Accumulates `bytes_sent` and `bytes_recv` onto the session totals and
306    /// updates `last_active_tick` to `current_tick`.
307    ///
308    /// Returns `false` if no session with `session_id` exists (no-op).
309    pub fn record_activity(
310        &mut self,
311        session_id: u64,
312        bytes_sent: u64,
313        bytes_recv: u64,
314        current_tick: u64,
315    ) -> bool {
316        match self.sessions.get_mut(&session_id) {
317            None => false,
318            Some(session) => {
319                session.bytes_sent = session.bytes_sent.saturating_add(bytes_sent);
320                session.bytes_received = session.bytes_received.saturating_add(bytes_recv);
321                session.last_active_tick = current_tick;
322                true
323            }
324        }
325    }
326
327    /// Mark idle sessions as `Closed` and return their IDs.
328    ///
329    /// A session is considered idle when
330    /// `idle_ticks(current_tick) >= config.session_idle_timeout_ticks`.
331    /// Only non-`Closed` sessions are evaluated.
332    pub fn evict_idle(&mut self, current_tick: u64) -> Vec<u64> {
333        let timeout = self.config.session_idle_timeout_ticks;
334        let mut evicted = Vec::new();
335
336        for session in self.sessions.values_mut() {
337            if session.state != PeerSessionState::Closed
338                && session.idle_ticks(current_tick) >= timeout
339            {
340                session.state = PeerSessionState::Closed;
341                evicted.push(session.session_id);
342            }
343        }
344
345        evicted
346    }
347
348    /// Return references to all sessions (including closed) associated with `peer_id`.
349    pub fn sessions_for_peer(&self, peer_id: &str) -> Vec<&PeerSessionEntry> {
350        match self.peer_sessions.get(peer_id) {
351            None => Vec::new(),
352            Some(ids) => ids.iter().filter_map(|id| self.sessions.get(id)).collect(),
353        }
354    }
355
356    /// Count of non-Closed sessions for `peer_id`.
357    pub fn active_count_for_peer(&self, peer_id: &str) -> usize {
358        self.peer_active_count(peer_id)
359    }
360
361    /// Look up a session by its ID.
362    pub fn get_session(&self, session_id: u64) -> Option<&PeerSessionEntry> {
363        self.sessions.get(&session_id)
364    }
365
366    /// Compute a statistics snapshot reflecting the current state of all sessions.
367    pub fn stats(&self) -> SessionManagerStats {
368        let mut by_state: HashMap<PeerSessionState, usize> = HashMap::new();
369        let mut total_bytes_sent: u64 = 0;
370        let mut total_bytes_received: u64 = 0;
371
372        for session in self.sessions.values() {
373            *by_state.entry(session.state).or_insert(0) += 1;
374            total_bytes_sent = total_bytes_sent.saturating_add(session.bytes_sent);
375            total_bytes_received = total_bytes_received.saturating_add(session.bytes_received);
376        }
377
378        let total_sessions = self.sessions.len();
379        let closed_sessions = by_state
380            .get(&PeerSessionState::Closed)
381            .copied()
382            .unwrap_or(0);
383        let active_sessions = total_sessions - closed_sessions;
384
385        SessionManagerStats {
386            total_sessions,
387            active_sessions,
388            closed_sessions,
389            by_state,
390            total_bytes_sent,
391            total_bytes_received,
392        }
393    }
394}
395
396// ─────────────────────────────────────────────────────────────────────────────
397// Public type aliases for external API compatibility
398// ─────────────────────────────────────────────────────────────────────────────
399
400/// Public alias for [`PeerSessionState`] matching the required API surface.
401pub type SessionState = PeerSessionState;
402
403/// Public alias for [`PeerSessionEntry`] matching the required API surface.
404pub type PeerSession = PeerSessionEntry;
405
406// ─────────────────────────────────────────────────────────────────────────────
407// Tests
408// ─────────────────────────────────────────────────────────────────────────────
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn default_mgr() -> PeerSessionManager {
415        PeerSessionManager::new(SessionManagerConfig::default())
416    }
417
418    // ── 1. open_session creates a session ────────────────────────────────────
419
420    #[test]
421    fn open_session_creates_session() {
422        let mut mgr = default_mgr();
423        let sid = mgr
424            .open_session("peer-a", SessionDirection::Inbound, 10)
425            .expect("open_session failed");
426        let session = mgr.get_session(sid).expect("session should exist");
427        assert_eq!(session.peer_id, "peer-a");
428        assert_eq!(session.state, PeerSessionState::Initiated);
429        assert_eq!(session.direction, SessionDirection::Inbound);
430        assert_eq!(session.created_at_tick, 10);
431        assert_eq!(session.last_active_tick, 10);
432        assert_eq!(session.bytes_sent, 0);
433        assert_eq!(session.bytes_received, 0);
434    }
435
436    // ── 2. open_session returns unique session IDs ────────────────────────────
437
438    #[test]
439    fn open_session_returns_unique_ids() {
440        let mut mgr = default_mgr();
441        let s1 = mgr
442            .open_session("peer-a", SessionDirection::Inbound, 0)
443            .expect("s1");
444        let s2 = mgr
445            .open_session("peer-a", SessionDirection::Outbound, 0)
446            .expect("s2");
447        assert_ne!(s1, s2);
448    }
449
450    // ── 3. per-peer cap enforcement ───────────────────────────────────────────
451
452    #[test]
453    fn per_peer_cap_enforced() {
454        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
455            max_sessions_per_peer: 2,
456            global_max_sessions: 100,
457            ..Default::default()
458        });
459        mgr.open_session("peer-x", SessionDirection::Inbound, 0)
460            .expect("s1");
461        mgr.open_session("peer-x", SessionDirection::Outbound, 0)
462            .expect("s2");
463        let err = mgr
464            .open_session("peer-x", SessionDirection::Inbound, 0)
465            .expect_err("should be capped");
466        assert_eq!(err, "per-peer session limit reached");
467    }
468
469    // ── 4. per-peer cap: different peer succeeds after same-peer hits cap ─────
470
471    #[test]
472    fn per_peer_cap_does_not_affect_other_peers() {
473        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
474            max_sessions_per_peer: 1,
475            global_max_sessions: 100,
476            ..Default::default()
477        });
478        mgr.open_session("peer-a", SessionDirection::Inbound, 0)
479            .expect("peer-a session");
480        // peer-a is at cap; peer-b should still succeed
481        mgr.open_session("peer-b", SessionDirection::Inbound, 0)
482            .expect("peer-b session");
483    }
484
485    // ── 5. global cap enforcement ─────────────────────────────────────────────
486
487    #[test]
488    fn global_cap_enforced() {
489        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
490            max_sessions_per_peer: 100,
491            global_max_sessions: 2,
492            ..Default::default()
493        });
494        mgr.open_session("peer-a", SessionDirection::Inbound, 0)
495            .expect("s1");
496        mgr.open_session("peer-b", SessionDirection::Inbound, 0)
497            .expect("s2");
498        let err = mgr
499            .open_session("peer-c", SessionDirection::Inbound, 0)
500            .expect_err("should hit global cap");
501        assert_eq!(err, "global session limit reached");
502    }
503
504    // ── 6. global cap: closed session frees a slot ────────────────────────────
505
506    #[test]
507    fn global_cap_freed_by_close() {
508        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
509            max_sessions_per_peer: 100,
510            global_max_sessions: 1,
511            ..Default::default()
512        });
513        let sid = mgr
514            .open_session("peer-a", SessionDirection::Inbound, 0)
515            .expect("s1");
516        // At capacity
517        assert!(mgr
518            .open_session("peer-b", SessionDirection::Inbound, 0)
519            .is_err());
520        // Close the first session
521        mgr.transition(sid, PeerSessionState::Closed)
522            .expect("transition to Closed");
523        // Now there is capacity again
524        mgr.open_session("peer-b", SessionDirection::Inbound, 0)
525            .expect("s2 after slot freed");
526    }
527
528    // ── 7. transition Initiated → Established → Closing → Closed ─────────────
529
530    #[test]
531    fn full_lifecycle_transition() {
532        let mut mgr = default_mgr();
533        let sid = mgr
534            .open_session("peer-a", SessionDirection::Outbound, 0)
535            .expect("open");
536
537        // Initiated → Established
538        mgr.transition(sid, PeerSessionState::Established)
539            .expect("→ Established");
540        assert_eq!(
541            mgr.get_session(sid).expect("s").state,
542            PeerSessionState::Established
543        );
544
545        // Established → Closing
546        mgr.transition(sid, PeerSessionState::Closing)
547            .expect("→ Closing");
548        assert_eq!(
549            mgr.get_session(sid).expect("s").state,
550            PeerSessionState::Closing
551        );
552
553        // Closing → Closed
554        mgr.transition(sid, PeerSessionState::Closed)
555            .expect("→ Closed");
556        assert_eq!(
557            mgr.get_session(sid).expect("s").state,
558            PeerSessionState::Closed
559        );
560    }
561
562    // ── 8. transition from Closed is invalid ──────────────────────────────────
563
564    #[test]
565    fn transition_from_closed_is_invalid() {
566        let mut mgr = default_mgr();
567        let sid = mgr
568            .open_session("peer-a", SessionDirection::Inbound, 0)
569            .expect("open");
570        mgr.transition(sid, PeerSessionState::Closed)
571            .expect("close");
572        let err = mgr
573            .transition(sid, PeerSessionState::Established)
574            .expect_err("should be invalid");
575        assert_eq!(err, "invalid transition");
576    }
577
578    // ── 9. transition on missing session returns session not found ────────────
579
580    #[test]
581    fn transition_missing_session_returns_not_found() {
582        let mut mgr = default_mgr();
583        let err = mgr
584            .transition(9999, PeerSessionState::Established)
585            .expect_err("should not find session 9999");
586        assert_eq!(err, "session not found");
587    }
588
589    // ── 10. record_activity accumulates bytes and updates tick ─────────────────
590
591    #[test]
592    fn record_activity_accumulates_and_updates_tick() {
593        let mut mgr = default_mgr();
594        let sid = mgr
595            .open_session("peer-a", SessionDirection::Inbound, 0)
596            .expect("open");
597
598        assert!(mgr.record_activity(sid, 100, 200, 5));
599        assert!(mgr.record_activity(sid, 50, 75, 10));
600
601        let session = mgr.get_session(sid).expect("session");
602        assert_eq!(session.bytes_sent, 150);
603        assert_eq!(session.bytes_received, 275);
604        assert_eq!(session.last_active_tick, 10);
605    }
606
607    // ── 11. record_activity returns false for unknown session ─────────────────
608
609    #[test]
610    fn record_activity_returns_false_for_missing_session() {
611        let mut mgr = default_mgr();
612        assert!(!mgr.record_activity(0xdeadbeef, 1, 1, 0));
613    }
614
615    // ── 12. evict_idle marks sessions Closed ──────────────────────────────────
616
617    #[test]
618    fn evict_idle_marks_sessions_closed() {
619        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
620            session_idle_timeout_ticks: 10,
621            ..Default::default()
622        });
623        let s1 = mgr
624            .open_session("peer-a", SessionDirection::Inbound, 0)
625            .expect("s1");
626        let s2 = mgr
627            .open_session("peer-b", SessionDirection::Inbound, 5)
628            .expect("s2");
629
630        // At tick 15: s1 idle for 15 ticks (≥ 10), s2 idle for 10 ticks (≥ 10)
631        let evicted = mgr.evict_idle(15);
632        assert_eq!(evicted.len(), 2);
633        assert!(mgr.get_session(s1).expect("s1").state == PeerSessionState::Closed);
634        assert!(mgr.get_session(s2).expect("s2").state == PeerSessionState::Closed);
635    }
636
637    // ── 13. evict_idle does not close recently active sessions ────────────────
638
639    #[test]
640    fn evict_idle_spares_recently_active_sessions() {
641        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
642            session_idle_timeout_ticks: 100,
643            ..Default::default()
644        });
645        let s1 = mgr
646            .open_session("peer-a", SessionDirection::Inbound, 0)
647            .expect("s1");
648        // Record activity at tick 50 → last_active_tick = 50
649        mgr.record_activity(s1, 0, 0, 50);
650
651        // At tick 100: idle = 100 - 50 = 50 < 100 → should NOT be evicted
652        let evicted = mgr.evict_idle(100);
653        assert!(evicted.is_empty());
654        assert_eq!(
655            mgr.get_session(s1).expect("s1").state,
656            PeerSessionState::Initiated
657        );
658    }
659
660    // ── 14. evict_idle skips already-Closed sessions ──────────────────────────
661
662    #[test]
663    fn evict_idle_skips_already_closed() {
664        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
665            session_idle_timeout_ticks: 1,
666            ..Default::default()
667        });
668        let sid = mgr
669            .open_session("peer-a", SessionDirection::Inbound, 0)
670            .expect("open");
671        mgr.transition(sid, PeerSessionState::Closed)
672            .expect("close");
673
674        // Even though idle >= timeout, Closed sessions must not appear in eviction list
675        let evicted = mgr.evict_idle(100);
676        assert!(evicted.is_empty());
677    }
678
679    // ── 15. sessions_for_peer returns all sessions including closed ────────────
680
681    #[test]
682    fn sessions_for_peer_returns_all_including_closed() {
683        let mut mgr = default_mgr();
684        let s1 = mgr
685            .open_session("peer-z", SessionDirection::Inbound, 0)
686            .expect("s1");
687        let s2 = mgr
688            .open_session("peer-z", SessionDirection::Outbound, 0)
689            .expect("s2");
690        mgr.transition(s1, PeerSessionState::Closed)
691            .expect("close s1");
692
693        let sessions = mgr.sessions_for_peer("peer-z");
694        assert_eq!(sessions.len(), 2);
695        let ids: Vec<u64> = sessions.iter().map(|s| s.session_id).collect();
696        assert!(ids.contains(&s1));
697        assert!(ids.contains(&s2));
698    }
699
700    // ── 16. sessions_for_peer returns empty for unknown peer ──────────────────
701
702    #[test]
703    fn sessions_for_peer_returns_empty_for_unknown_peer() {
704        let mgr = default_mgr();
705        let sessions = mgr.sessions_for_peer("nobody");
706        assert!(sessions.is_empty());
707    }
708
709    // ── 17. active_count_for_peer excludes Closed sessions ───────────────────
710
711    #[test]
712    fn active_count_for_peer_excludes_closed() {
713        let mut mgr = default_mgr();
714        let s1 = mgr
715            .open_session("peer-q", SessionDirection::Inbound, 0)
716            .expect("s1");
717        mgr.open_session("peer-q", SessionDirection::Outbound, 0)
718            .expect("s2");
719        assert_eq!(mgr.active_count_for_peer("peer-q"), 2);
720
721        mgr.transition(s1, PeerSessionState::Closed).expect("close");
722        assert_eq!(mgr.active_count_for_peer("peer-q"), 1);
723    }
724
725    // ── 18. active_count_for_peer returns 0 for unknown peer ─────────────────
726
727    #[test]
728    fn active_count_for_peer_returns_zero_for_unknown() {
729        let mgr = default_mgr();
730        assert_eq!(mgr.active_count_for_peer("nobody"), 0);
731    }
732
733    // ── 19. stats by_state counts correctly ───────────────────────────────────
734
735    #[test]
736    fn stats_by_state_counts_correctly() {
737        let mut mgr = default_mgr();
738        let s1 = mgr
739            .open_session("p1", SessionDirection::Inbound, 0)
740            .expect("s1");
741        let s2 = mgr
742            .open_session("p2", SessionDirection::Inbound, 0)
743            .expect("s2");
744        let s3 = mgr
745            .open_session("p3", SessionDirection::Outbound, 0)
746            .expect("s3");
747
748        mgr.transition(s1, PeerSessionState::Established)
749            .expect("s1 established");
750        mgr.transition(s2, PeerSessionState::Closing)
751            .expect("s2 closing");
752        mgr.transition(s3, PeerSessionState::Closed)
753            .expect("s3 closed");
754
755        let stats = mgr.stats();
756        assert_eq!(stats.total_sessions, 3);
757        assert_eq!(stats.active_sessions, 2); // s1 + s2
758        assert_eq!(stats.closed_sessions, 1); // s3
759
760        assert_eq!(
761            *stats
762                .by_state
763                .get(&PeerSessionState::Established)
764                .unwrap_or(&0),
765            1
766        );
767        assert_eq!(
768            *stats.by_state.get(&PeerSessionState::Closing).unwrap_or(&0),
769            1
770        );
771        assert_eq!(
772            *stats.by_state.get(&PeerSessionState::Closed).unwrap_or(&0),
773            1
774        );
775    }
776
777    // ── 20. stats total_bytes aggregates across sessions ─────────────────────
778
779    #[test]
780    fn stats_aggregates_bytes() {
781        let mut mgr = default_mgr();
782        let s1 = mgr
783            .open_session("p1", SessionDirection::Inbound, 0)
784            .expect("s1");
785        let s2 = mgr
786            .open_session("p2", SessionDirection::Outbound, 0)
787            .expect("s2");
788
789        mgr.record_activity(s1, 1000, 2000, 1);
790        mgr.record_activity(s2, 500, 750, 1);
791
792        let stats = mgr.stats();
793        assert_eq!(stats.total_bytes_sent, 1500);
794        assert_eq!(stats.total_bytes_received, 2750);
795    }
796
797    // ── 21. duration_ticks helper ─────────────────────────────────────────────
798
799    #[test]
800    fn duration_ticks_helper() {
801        let mut mgr = default_mgr();
802        let sid = mgr
803            .open_session("peer-a", SessionDirection::Inbound, 100)
804            .expect("open");
805        let session = mgr.get_session(sid).expect("session");
806        assert_eq!(session.duration_ticks(150), 50);
807        assert_eq!(session.duration_ticks(100), 0);
808        // Saturating: current < created → 0
809        assert_eq!(session.duration_ticks(50), 0);
810    }
811
812    // ── 22. idle_ticks helper ─────────────────────────────────────────────────
813
814    #[test]
815    fn idle_ticks_helper() {
816        let mut mgr = default_mgr();
817        let sid = mgr
818            .open_session("peer-a", SessionDirection::Inbound, 0)
819            .expect("open");
820        mgr.record_activity(sid, 0, 0, 40);
821
822        let session = mgr.get_session(sid).expect("session");
823        assert_eq!(session.idle_ticks(60), 20);
824        assert_eq!(session.idle_ticks(40), 0);
825    }
826
827    // ── 23. is_terminal helper ────────────────────────────────────────────────
828
829    #[test]
830    fn is_terminal_helper() {
831        let mut mgr = default_mgr();
832        let sid = mgr
833            .open_session("peer-a", SessionDirection::Inbound, 0)
834            .expect("open");
835        assert!(!mgr.get_session(sid).expect("s").is_terminal());
836        mgr.transition(sid, PeerSessionState::Closed)
837            .expect("close");
838        assert!(mgr.get_session(sid).expect("s").is_terminal());
839    }
840
841    // ── 24. per-peer cap counts only active sessions ──────────────────────────
842
843    #[test]
844    fn per_peer_cap_counts_only_active() {
845        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
846            max_sessions_per_peer: 1,
847            global_max_sessions: 100,
848            ..Default::default()
849        });
850        let s1 = mgr
851            .open_session("peer-a", SessionDirection::Inbound, 0)
852            .expect("s1");
853        // At cap for peer-a
854        assert!(mgr
855            .open_session("peer-a", SessionDirection::Inbound, 0)
856            .is_err());
857        // Close the first session → slot freed
858        mgr.transition(s1, PeerSessionState::Closed).expect("close");
859        // Now can open another for the same peer
860        mgr.open_session("peer-a", SessionDirection::Outbound, 0)
861            .expect("s2 after slot freed");
862    }
863
864    // ── 25. evict_idle returns evicted session IDs ────────────────────────────
865
866    #[test]
867    fn evict_idle_returns_evicted_ids() {
868        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
869            session_idle_timeout_ticks: 5,
870            ..Default::default()
871        });
872        let s1 = mgr
873            .open_session("peer-a", SessionDirection::Inbound, 0)
874            .expect("s1");
875        // s1 has last_active_tick = 0; at tick 5 idle = 5 >= 5 → evicted
876        let evicted = mgr.evict_idle(5);
877        assert_eq!(evicted, vec![s1]);
878    }
879
880    // ── 26. get_session returns None for unknown ID ───────────────────────────
881
882    #[test]
883    fn get_session_returns_none_for_unknown() {
884        let mgr = default_mgr();
885        assert!(mgr.get_session(0xdeadbeef).is_none());
886    }
887
888    // ── 27. outbound direction recorded correctly ─────────────────────────────
889
890    #[test]
891    fn outbound_direction_recorded() {
892        let mut mgr = default_mgr();
893        let sid = mgr
894            .open_session("peer-a", SessionDirection::Outbound, 0)
895            .expect("open");
896        assert_eq!(
897            mgr.get_session(sid).expect("s").direction,
898            SessionDirection::Outbound
899        );
900    }
901
902    // ── 28. multiple evict_idle calls are idempotent for closed sessions ──────
903
904    #[test]
905    fn evict_idle_idempotent_for_closed() {
906        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
907            session_idle_timeout_ticks: 1,
908            ..Default::default()
909        });
910        mgr.open_session("peer-a", SessionDirection::Inbound, 0)
911            .expect("open");
912
913        let first = mgr.evict_idle(10);
914        assert_eq!(first.len(), 1);
915
916        // Second call: already Closed → nothing new
917        let second = mgr.evict_idle(20);
918        assert!(second.is_empty());
919    }
920
921    // ── 29. stats on empty manager ────────────────────────────────────────────
922
923    #[test]
924    fn stats_empty_manager() {
925        let mgr = default_mgr();
926        let stats = mgr.stats();
927        assert_eq!(stats.total_sessions, 0);
928        assert_eq!(stats.active_sessions, 0);
929        assert_eq!(stats.closed_sessions, 0);
930        assert_eq!(stats.total_bytes_sent, 0);
931        assert_eq!(stats.total_bytes_received, 0);
932        assert!(stats.by_state.is_empty());
933    }
934
935    // ── 30. Initiated state counted as active in stats ────────────────────────
936
937    #[test]
938    fn initiated_counted_as_active_in_stats() {
939        let mut mgr = default_mgr();
940        mgr.open_session("peer-a", SessionDirection::Inbound, 0)
941            .expect("open");
942        let stats = mgr.stats();
943        assert_eq!(stats.active_sessions, 1);
944        assert_eq!(stats.closed_sessions, 0);
945        assert_eq!(
946            *stats
947                .by_state
948                .get(&PeerSessionState::Initiated)
949                .unwrap_or(&0),
950            1
951        );
952    }
953}