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}