ndn_discovery/strategy/
passive.rs

1//! Passive neighbor-detection probe scheduler.
2//!
3//! Zero-overhead discovery for mesh networks where traffic flows continuously.
4//! The multicast face receives packets from all on-link nodes; the face layer
5//! surfaces each sender's MAC via [`recv_with_source`].  When an unknown MAC
6//! appears, the protocol emits a targeted unicast hello; no broadcast is needed.
7//!
8//! ## Fallback to backoff
9//!
10//! When the link is quiet (no passive detections within `passive_idle_timeout`)
11//! the scheduler falls back to [`BackoffScheduler`] probing to catch nodes
12//! that are present but not sending any traffic.  Once passive activity
13//! resumes, the backoff fallback is suppressed again.
14//!
15//! ## Unicast vs broadcast
16//!
17//! On `PassiveDetection` the scheduler emits a [`ProbeRequest::Unicast`] for
18//! the detected face.  The fallback path emits [`ProbeRequest::Broadcast`].
19
20use std::time::{Duration, Instant};
21
22use ndn_transport::FaceId;
23
24use crate::backoff::{BackoffConfig, BackoffState};
25use crate::config::DiscoveryConfig;
26use crate::strategy::{NeighborProbeStrategy, ProbeRequest, TriggerEvent};
27
28// ─── PassiveScheduler ────────────────────────────────────────────────────────
29
30/// Probe scheduler that uses passive MAC overhearing with backoff fallback.
31pub struct PassiveScheduler {
32    /// Backoff config used for the fallback path.
33    backoff_cfg: BackoffConfig,
34    /// Mutable backoff state for the fallback path.
35    backoff_state: BackoffState,
36    /// When the next fallback probe should fire (`None` = not yet scheduled).
37    next_fallback_at: Option<Instant>,
38    /// How long without a passive detection before the fallback is activated.
39    passive_idle_timeout: Duration,
40    /// When we last saw a passive detection.
41    last_passive: Option<Instant>,
42    /// Unicast probes requested by passive detections, not yet emitted.
43    pending_unicast: Vec<FaceId>,
44    /// Whether a broadcast probe is pending (from a non-passive trigger).
45    pending_broadcast: bool,
46}
47
48impl PassiveScheduler {
49    /// Build from the relevant fields of a [`DiscoveryConfig`].
50    pub fn from_discovery_config(cfg: &DiscoveryConfig) -> Self {
51        let backoff_cfg = BackoffConfig {
52            initial_interval: cfg.hello_interval_base,
53            max_interval: cfg.hello_interval_max,
54            jitter_fraction: cfg.hello_jitter as f64,
55        };
56        // Idle timeout = 3× max interval; if no passive traffic for this long
57        // we fall back to probing.
58        let passive_idle_timeout = cfg.hello_interval_max * 3;
59        Self {
60            backoff_state: BackoffState::new(seed_from_now()),
61            backoff_cfg,
62            next_fallback_at: None,
63            passive_idle_timeout,
64            last_passive: None,
65            pending_unicast: Vec::new(),
66            pending_broadcast: true, // bootstrap probe on first tick
67        }
68    }
69
70    fn is_passive_active(&self, now: Instant) -> bool {
71        match self.last_passive {
72            None => false,
73            Some(t) => now.duration_since(t) < self.passive_idle_timeout,
74        }
75    }
76}
77
78impl NeighborProbeStrategy for PassiveScheduler {
79    fn on_tick(&mut self, now: Instant) -> Vec<ProbeRequest> {
80        let mut reqs: Vec<ProbeRequest> = Vec::new();
81
82        // Emit any queued unicast probes from passive detections.
83        for face_id in self.pending_unicast.drain(..) {
84            reqs.push(ProbeRequest::Unicast(face_id));
85        }
86
87        // Emit a pending broadcast (from a non-passive trigger).
88        if self.pending_broadcast {
89            self.pending_broadcast = false;
90            reqs.push(ProbeRequest::Broadcast);
91        }
92
93        // Fallback backoff path: only when passive detection is idle.
94        if !self.is_passive_active(now) {
95            let fire_fallback = self.next_fallback_at.map(|t| now >= t).unwrap_or(true);
96            if fire_fallback {
97                let interval = self.backoff_state.next_failure(&self.backoff_cfg);
98                self.next_fallback_at = Some(now + interval);
99                reqs.push(ProbeRequest::Broadcast);
100            }
101        }
102
103        reqs
104    }
105
106    fn on_probe_success(&mut self, _rtt: Duration) {
107        self.backoff_state.reset(&self.backoff_cfg);
108        let next = self.backoff_cfg.initial_interval;
109        self.next_fallback_at = Some(Instant::now() + next);
110    }
111
112    fn on_probe_timeout(&mut self) {
113        // Backoff advances on next fallback tick; nothing extra needed.
114    }
115
116    fn trigger(&mut self, event: TriggerEvent) {
117        match event {
118            TriggerEvent::PassiveDetection => {
119                // Update passive activity timestamp.
120                self.last_passive = Some(Instant::now());
121                // The caller is expected to call trigger with the detected
122                // face ID separately if a unicast probe is desired.  Here
123                // we just suppress the backoff fallback.
124            }
125            TriggerEvent::FaceUp => {
126                self.pending_broadcast = true;
127            }
128            TriggerEvent::ForwardingFailure | TriggerEvent::NeighborStale => {
129                self.pending_broadcast = true;
130                // Reset backoff so re-probe is fast.
131                self.backoff_state.reset(&self.backoff_cfg);
132            }
133        }
134    }
135}
136
137/// Enqueue a unicast probe toward a specific face detected passively.
138///
139/// Call this after [`trigger`]`(TriggerEvent::PassiveDetection)` when the
140/// detected MAC maps to an existing [`FaceId`] that needs a hello.
141impl PassiveScheduler {
142    pub fn enqueue_unicast(&mut self, face_id: FaceId) {
143        if !self.pending_unicast.contains(&face_id) {
144            self.pending_unicast.push(face_id);
145        }
146    }
147}
148
149// ─── RNG seed ────────────────────────────────────────────────────────────────
150
151fn seed_from_now() -> u32 {
152    let ns = Instant::now().elapsed().subsec_nanos();
153    if ns == 0 { 0xdeadbeef } else { ns }
154}
155
156// ─── Tests ────────────────────────────────────────────────────────────────────
157
158#[cfg(test)]
159mod tests {
160    use std::time::Duration;
161
162    use ndn_transport::FaceId;
163
164    use super::*;
165    use crate::config::{DiscoveryConfig, DiscoveryProfile};
166
167    fn high_mob_sched() -> PassiveScheduler {
168        PassiveScheduler::from_discovery_config(&DiscoveryConfig::for_profile(
169            &DiscoveryProfile::HighMobility,
170        ))
171    }
172
173    #[test]
174    fn fires_broadcast_on_first_tick() {
175        let mut s = high_mob_sched();
176        let reqs = s.on_tick(Instant::now());
177        assert!(reqs.contains(&ProbeRequest::Broadcast));
178    }
179
180    #[test]
181    fn unicast_after_passive_detection_enqueue() {
182        let mut s = high_mob_sched();
183        let now = Instant::now();
184        s.on_tick(now); // initial broadcast
185
186        s.trigger(TriggerEvent::PassiveDetection);
187        s.enqueue_unicast(FaceId(3));
188        let reqs = s.on_tick(now + Duration::from_millis(10));
189        assert!(reqs.contains(&ProbeRequest::Unicast(FaceId(3))));
190    }
191
192    #[test]
193    fn fallback_fires_when_passive_idle() {
194        let mut s = high_mob_sched();
195        let now = Instant::now();
196        s.on_tick(now); // initial
197
198        // Far in the future — no passive activity, fallback should fire.
199        let future = now + Duration::from_secs(3600);
200        let reqs = s.on_tick(future);
201        assert!(reqs.contains(&ProbeRequest::Broadcast));
202    }
203
204    #[test]
205    fn fallback_suppressed_when_passive_active() {
206        let mut s = high_mob_sched();
207        let now = Instant::now();
208        s.on_tick(now); // initial broadcast consumed
209
210        // Record recent passive detection.
211        s.trigger(TriggerEvent::PassiveDetection);
212        // Advance by less than passive_idle_timeout.
213        let soon = now + Duration::from_millis(100);
214        let reqs = s.on_tick(soon);
215        // No fallback broadcast (passive is still active); no pending_broadcast.
216        let broadcasts: Vec<_> = reqs
217            .iter()
218            .filter(|r| **r == ProbeRequest::Broadcast)
219            .collect();
220        assert!(
221            broadcasts.is_empty(),
222            "fallback should be suppressed: {reqs:?}"
223        );
224    }
225
226    #[test]
227    fn face_up_trigger_broadcasts() {
228        let mut s = high_mob_sched();
229        let now = Instant::now();
230        s.on_tick(now); // initial
231
232        s.trigger(TriggerEvent::FaceUp);
233        let reqs = s.on_tick(now + Duration::from_millis(10));
234        assert!(reqs.contains(&ProbeRequest::Broadcast));
235    }
236}