Skip to main content

leviath_cli/daemon/
readiness.rs

1//! Waiting for the daemon to appear or go away.
2//!
3//! Both directions are the same shape: ask a cheap predicate whether the state
4//! has flipped yet, and keep asking until it has or the wait is over. The
5//! predicate is a socket probe, so this lives here rather than in `main.rs` -
6//! the timing is a decision worth testing, and the probe is the only part that
7//! needs a real socket.
8
9use std::time::Duration;
10
11/// How long to keep asking before giving up.
12///
13/// Windows gets longer. Starting the daemon there has to bind a named pipe and
14/// detach into a job object, and under a supervisor opening many sessions at
15/// once those serialise: the 5s that is generous on Unix was regularly missed,
16/// leaving sessions `runtime-missing` and the control pipe reporting "All pipe
17/// instances are busy" (os error 231). The cost of the longer window is paid
18/// only by a start that would otherwise have failed - the poll returns as soon
19/// as the daemon answers, and a healthy one answers in ~20ms on either
20/// platform.
21pub const READY_TIMEOUT: Duration = match cfg!(windows) {
22    true => Duration::from_secs(15),
23    false => Duration::from_secs(5),
24};
25
26/// First gap between polls.
27const FIRST_DELAY: Duration = Duration::from_millis(2);
28
29/// Ceiling the gap doubles up to.
30const MAX_DELAY: Duration = Duration::from_millis(50);
31
32/// Poll `done` until it returns true or [`READY_TIMEOUT`] elapses, starting at
33/// 2ms and doubling to a 50ms ceiling. Returns whether it flipped in time.
34///
35/// The backoff is the point. The daemon boots in about 20ms, so a fixed 50ms
36/// tick spent more time waiting than the daemon spent starting - it was most
37/// of the measured 97ms cold `lev run`. Doubling to the same 50ms ceiling
38/// leaves the slow path (a daemon that genuinely takes seconds) unchanged
39/// while making the common path cost one 2ms sleep.
40///
41/// `done` is checked before the first sleep, so a predicate that is already
42/// true costs nothing.
43///
44/// `&mut dyn FnMut` rather than `impl FnMut`: a generic parameter gives rustc
45/// one monomorphization per call site and llvm-cov instruments each
46/// separately - it reported 18 of 26 instantiations as 0-hit here even though
47/// the union of them covers every line. A trait object is one instantiation
48/// however many callers there are. Same reason `run/task.rs`'s
49/// `resolve_task_with` takes one, and the cost is a vtable dispatch on a loop
50/// that sleeps between iterations.
51pub async fn poll_until(done: &mut dyn FnMut() -> bool) -> bool {
52    let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
53    let mut delay = FIRST_DELAY;
54    while !done() {
55        if tokio::time::Instant::now() >= deadline {
56            return false;
57        }
58        tokio::time::sleep(delay).await;
59        delay = (delay * 2).min(MAX_DELAY);
60    }
61    true
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use std::cell::Cell;
68
69    /// Drive the backoff against a virtual clock: `tokio::time::pause` makes
70    /// `sleep` return as soon as nothing else can run, so a five-second
71    /// timeout is tested without waiting five seconds.
72    #[tokio::test(start_paused = true)]
73    async fn an_already_true_predicate_returns_without_sleeping() {
74        let started = tokio::time::Instant::now();
75        assert!(poll_until(&mut || true).await);
76        assert_eq!(tokio::time::Instant::now(), started, "it slept anyway");
77    }
78
79    #[tokio::test(start_paused = true)]
80    async fn a_predicate_that_never_flips_gives_up_at_the_timeout() {
81        let started = tokio::time::Instant::now();
82        assert!(!poll_until(&mut || false).await);
83        assert!(
84            tokio::time::Instant::now() - started >= READY_TIMEOUT,
85            "gave up early"
86        );
87    }
88
89    #[tokio::test(start_paused = true)]
90    async fn it_returns_as_soon_as_the_predicate_flips() {
91        let calls = Cell::new(0);
92        assert!(
93            poll_until(&mut || {
94                calls.set(calls.get() + 1);
95                calls.get() == 4
96            })
97            .await
98        );
99        assert_eq!(calls.get(), 4, "it kept polling after the flip");
100    }
101
102    #[tokio::test(start_paused = true)]
103    async fn the_delay_doubles_and_then_holds_at_the_ceiling() {
104        // Record the gap between polls by reading the virtual clock inside the
105        // predicate. Asserting on the sequence is what pins the backoff; a
106        // test that only checked the total would pass on a fixed tick.
107        let gaps = std::cell::RefCell::new(Vec::new());
108        let last = Cell::new(tokio::time::Instant::now());
109        let calls = Cell::new(0);
110        poll_until(&mut || {
111            let now = tokio::time::Instant::now();
112            gaps.borrow_mut().push(now - last.get());
113            last.set(now);
114            calls.set(calls.get() + 1);
115            calls.get() == 8
116        })
117        .await;
118
119        let gaps = gaps.borrow();
120        // gaps[0] is the first call, before any sleep.
121        assert_eq!(gaps[0], Duration::ZERO);
122        assert_eq!(gaps[1], FIRST_DELAY);
123        assert_eq!(gaps[2], FIRST_DELAY * 2);
124        assert_eq!(gaps[3], FIRST_DELAY * 4);
125        assert_eq!(gaps[4], FIRST_DELAY * 8);
126        assert_eq!(gaps[5], FIRST_DELAY * 16);
127        // 2ms doubled five times is 64ms, past the ceiling, so it clamps and
128        // stays there rather than growing without bound.
129        assert_eq!(gaps[6], MAX_DELAY);
130        assert_eq!(gaps[7], MAX_DELAY);
131    }
132
133    /// The window is a platform decision, so assert the decision rather than a
134    /// number: Windows has to bind a named pipe and detach into a job object,
135    /// and a supervisor starting many sessions serialises those.
136    #[test]
137    fn windows_gets_a_longer_readiness_window_than_unix() {
138        // Arithmetic rather than a branch: a `match`/`if` on `cfg!` leaves the
139        // other platform's arm unreachable here, which the 100% gate reads as an
140        // uncovered region.
141        let expected = 5 + 10 * u64::from(cfg!(windows));
142        assert_eq!(READY_TIMEOUT.as_secs(), expected);
143        // Whatever the platform, the window has to outlast the backoff ceiling
144        // by enough to poll more than once - a window shorter than MAX_DELAY
145        // would give up after a single sleep.
146        assert!(READY_TIMEOUT > MAX_DELAY * 10);
147    }
148}