Skip to main content

ignition_core/
poll.rs

1//! The ONE wait/retry engine (02-04) — shared by the log tail here and
2//! by 02-05's `wait` / `restart --wait`. Deliberately ~40 lines + tests
3//! instead of a retry framework (STACK.md rejected reqwest-middleware;
4//! 02-RESEARCH §Wait-loop pattern).
5//!
6//! Semantics (research-locked):
7//! - adaptive interval: ×1.5 growth clamped to `[interval, 30 s]`
8//!   (igw-cli's verified pattern);
9//! - `Network` and `GatewayRestarting` are RETRIED (transient: the
10//!   webserver answers 503 mid-restart and connections flap);
11//! - `Auth` is NEVER retried — retrying a rejected token cannot
12//!   succeed; fail fast (exit 5);
13//! - any other error aborts;
14//! - deadline expiry → `CoreError::Network`-class timeout (exit 4,
15//!   `network_error` slug — NO new variant; the source is `None` and
16//!   `url` carries the poll's subject). The last observation rides
17//!   the dedicated `observation` field (09-07): `Some` ⇒ the gateway
18//!   ANSWERED (Display leads "no terminal state", never claims
19//!   unreachability for an observed answer); `None` ⇒ today's plain
20//!   "gateway unreachable" wording preserved.
21//!
22//! `deadline = Duration::MAX` runs until the process is killed — the
23//! documented Ctrl-C contract for `logs -f` (default kill, no envelope).
24
25use std::future::Future;
26use std::pin::Pin;
27use std::time::{Duration, Instant};
28
29use crate::error::CoreError;
30
31/// The research ceiling for the adaptive backoff (×1.5 clamped to
32/// [interval, 30 s]) — even a user-provided `max` never exceeds it.
33const BACKOFF_CEILING: Duration = Duration::from_secs(30);
34
35/// What one probe reports.
36#[derive(Debug, PartialEq, Eq)]
37pub enum PollState<T> {
38    /// Condition met — `poll` returns the value.
39    Done(T),
40    /// Not yet; the optional last observation rides the deadline error.
41    Pending(Option<String>),
42}
43
44/// Poll tuning. `Default`: 2 s interval, 30 s clamp, 120 s deadline.
45#[derive(Debug, Clone)]
46pub struct PollConfig {
47    /// What is being waited on — the deadline error names it (e.g.
48    /// `"log tail (GET /data/api/v1/logs)"`, `"/StatusPing readiness"`).
49    pub subject: String,
50    /// Wait between polls; also the backoff FLOOR (never shrinks below).
51    pub interval: Duration,
52    /// Backoff clamp ceiling (additionally capped at 30 s).
53    pub max: Duration,
54    /// Total budget; `Duration::MAX` = until the process is killed.
55    pub deadline: Duration,
56}
57
58impl Default for PollConfig {
59    fn default() -> Self {
60        Self {
61            subject: "poll".to_string(),
62            interval: Duration::from_secs(2),
63            max: BACKOFF_CEILING,
64            deadline: Duration::from_secs(120),
65        }
66    }
67}
68
69/// One probe call: a boxed future borrowing the probe closure, so the
70/// closure can carry mutable state (the tail's cursor and sink) across
71/// iterations without naming an unnameable future type.
72///
73/// `+ Send` (06-02): the Phase-6 TUI spawns whole `wait_*` actions on
74/// `tokio::spawn`, which requires the poll future (and therefore this
75/// box) to be Send. Every existing probe already captures only Send
76/// state (`&dyn GatewayApi`, `Cell`s, query values) — the bound was
77/// simply never demanded before anything spawned one.
78pub type Probe<'a, T> = Pin<Box<dyn Future<Output = Result<PollState<T>, CoreError>> + Send + 'a>>;
79
80/// The adaptive-interval step: ×1.5 growth clamped to `[floor, ceiling]`.
81/// Pure so the backoff sequence is unit-testable without sleeping.
82fn next_interval(current: Duration, floor: Duration, ceiling: Duration) -> Duration {
83    current.mul_f64(1.5).clamp(floor, ceiling)
84}
85
86/// Poll `probe` until `Done`, the deadline expires, or an unretryable
87/// error fires (see module docs for the retry matrix). The FIRST probe
88/// runs immediately — no initial sleep.
89///
90/// `state` is the probe's own mutable scratch (owned by the loop,
91/// lent fresh to every call) — the pattern that lets a borrowing
92/// async closure (`FnMut(&'a mut S) -> Probe<'a, T>`) carry a cursor
93/// or sink across iterations without naming an unnameable future
94/// type. `wait`-style callers pass `()`.
95pub async fn poll<T, S, F>(cfg: PollConfig, state: S, mut probe: F) -> Result<T, CoreError>
96where
97    F: for<'a> FnMut(&'a mut S) -> Probe<'a, T>,
98{
99    let ceiling = cfg.max.min(BACKOFF_CEILING);
100    let started = Instant::now();
101    let mut interval = cfg.interval;
102    let mut state = state;
103    let mut last_observation: Option<String> = None;
104    loop {
105        match probe(&mut state).await {
106            Ok(PollState::Done(value)) => return Ok(value),
107            Ok(PollState::Pending(observation)) => last_observation = observation,
108            // NEVER retried: a rejected token cannot succeed on retry.
109            Err(err) if matches!(err, CoreError::Auth { .. }) => return Err(err),
110            // Transient — retried until Done or deadline; a Network flap
111            // keeps the last Pending observation for the deadline message.
112            Err(CoreError::Network { .. } | CoreError::GatewayRestarting { .. }) => {}
113            // Any other class aborts immediately.
114            Err(other) => return Err(other),
115        }
116        let Some(remaining) = cfg.deadline.checked_sub(started.elapsed()) else {
117            return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
118        };
119        if remaining.is_zero() {
120            return Err(deadline_error(&cfg, started.elapsed(), &last_observation));
121        }
122        tokio::time::sleep(interval.min(remaining)).await;
123        interval = next_interval(interval, cfg.interval, ceiling);
124    }
125}
126
127/// Deadline expiry: the `network_error` slug (exit 4) carrying the
128/// subject and — when one exists — the last observation. The
129/// observation rides its DEDICATED field (09-07): `Some` means the
130/// gateway ANSWERED with a concrete state and the Display leads "no
131/// terminal state" (never "unreachable" for an observed answer);
132/// `None` keeps today's plain unreachability wording. Reusing the
133/// Network variant with `source: None` (a poll timeout has no
134/// transport error to show).
135fn deadline_error(cfg: &PollConfig, waited: Duration, last: &Option<String>) -> CoreError {
136    CoreError::Network {
137        url: format!("{} — timed out after {waited:?}", cfg.subject),
138        source: None,
139        observation: last.clone(),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use std::collections::VecDeque;
146    use std::sync::Mutex;
147    use std::time::Duration;
148
149    use super::{PollConfig, PollState, next_interval, poll};
150    use crate::error::CoreError;
151
152    /// A real transport error (instant loopback refusal) —
153    /// `reqwest::Error` has no public constructor.
154    async fn transport_error() -> reqwest::Error {
155        reqwest::get("http://127.0.0.1:1")
156            .await
157            .expect_err("dead port refuses")
158    }
159
160    /// Scripted probe steps, served in order.
161    struct FakeProbe {
162        steps: Mutex<VecDeque<Step>>,
163    }
164
165    enum Step {
166        Done(u32),
167        Pending(Option<String>),
168        Network,
169        Restarting,
170        Auth,
171        NotFound,
172    }
173
174    impl FakeProbe {
175        fn with(steps: Vec<Step>) -> Self {
176            Self {
177                steps: Mutex::new(steps.into()),
178            }
179        }
180
181        async fn next(&self) -> Result<PollState<u32>, CoreError> {
182            // Pop BEFORE matching: the guard must drop before any arm
183            // awaits (clippy: await-holding-lock).
184            let step = self.steps.lock().unwrap().pop_front();
185            match step {
186                Some(Step::Done(value)) => Ok(PollState::Done(value)),
187                Some(Step::Pending(observation)) => Ok(PollState::Pending(observation)),
188                Some(Step::Network) => Err(CoreError::Network {
189                    url: "http://127.0.0.1:1".into(),
190                    source: Some(transport_error().await),
191                    observation: None,
192                }),
193                Some(Step::Restarting) => Err(CoreError::GatewayRestarting {
194                    endpoint: Some("http://127.0.0.1:1/data/api/v1/overview".into()),
195                }),
196                Some(Step::Auth) => Err(CoreError::Auth {
197                    status: 401,
198                    endpoint: None,
199                }),
200                Some(Step::NotFound) => Err(CoreError::NotFound { endpoint: None }),
201                None => panic!("scripted steps exhausted"),
202            }
203        }
204    }
205
206    /// The counting closure shape every scripted test shares: the
207    /// counter is an owned `Arc` clone inside the future (no borrow to
208    /// outlive the HRTB), the state is lent per iteration, the step
209    /// serves in order.
210    fn counting_probe(
211        calls: std::sync::Arc<Mutex<usize>>,
212    ) -> impl for<'a> FnMut(&'a mut FakeProbe) -> super::Probe<'a, u32> {
213        move |rig| {
214            let calls = std::sync::Arc::clone(&calls);
215            Box::pin(async move {
216                *calls.lock().unwrap() += 1;
217                rig.next().await
218            })
219        }
220    }
221
222    fn counted_rig(steps: Vec<Step>) -> (FakeProbe, std::sync::Arc<Mutex<usize>>) {
223        let calls = std::sync::Arc::new(Mutex::new(0usize));
224        (FakeProbe::with(steps), std::sync::Arc::clone(&calls))
225    }
226
227    fn fast_cfg() -> PollConfig {
228        PollConfig {
229            subject: "test wait".into(),
230            interval: Duration::from_millis(1),
231            // Generous on purpose: the Network/Restarting steps build
232            // REAL transport errors (a TCP connect to a refused port),
233            // which can take tens of ms each under parallel test load —
234            // 500 ms flaked there, and 5 s flaked again on a heavily
235            // loaded box (concurrent cargo builds + agents, 08-01). The
236            // fast path this config drives is the sleep/backoff, not the
237            // deadline: the ceiling only bounds pathological load.
238            deadline: Duration::from_millis(60_000),
239            ..PollConfig::default()
240        }
241    }
242
243    /// First probe Done → value returned, exactly one call.
244    #[tokio::test]
245    async fn success_first_poll() {
246        let (rig, calls) = counted_rig(vec![Step::Done(7)]);
247        let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
248            .await
249            .expect("immediate Done");
250        assert_eq!(value, 7);
251        assert_eq!(*calls.lock().unwrap(), 1);
252    }
253
254    /// Network and GatewayRestarting are retried; Done eventually wins.
255    #[tokio::test]
256    async fn transient_errors_are_retried_then_done() {
257        let (rig, calls) = counted_rig(vec![
258            Step::Network,
259            Step::Restarting,
260            Step::Pending(Some("almost".into())),
261            Step::Done(3),
262        ]);
263        let value = poll(fast_cfg(), rig, counting_probe(calls.clone()))
264            .await
265            .expect("transients retried to Done");
266        assert_eq!(value, 3);
267        assert_eq!(*calls.lock().unwrap(), 4);
268    }
269
270    /// Auth NEVER retries — exactly one call, the error propagates.
271    #[tokio::test]
272    async fn auth_fails_immediately() {
273        let (rig, calls) = counted_rig(vec![Step::Auth, Step::Done(1)]);
274        let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
275            .await
276            .expect_err("auth aborts");
277        assert!(matches!(err, CoreError::Auth { status: 401, .. }));
278        assert_eq!(*calls.lock().unwrap(), 1, "no retry on auth");
279    }
280
281    /// Any other error class aborts immediately (no retry).
282    #[tokio::test]
283    async fn other_errors_abort_immediately() {
284        let (rig, calls) = counted_rig(vec![Step::NotFound, Step::Done(1)]);
285        let err = poll(fast_cfg(), rig, counting_probe(calls.clone()))
286            .await
287            .expect_err("not-found aborts");
288        assert!(matches!(err, CoreError::NotFound { .. }));
289        assert_eq!(*calls.lock().unwrap(), 1);
290    }
291
292    /// Deadline expiry: Network class (exit 4, `network_error` slug) —
293    /// NO new variant — with the subject AND the last observation in
294    /// the message, and no transport source.
295    #[tokio::test]
296    async fn deadline_expiry_is_network_class_with_observation() {
297        let calls = Mutex::new(0usize);
298        let err = poll(
299            PollConfig {
300                subject: "test readiness".into(),
301                interval: Duration::from_millis(1),
302                deadline: Duration::from_millis(20),
303                ..PollConfig::default()
304            },
305            &mut (),
306            |()| {
307                Box::pin(async {
308                    *calls.lock().unwrap() += 1;
309                    Ok(PollState::<()>::Pending(Some("obs-42".into())))
310                })
311            },
312        )
313        .await
314        .expect_err("deadline must expire");
315        assert!(
316            matches!(&err, CoreError::Network { source: None, .. }),
317            "deadline = Network with no transport source: {err}"
318        );
319        assert_eq!(err.exit_code(), 4);
320        assert_eq!(err.code(), "network_error");
321        let message = err.to_string();
322        assert!(
323            message.contains("test readiness"),
324            "subject named: {message}"
325        );
326        assert!(
327            message.contains("obs-42"),
328            "last observation carried: {message}"
329        );
330        assert!(message.contains("timed out"), "timeout named: {message}");
331        assert!(
332            !message.contains("unreachable"),
333            "an OBSERVED answer is never called unreachable (09-07): {message}"
334        );
335        assert!(
336            message.contains("no terminal state"),
337            "the observation-bearing lead: {message}"
338        );
339        assert!(*calls.lock().unwrap() > 1, "multiple polls before expiry");
340    }
341
342    /// The `observation: None` deadline branch (09-07): with NO last
343    /// observation the plain unreachability wording is preserved —
344    /// "gateway unreachable at {subject} — timed out after …".
345    #[tokio::test]
346    async fn deadline_without_observation_still_says_unreachable() {
347        let err = poll(
348            PollConfig {
349                subject: "silent wait".into(),
350                interval: Duration::from_millis(1),
351                deadline: Duration::from_millis(20),
352                ..PollConfig::default()
353            },
354            &mut (),
355            |()| Box::pin(async { Ok(PollState::<()>::Pending(None)) }),
356        )
357        .await
358        .expect_err("deadline must expire");
359        let message = err.to_string();
360        assert!(
361            message.starts_with("gateway unreachable at silent wait"),
362            "the no-observation wording preserved: {message}"
363        );
364        assert!(message.contains("timed out"), "timeout named: {message}");
365        assert!(
366            !message.contains("last observation"),
367            "no observation to carry: {message}"
368        );
369    }
370
371    /// The backoff sequence: 2 s → 3 s → 4.5 s → … clamped at 30 s,
372    /// never below the interval floor, and a custom smaller ceiling
373    /// holds too (the 30 s research cap is an upper bound).
374    #[test]
375    fn backoff_sequence_math() {
376        let floor = Duration::from_secs(2);
377        let ceiling = Duration::from_secs(30);
378        let mut current = floor;
379        let mut sequence = Vec::new();
380        for _ in 0..12 {
381            sequence.push(current);
382            current = next_interval(current, floor, ceiling);
383        }
384        assert_eq!(
385            sequence,
386            vec![
387                Duration::from_secs(2),
388                Duration::from_secs(3),
389                Duration::from_secs_f64(4.5),
390                Duration::from_secs_f64(6.75),
391                Duration::from_secs_f64(10.125),
392                Duration::from_secs_f64(15.1875),
393                Duration::from_secs_f64(22.781_25),
394                Duration::from_secs(30), // 34.17 s clamped
395                Duration::from_secs(30),
396                Duration::from_secs(30),
397                Duration::from_secs(30),
398                Duration::from_secs(30),
399            ],
400            "×1.5 growth clamped to [interval, 30 s]"
401        );
402        // Custom ceiling below 30 s also holds (and the floor never
403        // lets the interval shrink).
404        let tight = next_interval(
405            Duration::from_secs(3),
406            Duration::from_secs(2),
407            Duration::from_secs(4),
408        );
409        assert_eq!(tight, Duration::from_secs(4));
410        let floored = next_interval(
411            Duration::from_secs(2),
412            Duration::from_secs(2),
413            Duration::from_secs(4),
414        );
415        assert_eq!(floored, Duration::from_secs(3), "3.0 s — floor unchanged");
416    }
417}