Skip to main content

phantom_protocol/transport/
liveness.rs

1//! Path-liveness decision logic (Phase 4 / P4.3).
2//!
3//! A purely-functional, async-free, [`Duration`]-only decision so the threshold
4//! logic is exhaustively unit-testable without standing up the data pump. The
5//! pump gathers the live signals each heartbeat and applies the returned verdict.
6//!
7//! Liveness signal (design §D4): *N×PTO of inbound silence while we have
8//! outstanding unacked reliable data → the path is down.* The `inflight > 0` gate
9//! is what keeps a slow-but-alive or genuinely-idle path from false-tripping — we
10//! only declare a path down when we are *expecting* a response.
11//!
12//! A purely-passive receiver (download-only, sending only ACKs) has nothing in
13//! flight, so the inactivity sweep alone would never trip for it. Direction #3
14//! closes that gap with an **idle keep-alive PING** ([`should_send_keepalive`]):
15//! when a `Connected` session has been idle for `keepalive_interval`, the pump
16//! emits a small `ENCRYPTED | KEEPALIVE` packet. That PING is in-flight until the
17//! peer PONGs it, so the very same `inflight > 0` sweep now detects a dead
18//! downstream on a download-only path — and the PONG refreshes the peer's
19//! activity timer symmetrically.
20
21use core::time::Duration;
22
23/// Tunables for path-down / session-death detection (Phase 4 / P4.3).
24#[derive(Debug, Clone, Copy)]
25pub struct LivenessConfig {
26    /// Floor for the probe-timeout period. The effective PTO is
27    /// `max(min_pto, 3 × min_rtt)`, so an unmeasured RTT (`min_rtt == 0`) falls
28    /// back to this floor.
29    pub min_pto: Duration,
30    /// Consecutive probe-timeout periods of inbound silence — while we have
31    /// outstanding unacked data — before the path is declared down.
32    pub path_down_ptos: u32,
33    /// Once `Migrating`, how long to wait for the path to recover (a migrate +
34    /// validate, or any inbound life) before declaring the session dead.
35    pub idle_timeout: Duration,
36    /// Idle keep-alive interval (Direction #3 — download-only liveness). When
37    /// `Some(iv)`, an otherwise-idle `Connected` session (no inbound for `iv`
38    /// **and** nothing in flight) emits a small encrypted keep-alive PING every
39    /// `iv`. That PING is in-flight until the peer PONGs it, so a download-only
40    /// path — which sends only ACKs and otherwise has nothing in flight — gains
41    /// an outstanding probe and can detect a silently-dead downstream via the
42    /// same `path_down_ptos × PTO` sweep. `None` disables keep-alives (the path
43    /// only trips on inactivity while reliable data is already outstanding).
44    pub keepalive_interval: Option<Duration>,
45}
46
47impl Default for LivenessConfig {
48    fn default() -> Self {
49        Self {
50            // ~1s of silence-with-outstanding-data on a fast path (5 × 200ms)
51            // before "path down" — responsive but well clear of transient blips.
52            min_pto: Duration::from_millis(200),
53            path_down_ptos: 5,
54            idle_timeout: Duration::from_secs(30),
55            // A 15s idle PING keeps a download-only path detectable: well under
56            // the 30s idle-timeout, comfortably above any transient inbound gap,
57            // and far rarer than data traffic so it adds negligible overhead.
58            keepalive_interval: Some(Duration::from_secs(15)),
59        }
60    }
61}
62
63impl LivenessConfig {
64    /// Shrunk thresholds so tests/integration exercise the state machine in
65    /// milliseconds instead of seconds (mirrors `Session::set_rekey_threshold`).
66    pub fn for_test() -> Self {
67        Self {
68            min_pto: Duration::from_millis(10),
69            path_down_ptos: 3,
70            idle_timeout: Duration::from_millis(300),
71            // Fire a keep-alive quickly so the download-only path test exercises
72            // it in milliseconds rather than waiting the 15s production cadence.
73            keepalive_interval: Some(Duration::from_millis(40)),
74        }
75    }
76
77    /// Effective probe-timeout period for an estimated `min_rtt`: `3 × RTT`,
78    /// floored at `min_pto`. (Session-level approximation of RFC 9002 PTO; we do
79    /// not track `rttvar` at the session level.)
80    fn pto(&self, min_rtt: Duration) -> Duration {
81        let scaled = min_rtt.saturating_mul(3);
82        if scaled > self.min_pto {
83            scaled
84        } else {
85            self.min_pto
86        }
87    }
88
89    /// Inbound-silence (with outstanding data) beyond which the path is down.
90    fn path_down_after(&self, min_rtt: Duration) -> Duration {
91        self.pto(min_rtt).saturating_mul(self.path_down_ptos)
92    }
93}
94
95/// The transition implied by one liveness evaluation. The caller maps it onto the
96/// session state machine (`Connected` ⇄ `Migrating` → `Dead`).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum LivenessVerdict {
99    /// No transition — keep the current state.
100    Unchanged,
101    /// Path is down: `Connected → Migrating` (surface "migrate me"; hold keys).
102    PathDown,
103    /// Inbound life resumed while `Migrating`: `Migrating → Connected`.
104    Recovered,
105    /// Idle-timeout elapsed while `Migrating` with no recovery: `Migrating → Dead`.
106    Dead,
107}
108
109/// Decide the liveness transition from the current signals (pure).
110///
111/// - `silence`: time since the last authenticated inbound packet.
112/// - `inflight`: outstanding unacked reliable bytes (BBR in-flight) — the gate
113///   that keeps a slow-but-alive or idle path from false-tripping.
114/// - `min_rtt`: path RTT estimate (`0`/unset → the `min_pto` floor governs).
115/// - `in_migrating`: whether we are already in the `Migrating` keep-alive state.
116/// - `migrating_for`: how long we have been `Migrating` (ignored when not).
117pub fn liveness_verdict(
118    silence: Duration,
119    inflight: u64,
120    min_rtt: Duration,
121    in_migrating: bool,
122    migrating_for: Duration,
123    cfg: &LivenessConfig,
124) -> LivenessVerdict {
125    if in_migrating {
126        // Recovery beats death: if inbound just resumed (silence within one PTO),
127        // recover even at/after the idle-timeout edge — liveness over teardown.
128        if silence <= cfg.pto(min_rtt) {
129            return LivenessVerdict::Recovered;
130        }
131        if migrating_for > cfg.idle_timeout {
132            return LivenessVerdict::Dead;
133        }
134        return LivenessVerdict::Unchanged;
135    }
136    // Connected: down only when we have outstanding data AND inbound has been
137    // silent past `path_down_ptos × PTO`.
138    if inflight > 0 && silence > cfg.path_down_after(min_rtt) {
139        LivenessVerdict::PathDown
140    } else {
141        LivenessVerdict::Unchanged
142    }
143}
144
145/// Decide whether an idle `Connected` session should emit a keep-alive PING now
146/// (Direction #3 — download-only liveness). Pure, so the cadence gate is unit-
147/// testable without the data pump.
148///
149/// Fires only when **all** hold:
150/// - keep-alives are enabled (`cfg.keepalive_interval.is_some()`),
151/// - the session is `Connected` (not `Migrating`/handshaking/closed),
152/// - nothing is in flight (`inflight == 0`) — an active reliable transfer
153///   already anchors the liveness timer, so a keep-alive would be redundant
154///   *and* could race the in-flight accounting,
155/// - inbound has been silent for at least the interval (`inbound_silence ≥ iv`)
156///   — the path is genuinely idle, not mid-download, and
157/// - we have not sent a keep-alive within the last interval
158///   (`since_last_keepalive ≥ iv`) — so at most one PING per interval, never a
159///   spam burst on the 10 ms pump heartbeat.
160///
161/// The emitted PING is itself in-flight until PONGed, which is exactly what lets
162/// the [`liveness_verdict`] `inflight > 0` gate then detect a dead downstream on a
163/// path that would otherwise only ever send ACKs.
164pub fn should_send_keepalive(
165    connected: bool,
166    inflight: u64,
167    inbound_silence: Duration,
168    since_last_keepalive: Duration,
169    cfg: &LivenessConfig,
170) -> bool {
171    let Some(iv) = cfg.keepalive_interval else {
172        return false;
173    };
174    connected && inflight == 0 && inbound_silence >= iv && since_last_keepalive >= iv
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    /// Explicit thresholds so the boundary asserts are obvious: with `min_rtt = 0`
182    /// the PTO is `min_pto = 100ms` and the path-down threshold is `4 × 100ms = 400ms`.
183    fn cfg() -> LivenessConfig {
184        LivenessConfig {
185            min_pto: Duration::from_millis(100),
186            path_down_ptos: 4,
187            idle_timeout: Duration::from_secs(1),
188            // Keep-alives off in these verdict tests — they assert the inactivity
189            // sweep alone; the keep-alive gate is covered by `should_send_keepalive`.
190            keepalive_interval: None,
191        }
192    }
193
194    #[test]
195    fn no_outstanding_data_never_declares_path_down() {
196        // Huge silence but nothing in flight → no transition: a quiet idle path is
197        // not a dead path *by the inactivity sweep alone*. (A download-only path is
198        // kept detectable by the idle keep-alive PING — `should_send_keepalive`.)
199        let v = liveness_verdict(
200            Duration::from_secs(60),
201            0,
202            Duration::ZERO,
203            false,
204            Duration::ZERO,
205            &cfg(),
206        );
207        assert_eq!(v, LivenessVerdict::Unchanged);
208    }
209
210    #[test]
211    fn outstanding_data_just_below_threshold_is_unchanged() {
212        let v = liveness_verdict(
213            Duration::from_millis(399),
214            1200,
215            Duration::ZERO,
216            false,
217            Duration::ZERO,
218            &cfg(),
219        );
220        assert_eq!(v, LivenessVerdict::Unchanged);
221    }
222
223    #[test]
224    fn outstanding_data_past_threshold_is_path_down() {
225        let v = liveness_verdict(
226            Duration::from_millis(401),
227            1200,
228            Duration::ZERO,
229            false,
230            Duration::ZERO,
231            &cfg(),
232        );
233        assert_eq!(v, LivenessVerdict::PathDown);
234    }
235
236    #[test]
237    fn pto_scales_with_min_rtt() {
238        // min_rtt=200ms → pto = max(100ms, 3×200ms) = 600ms; threshold = 4×600 = 2400ms.
239        let below = liveness_verdict(
240            Duration::from_millis(2000),
241            800,
242            Duration::from_millis(200),
243            false,
244            Duration::ZERO,
245            &cfg(),
246        );
247        assert_eq!(
248            below,
249            LivenessVerdict::Unchanged,
250            "2000ms < 2400ms threshold"
251        );
252        let above = liveness_verdict(
253            Duration::from_millis(2500),
254            800,
255            Duration::from_millis(200),
256            false,
257            Duration::ZERO,
258            &cfg(),
259        );
260        assert_eq!(
261            above,
262            LivenessVerdict::PathDown,
263            "2500ms > 2400ms threshold"
264        );
265    }
266
267    #[test]
268    fn migrating_and_still_silent_is_unchanged_until_idle_timeout() {
269        let v = liveness_verdict(
270            Duration::from_secs(10),
271            800,
272            Duration::ZERO,
273            true,
274            Duration::from_millis(500),
275            &cfg(),
276        );
277        assert_eq!(v, LivenessVerdict::Unchanged);
278    }
279
280    #[test]
281    fn migrating_recovers_when_inbound_resumes() {
282        // Silence collapsed to within one PTO (100ms) → we just heard from the peer.
283        let v = liveness_verdict(
284            Duration::from_millis(50),
285            800,
286            Duration::ZERO,
287            true,
288            Duration::from_millis(500),
289            &cfg(),
290        );
291        assert_eq!(v, LivenessVerdict::Recovered);
292    }
293
294    #[test]
295    fn migrating_dies_after_idle_timeout() {
296        let v = liveness_verdict(
297            Duration::from_secs(10),
298            800,
299            Duration::ZERO,
300            true,
301            Duration::from_millis(1100),
302            &cfg(),
303        );
304        assert_eq!(v, LivenessVerdict::Dead);
305    }
306
307    #[test]
308    fn recovery_beats_death_at_the_idle_timeout_edge() {
309        // Even past the idle-timeout, if inbound JUST resumed we recover rather than
310        // kill the session — liveness wins.
311        let v = liveness_verdict(
312            Duration::from_millis(20),
313            800,
314            Duration::ZERO,
315            true,
316            Duration::from_secs(5),
317            &cfg(),
318        );
319        assert_eq!(v, LivenessVerdict::Recovered);
320    }
321
322    #[test]
323    fn for_test_config_is_faster_than_default() {
324        let t = LivenessConfig::for_test();
325        let d = LivenessConfig::default();
326        assert!(t.idle_timeout < d.idle_timeout);
327        assert!(t.min_pto <= d.min_pto);
328        assert!(t.path_down_ptos >= 1);
329        // The test keep-alive must fire faster than the default cadence too.
330        assert!(t.keepalive_interval.unwrap() < d.keepalive_interval.unwrap());
331    }
332
333    #[test]
334    fn default_config_is_sane() {
335        let d = LivenessConfig::default();
336        assert!(d.min_pto > Duration::ZERO);
337        assert!(d.path_down_ptos >= 1);
338        assert!(d.idle_timeout > d.min_pto);
339        // A sane default keep-alive fires well before the idle-timeout would
340        // otherwise reap an idle session, and rarely enough to be cheap.
341        let ka = d.keepalive_interval.expect("default keep-alive enabled");
342        assert!(
343            ka < d.idle_timeout,
344            "keep-alive must precede the idle-timeout"
345        );
346        assert!(ka > d.min_pto, "keep-alive must be rarer than one PTO");
347    }
348
349    // ── Idle keep-alive gate (`should_send_keepalive`, Direction #3) ─────────
350
351    /// A config with a 100 ms keep-alive interval (and the same sweep thresholds
352    /// as `cfg()`), so the boundary asserts below are obvious.
353    fn ka_cfg() -> LivenessConfig {
354        LivenessConfig {
355            keepalive_interval: Some(Duration::from_millis(100)),
356            ..cfg()
357        }
358    }
359
360    #[test]
361    fn keepalive_fires_on_an_idle_connected_path() {
362        // Connected, nothing in flight, inbound silent past the interval, and no
363        // recent keep-alive → emit one (the download-only liveness anchor).
364        assert!(should_send_keepalive(
365            true,
366            0,
367            Duration::from_millis(150),
368            Duration::from_millis(150),
369            &ka_cfg(),
370        ));
371    }
372
373    #[test]
374    fn keepalive_suppressed_when_disabled() {
375        let cfg = LivenessConfig {
376            keepalive_interval: None,
377            ..cfg()
378        };
379        assert!(!should_send_keepalive(
380            true,
381            0,
382            Duration::from_secs(60),
383            Duration::from_secs(60),
384            &cfg,
385        ));
386    }
387
388    #[test]
389    fn keepalive_suppressed_with_data_in_flight() {
390        // An active transfer already anchors the liveness timer — no PING needed.
391        assert!(!should_send_keepalive(
392            true,
393            1200,
394            Duration::from_millis(150),
395            Duration::from_millis(150),
396            &ka_cfg(),
397        ));
398    }
399
400    #[test]
401    fn keepalive_suppressed_when_not_connected() {
402        // Already Migrating (or handshaking / closed) → the keep-alive path is off.
403        assert!(!should_send_keepalive(
404            false,
405            0,
406            Duration::from_millis(150),
407            Duration::from_millis(150),
408            &ka_cfg(),
409        ));
410    }
411
412    #[test]
413    fn keepalive_suppressed_before_the_interval_and_throttled_after_a_recent_ping() {
414        // Inbound only just went quiet → not yet idle enough.
415        assert!(!should_send_keepalive(
416            true,
417            0,
418            Duration::from_millis(50),
419            Duration::from_millis(150),
420            &ka_cfg(),
421        ));
422        // Idle long enough, but a keep-alive was sent <interval ago → throttled to
423        // at most one PING per interval (no 10 ms-heartbeat spam).
424        assert!(!should_send_keepalive(
425            true,
426            0,
427            Duration::from_millis(150),
428            Duration::from_millis(50),
429            &ka_cfg(),
430        ));
431    }
432}