Skip to main content

hive_btle/
peer_lifetime.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Peer lifetime management with stale peer cleanup
17//!
18//! Tracks peer activity and identifies stale peers that should be removed.
19//! This prevents memory leaks from accumulated discovered devices that are
20//! no longer in range.
21//!
22//! # Example
23//!
24//! ```ignore
25//! use hive_btle::peer_lifetime::{PeerLifetimeManager, PeerLifetimeConfig};
26//!
27//! let config = PeerLifetimeConfig::default();
28//! let mut manager = PeerLifetimeManager::new(config);
29//!
30//! // When a peer is discovered or connects
31//! manager.on_peer_activity("00:11:22:33:44:55", true); // connected = true
32//!
33//! // When peer disconnects
34//! manager.on_peer_disconnected("00:11:22:33:44:55");
35//!
36//! // Periodically check for stale peers
37//! for address in manager.get_stale_peers() {
38//!     // Remove peer from your data structures
39//!     manager.remove_peer(&address);
40//! }
41//! ```
42
43use std::collections::HashMap;
44use std::time::{Duration, Instant};
45
46/// Configuration for peer lifetime management
47#[derive(Debug, Clone)]
48pub struct PeerLifetimeConfig {
49    /// Timeout for disconnected peers (default: 30 seconds)
50    /// Peers that have been disconnected for longer than this are considered stale
51    pub disconnected_timeout: Duration,
52
53    /// Timeout for connected peers (default: 60 seconds)
54    /// Connected peers that haven't had activity for longer than this are
55    /// considered stale (handles ghost connections where disconnect was missed)
56    pub connected_timeout: Duration,
57
58    /// Interval for cleanup checks (default: 10 seconds)
59    pub cleanup_interval: Duration,
60}
61
62impl Default for PeerLifetimeConfig {
63    fn default() -> Self {
64        Self {
65            disconnected_timeout: Duration::from_secs(30),
66            connected_timeout: Duration::from_secs(60),
67            cleanup_interval: Duration::from_secs(10),
68        }
69    }
70}
71
72impl PeerLifetimeConfig {
73    /// Create a new configuration with custom values
74    pub fn new(
75        disconnected_timeout: Duration,
76        connected_timeout: Duration,
77        cleanup_interval: Duration,
78    ) -> Self {
79        Self {
80            disconnected_timeout,
81            connected_timeout,
82            cleanup_interval,
83        }
84    }
85
86    /// Create a fast configuration for testing
87    pub fn fast() -> Self {
88        Self {
89            disconnected_timeout: Duration::from_secs(5),
90            connected_timeout: Duration::from_secs(10),
91            cleanup_interval: Duration::from_secs(2),
92        }
93    }
94
95    /// Create a relaxed configuration for stable networks
96    pub fn relaxed() -> Self {
97        Self {
98            disconnected_timeout: Duration::from_secs(60),
99            connected_timeout: Duration::from_secs(120),
100            cleanup_interval: Duration::from_secs(30),
101        }
102    }
103}
104
105/// State for tracking a single peer's lifetime
106#[derive(Debug, Clone)]
107struct PeerState {
108    /// Whether the peer is currently connected
109    connected: bool,
110    /// Last time we saw activity from this peer
111    last_seen: Instant,
112    /// When the peer was first discovered
113    first_seen: Instant,
114    /// When the peer disconnected (if disconnected)
115    disconnected_at: Option<Instant>,
116}
117
118impl PeerState {
119    fn new(connected: bool) -> Self {
120        let now = Instant::now();
121        Self {
122            connected,
123            last_seen: now,
124            first_seen: now,
125            disconnected_at: if connected { None } else { Some(now) },
126        }
127    }
128}
129
130/// Reason a peer is considered stale
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum StaleReason {
133    /// Disconnected peer hasn't been seen in a while
134    DisconnectedTimeout,
135    /// Connected peer hasn't had activity (possible ghost connection)
136    ConnectedTimeout,
137}
138
139/// Information about a stale peer
140#[derive(Debug, Clone)]
141pub struct StalePeerInfo {
142    /// Peer address
143    pub address: String,
144    /// Why the peer is considered stale
145    pub reason: StaleReason,
146    /// How long since the peer was last seen
147    pub time_since_last_seen: Duration,
148    /// Whether the peer was connected when it went stale
149    pub was_connected: bool,
150}
151
152/// Manager for peer lifetime and stale peer cleanup
153///
154/// Tracks peer activity and determines when peers should be removed
155/// to prevent memory leaks.
156#[derive(Debug)]
157pub struct PeerLifetimeManager {
158    /// Configuration
159    config: PeerLifetimeConfig,
160    /// Per-peer state
161    peers: HashMap<String, PeerState>,
162}
163
164impl PeerLifetimeManager {
165    /// Create a new peer lifetime manager with the given configuration
166    pub fn new(config: PeerLifetimeConfig) -> Self {
167        Self {
168            config,
169            peers: HashMap::new(),
170        }
171    }
172
173    /// Create a manager with default configuration
174    pub fn with_defaults() -> Self {
175        Self::new(PeerLifetimeConfig::default())
176    }
177
178    /// Record activity for a peer
179    ///
180    /// Call this when:
181    /// - A peer is discovered via advertisement
182    /// - A peer connects successfully
183    /// - Data is received from a peer
184    ///
185    /// This updates the `last_seen` timestamp.
186    pub fn on_peer_activity(&mut self, address: &str, connected: bool) {
187        let now = Instant::now();
188
189        if let Some(state) = self.peers.get_mut(address) {
190            state.last_seen = now;
191            if connected && !state.connected {
192                // Transitioning to connected state
193                state.connected = true;
194                state.disconnected_at = None;
195                log::debug!("Peer {} connected", address);
196            } else if !connected && state.connected {
197                // Transitioning to disconnected state
198                state.connected = false;
199                state.disconnected_at = Some(now);
200                log::debug!("Peer {} disconnected", address);
201            }
202        } else {
203            // New peer
204            log::debug!("New peer {} (connected: {})", address, connected);
205            self.peers
206                .insert(address.to_string(), PeerState::new(connected));
207        }
208    }
209
210    /// Record that a peer has disconnected
211    ///
212    /// Note: This does NOT update `last_seen` - that's intentional.
213    /// We want the stale timeout to start from the last actual activity,
214    /// not from the disconnect event.
215    pub fn on_peer_disconnected(&mut self, address: &str) {
216        if let Some(state) = self.peers.get_mut(address) {
217            if state.connected {
218                state.connected = false;
219                state.disconnected_at = Some(Instant::now());
220                log::debug!("Peer {} marked as disconnected", address);
221            }
222        }
223    }
224
225    /// Check if a peer is being tracked
226    pub fn is_tracked(&self, address: &str) -> bool {
227        self.peers.contains_key(address)
228    }
229
230    /// Check if a peer is connected
231    pub fn is_connected(&self, address: &str) -> bool {
232        self.peers
233            .get(address)
234            .map(|s| s.connected)
235            .unwrap_or(false)
236    }
237
238    /// Get the list of stale peers that should be removed
239    ///
240    /// Returns addresses of peers that have exceeded their timeout:
241    /// - Disconnected peers: `disconnected_timeout` since last seen
242    /// - Connected peers: `connected_timeout` since last seen (handles ghost connections)
243    pub fn get_stale_peers(&self) -> Vec<StalePeerInfo> {
244        self.peers
245            .iter()
246            .filter_map(|(address, state)| {
247                let time_since_last_seen = state.last_seen.elapsed();
248
249                let (is_stale, reason) = if state.connected {
250                    // Connected peers get longer timeout
251                    let is_stale = time_since_last_seen > self.config.connected_timeout;
252                    (is_stale, StaleReason::ConnectedTimeout)
253                } else {
254                    // Disconnected peers have shorter timeout
255                    let is_stale = time_since_last_seen > self.config.disconnected_timeout;
256                    (is_stale, StaleReason::DisconnectedTimeout)
257                };
258
259                if is_stale {
260                    Some(StalePeerInfo {
261                        address: address.clone(),
262                        reason,
263                        time_since_last_seen,
264                        was_connected: state.connected,
265                    })
266                } else {
267                    None
268                }
269            })
270            .collect()
271    }
272
273    /// Get just the addresses of stale peers
274    pub fn get_stale_peer_addresses(&self) -> Vec<String> {
275        self.get_stale_peers()
276            .into_iter()
277            .map(|info| info.address)
278            .collect()
279    }
280
281    /// Remove a peer from tracking
282    ///
283    /// Call this after cleaning up the peer's resources.
284    pub fn remove_peer(&mut self, address: &str) -> bool {
285        if self.peers.remove(address).is_some() {
286            log::debug!("Removed peer {} from lifetime tracking", address);
287            true
288        } else {
289            false
290        }
291    }
292
293    /// Remove all stale peers and return their addresses
294    ///
295    /// Convenience method that combines `get_stale_peers` and `remove_peer`.
296    pub fn cleanup_stale_peers(&mut self) -> Vec<StalePeerInfo> {
297        let stale = self.get_stale_peers();
298
299        for info in &stale {
300            self.peers.remove(&info.address);
301        }
302
303        if !stale.is_empty() {
304            log::debug!("Cleaned up {} stale peers", stale.len());
305        }
306
307        stale
308    }
309
310    /// Get statistics about tracked peers
311    pub fn stats(&self) -> PeerLifetimeStats {
312        let mut connected = 0;
313        let mut disconnected = 0;
314
315        for state in self.peers.values() {
316            if state.connected {
317                connected += 1;
318            } else {
319                disconnected += 1;
320            }
321        }
322
323        PeerLifetimeStats {
324            total_tracked: self.peers.len(),
325            connected,
326            disconnected,
327        }
328    }
329
330    /// Get detailed info about a specific peer
331    pub fn get_peer_info(&self, address: &str) -> Option<PeerInfo> {
332        self.peers.get(address).map(|state| PeerInfo {
333            connected: state.connected,
334            time_since_last_seen: state.last_seen.elapsed(),
335            time_since_first_seen: state.first_seen.elapsed(),
336            time_since_disconnect: state.disconnected_at.map(|t| t.elapsed()),
337        })
338    }
339
340    /// Clear all tracked peers
341    pub fn clear(&mut self) {
342        let count = self.peers.len();
343        self.peers.clear();
344        if count > 0 {
345            log::debug!("Cleared {} peers from lifetime tracking", count);
346        }
347    }
348
349    /// Get the number of tracked peers
350    pub fn tracked_count(&self) -> usize {
351        self.peers.len()
352    }
353
354    /// Get the cleanup interval from configuration
355    pub fn cleanup_interval(&self) -> Duration {
356        self.config.cleanup_interval
357    }
358}
359
360/// Statistics about tracked peers
361#[derive(Debug, Clone, Copy)]
362pub struct PeerLifetimeStats {
363    /// Total number of tracked peers
364    pub total_tracked: usize,
365    /// Number of connected peers
366    pub connected: usize,
367    /// Number of disconnected peers
368    pub disconnected: usize,
369}
370
371/// Detailed information about a peer
372#[derive(Debug, Clone)]
373pub struct PeerInfo {
374    /// Whether the peer is currently connected
375    pub connected: bool,
376    /// Time since last activity
377    pub time_since_last_seen: Duration,
378    /// Time since first discovery
379    pub time_since_first_seen: Duration,
380    /// Time since disconnect (if disconnected)
381    pub time_since_disconnect: Option<Duration>,
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::thread::sleep;
388
389    #[test]
390    fn test_new_peer_tracking() {
391        let mut manager = PeerLifetimeManager::with_defaults();
392
393        assert!(!manager.is_tracked("test"));
394
395        manager.on_peer_activity("test", true);
396
397        assert!(manager.is_tracked("test"));
398        assert!(manager.is_connected("test"));
399    }
400
401    #[test]
402    fn test_peer_disconnect() {
403        let mut manager = PeerLifetimeManager::with_defaults();
404
405        manager.on_peer_activity("test", true);
406        assert!(manager.is_connected("test"));
407
408        manager.on_peer_disconnected("test");
409        assert!(!manager.is_connected("test"));
410    }
411
412    #[test]
413    fn test_stale_peer_detection() {
414        let config = PeerLifetimeConfig {
415            disconnected_timeout: Duration::from_millis(50),
416            connected_timeout: Duration::from_millis(100),
417            cleanup_interval: Duration::from_millis(10),
418        };
419        let mut manager = PeerLifetimeManager::new(config);
420
421        // Add a disconnected peer
422        manager.on_peer_activity("test", false);
423
424        // Should not be stale yet
425        assert!(manager.get_stale_peers().is_empty());
426
427        // Wait for timeout
428        sleep(Duration::from_millis(60));
429
430        // Should be stale now
431        let stale = manager.get_stale_peers();
432        assert_eq!(stale.len(), 1);
433        assert_eq!(stale[0].address, "test");
434        assert_eq!(stale[0].reason, StaleReason::DisconnectedTimeout);
435    }
436
437    #[test]
438    fn test_cleanup_stale_peers() {
439        let config = PeerLifetimeConfig {
440            disconnected_timeout: Duration::from_millis(10),
441            connected_timeout: Duration::from_millis(100),
442            cleanup_interval: Duration::from_millis(5),
443        };
444        let mut manager = PeerLifetimeManager::new(config);
445
446        manager.on_peer_activity("peer1", false);
447        manager.on_peer_activity("peer2", true);
448
449        sleep(Duration::from_millis(20));
450
451        // Only peer1 should be stale (disconnected timeout is shorter)
452        let cleaned = manager.cleanup_stale_peers();
453        assert_eq!(cleaned.len(), 1);
454        assert_eq!(cleaned[0].address, "peer1");
455
456        // peer1 should be removed
457        assert!(!manager.is_tracked("peer1"));
458        // peer2 should still be tracked
459        assert!(manager.is_tracked("peer2"));
460    }
461
462    #[test]
463    fn test_stats() {
464        let mut manager = PeerLifetimeManager::with_defaults();
465
466        manager.on_peer_activity("connected1", true);
467        manager.on_peer_activity("connected2", true);
468        manager.on_peer_activity("disconnected1", false);
469
470        let stats = manager.stats();
471        assert_eq!(stats.total_tracked, 3);
472        assert_eq!(stats.connected, 2);
473        assert_eq!(stats.disconnected, 1);
474    }
475}