ryra_vm/progress.rs
1//! Standardized heartbeat for poll-until-ready loops.
2//!
3//! Every "wait for X to become ready" loop in ryra has the same shape: probe a
4//! condition on a fixed interval, bounded by a timeout, and bail if the timeout
5//! trips. Left silent, a slow wait reads as a hang. [`WaitProgress`] gives all
6//! of them one consistent line — *what* is being probed, *how many* checks have
7//! run, and the *limit* — printed on a throttled cadence so fast waits stay
8//! quiet while slow ones always show life.
9
10use std::time::{Duration, Instant};
11
12/// Tracks one poll-until-ready loop: owns the clock, counts probes, and emits a
13/// throttled heartbeat line.
14///
15/// Construct it just before the loop, then per iteration use [`timed_out`] for
16/// the bound and [`tick`] to count the probe and print a heartbeat when due:
17///
18/// ```ignore
19/// let mut progress = WaitProgress::new("vikunja", "systemctl + healthcheck", timeout)
20/// .with_prefix(format!("[{test_name}] "));
21/// loop {
22/// if ready { return Ok(()); }
23/// if failed { bail!("..."); }
24/// if progress.timed_out() { bail!("not ready after {}s", timeout.as_secs()); }
25/// progress.tick();
26/// tokio::time::sleep(interval).await;
27/// }
28/// ```
29///
30/// [`timed_out`]: WaitProgress::timed_out
31/// [`tick`]: WaitProgress::tick
32pub struct WaitProgress {
33 /// What we're waiting for, e.g. a service name.
34 label: String,
35 /// How readiness is probed, e.g. "systemctl is-active" — the "how it
36 /// checks" the heartbeat reports.
37 method: String,
38 /// Line prefix so the heartbeat lines up with surrounding output.
39 prefix: String,
40 /// Bound on the loop.
41 timeout: Duration,
42 /// Minimum gap between heartbeat lines.
43 heartbeat: Duration,
44 /// When the loop started — the single clock for `elapsed`/`timed_out`.
45 start: Instant,
46 /// When the last heartbeat printed (starts at `start`, so the first line
47 /// waits a full `heartbeat` — fast waits print nothing).
48 last_logged: Instant,
49 /// How many probes have run.
50 checks: u32,
51}
52
53impl WaitProgress {
54 /// `label` is what we're waiting for; `method` describes how readiness is
55 /// probed; the loop is bounded by `timeout`. Defaults to a two-space prefix
56 /// and a 5s heartbeat cadence — override with [`with_prefix`] /
57 /// [`with_heartbeat`].
58 ///
59 /// [`with_prefix`]: WaitProgress::with_prefix
60 /// [`with_heartbeat`]: WaitProgress::with_heartbeat
61 pub fn new(label: impl Into<String>, method: impl Into<String>, timeout: Duration) -> Self {
62 let start = Instant::now();
63 Self {
64 label: label.into(),
65 method: method.into(),
66 prefix: " ".to_string(),
67 timeout,
68 heartbeat: Duration::from_secs(5),
69 start,
70 last_logged: start,
71 checks: 0,
72 }
73 }
74
75 /// Set the line prefix so the heartbeat aligns with the surrounding output
76 /// (e.g. `"[my-test] "`). Defaults to two spaces.
77 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
78 self.prefix = prefix.into();
79 self
80 }
81
82 /// Override how often a heartbeat prints. Defaults to 5s — right for the
83 /// short service/port waits; long VM boot/SSH waits set this to 30s so they
84 /// don't flood the log.
85 pub fn with_heartbeat(mut self, every: Duration) -> Self {
86 self.heartbeat = every;
87 self
88 }
89
90 /// Time since the loop started.
91 pub fn elapsed(&self) -> Duration {
92 self.start.elapsed()
93 }
94
95 /// Whether the loop has run past its timeout.
96 pub fn timed_out(&self) -> bool {
97 self.start.elapsed() > self.timeout
98 }
99
100 /// Record one probe attempt and print a heartbeat line if the cadence is
101 /// due. Call once per loop iteration.
102 pub fn tick(&mut self) {
103 self.checks += 1;
104 if self.last_logged.elapsed() >= self.heartbeat {
105 println!(
106 "{}still waiting for {}... via {} (check {}, limit {}s)",
107 self.prefix,
108 self.label,
109 self.method,
110 self.checks,
111 self.timeout.as_secs(),
112 );
113 self.last_logged = Instant::now();
114 }
115 }
116}