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}