Skip to main content

ados_protocol/
retry.rs

1//! The one pacing primitive for a recovery loop.
2//!
3//! Six independent backoff implementations existed across the daemon crates
4//! before this module, four of them exponential and two of those byte-identical
5//! copies of each other. Each was written by someone reaching for the reflex
6//! answer — double the wait, cap it, give up after N — which is correct for a
7//! *client of a remote service you are one of many callers of*, and wrong for
8//! every recovery loop on this vehicle. The two are not the same problem, and
9//! conflating them is what this module exists to stop.
10//!
11//! ## Why fixed, and why forever
12//!
13//! Exponential backoff protects a shared remote resource from a thundering
14//! herd. A drone recovering its own radio, its own camera, its own local Unix
15//! socket is not part of a herd: there is one caller and the resource is on the
16//! same board. So the growth buys nothing, and it costs the one thing that
17//! matters — time-to-recovery at exactly the moment recovery is needed. A
18//! 250 ms→5 s ladder on a *local socket* means the reader is idle for five
19//! seconds after the writer comes back.
20//!
21//! An attempt cap or a latched failed state costs more than that. It converts a
22//! recoverable fault into an unrecoverable one: the vehicle stops trying, and
23//! the only way back is an operator with an SSH session — which, on a drone in
24//! a field, is the same thing as a dead vehicle. Worse, the caps are typically
25//! placed so the state they latch cannot clear on its own, because clearing it
26//! requires the very attempts the latch stopped.
27//!
28//! ## Why jitter is a first-class knob and not an option
29//!
30//! `ados-supervisor`'s WFB bind loop is the case that proves it. The bind is a
31//! two-party rendezvous: the drone's tunnel only opens while the ground
32//! station's server is beaconing, each rig runs its own independent
33//! attempt-then-wait loop, and the two rigs have no shared clock. With a fixed
34//! interval and no jitter, two rigs that started at different times stay
35//! phase-locked apart forever — both radios healthy, neither ever binding.
36//! Jitter is what breaks the lock. Any primitive that treated it as optional
37//! would reintroduce a defect that took a bench to find.
38//!
39//! ## What this is not
40//!
41//! Not for a bounded, session-scoped operation with a caller waiting on the
42//! answer: an RPC that resends twice before reporting no-ack, a setup helper
43//! inside one bind session, a socat connect budget. Those have a legitimate
44//! terminal outcome, which is the honest per-request answer "it did not
45//! respond" — not a latched daemon state. Bounding those is correct and they
46//! deliberately do not use this.
47
48use core::time::Duration;
49
50/// Pacing for a recovery loop that never gives up.
51///
52/// Construct once, `wait()` before each attempt. There is no `next()` that
53/// grows, no attempt counter, and no terminal state — that absence is the
54/// contract.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct RetryPace {
57    base: Duration,
58    jitter: Duration,
59}
60
61impl RetryPace {
62    /// A fixed interval with no jitter.
63    ///
64    /// Correct only where nothing else on the estate is looping against the
65    /// same resource on the same cadence. If two independent loops can
66    /// rendezvous, use [`RetryPace::jittered`].
67    pub const fn fixed(base: Duration) -> Self {
68        Self {
69            base,
70            jitter: Duration::ZERO,
71        }
72    }
73
74    /// A fixed interval plus a uniform random `0..=jitter`.
75    ///
76    /// The jitter breaks phase-lock between two loops that have no shared
77    /// clock, which is what the WFB bind rendezvous needs.
78    pub const fn jittered(base: Duration, jitter: Duration) -> Self {
79        Self { base, jitter }
80    }
81
82    /// The floor of the interval.
83    pub const fn base(&self) -> Duration {
84        self.base
85    }
86
87    /// The width of the jitter window.
88    pub const fn jitter(&self) -> Duration {
89        self.jitter
90    }
91
92    /// The wait before the next attempt.
93    ///
94    /// Deliberately takes no attempt index: an interval that could vary by
95    /// attempt is an interval that can grow, and the whole point is that this
96    /// one cannot. Compute the jitter from `rand` in `0..=jitter`; a failure to
97    /// read the OS entropy source degrades to zero jitter rather than
98    /// propagating, because a loop that stops pacing is worse than a loop that
99    /// briefly loses its phase-lock protection.
100    pub fn wait(&self) -> Duration {
101        if self.jitter.is_zero() {
102            return self.base;
103        }
104        let span = self.jitter.as_millis().saturating_add(1);
105        let mut byte = [0u8; 8];
106        let offset_ms = match getrandom::fill(&mut byte) {
107            Ok(()) => (u64::from_le_bytes(byte) as u128 % span) as u64,
108            Err(_) => 0,
109        };
110        self.base + Duration::from_millis(offset_ms)
111    }
112
113    /// The widest wait this pace can produce, for a test or a doc.
114    pub fn max_wait(&self) -> Duration {
115        self.base + self.jitter
116    }
117}
118
119/// The pacing band a recovery loop is expected to sit in.
120///
121/// Not enforced by the type — a USB rebind and a Unix-socket reconnect
122/// genuinely want different magnitudes, and a type that forbade that would be
123/// obeyed by someone adding a growth ladder somewhere the linter cannot see.
124/// It is here so the workspace test in `tests/recovery_loops_never_give_up.rs`
125/// can name the band, and so a site outside it is a deliberate, commented
126/// decision rather than a default.
127pub const RECOVERY_BAND: (Duration, Duration) =
128    (Duration::from_millis(250), Duration::from_secs(5));
129
130/// A local Unix-socket reader reconnecting to a peer daemon on the same board.
131///
132/// No jitter: there is exactly one reader per socket, so there is no herd and
133/// nothing to de-phase. Short, because the socket reappearing is the common
134/// case (the peer restarted) and the cost of the wait is stale telemetry on an
135/// operator's screen.
136pub const LOCAL_SOCKET: RetryPace = RetryPace::fixed(Duration::from_millis(500));
137
138/// A two-party rendezvous where both sides loop independently with no shared
139/// clock. Short base, wide jitter — see the module docs.
140pub const RENDEZVOUS: RetryPace =
141    RetryPace::jittered(Duration::from_secs(3), Duration::from_secs(4));
142
143/// Re-driving a piece of OS or hardware state that refused once: a regulatory
144/// domain that lost a `cfg80211` race, an interface that was not up yet.
145pub const OS_STATE: RetryPace = RetryPace::fixed(Duration::from_secs(2));
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn a_fixed_pace_does_not_move() {
153        // The property the exponential ladders violated: the interval after
154        // the hundredth failure is the interval after the first.
155        let pace = RetryPace::fixed(Duration::from_secs(3));
156        let waits: Vec<Duration> = (0..100).map(|_| pace.wait()).collect();
157        assert!(
158            waits.iter().all(|w| *w == Duration::from_secs(3)),
159            "a fixed pace grew"
160        );
161    }
162
163    #[test]
164    fn a_jittered_pace_stays_inside_its_band_and_actually_varies() {
165        let pace = RetryPace::jittered(Duration::from_secs(3), Duration::from_secs(4));
166        let waits: Vec<Duration> = (0..200).map(|_| pace.wait()).collect();
167        assert!(
168            waits
169                .iter()
170                .all(|w| *w >= pace.base() && *w <= pace.max_wait()),
171            "jitter escaped its band"
172        );
173        // If the jitter were a no-op the phase-lock defect would be back with
174        // the tests still green, so assert it is really random.
175        let distinct: std::collections::BTreeSet<u128> =
176            waits.iter().map(|w| w.as_millis()).collect();
177        assert!(
178            distinct.len() > 10,
179            "jitter produced only {} distinct waits in 200 draws",
180            distinct.len()
181        );
182    }
183
184    #[test]
185    fn zero_jitter_is_exactly_the_base() {
186        let pace = RetryPace::jittered(Duration::from_secs(2), Duration::ZERO);
187        assert_eq!(pace.wait(), Duration::from_secs(2));
188    }
189
190    #[test]
191    fn the_shipped_paces_sit_in_the_recovery_band() {
192        let (lo, hi) = RECOVERY_BAND;
193        for (name, pace) in [
194            ("LOCAL_SOCKET", LOCAL_SOCKET),
195            ("RENDEZVOUS", RENDEZVOUS),
196            ("OS_STATE", OS_STATE),
197        ] {
198            assert!(
199                pace.base() >= lo && pace.base() <= hi,
200                "{name} base {:?} is outside the recovery band {lo:?}..={hi:?}",
201                pace.base()
202            );
203        }
204    }
205
206    #[test]
207    fn the_rendezvous_pace_keeps_the_jitter_the_bench_needed() {
208        // Two rigs looping with identical timing must be able to de-phase, so
209        // the jitter window has to be a real fraction of the base rather than a
210        // token millisecond.
211        assert!(RENDEZVOUS.jitter() >= RENDEZVOUS.base());
212    }
213}