Skip to main content

anodizer_core/
progress.rs

1//! Heartbeat / liveness progress for long-running work.
2//!
3//! At default verbosity anodizer shows a per-artifact result line but nothing
4//! *during* a step, so a legitimately slow operation — a large `cargo publish`,
5//! a multi-minute asset upload over a slow link, notarytool polling Apple — is
6//! visually indistinguishable from a hang. Two heartbeat paths close that gap,
7//! both emitting a `still …` line ([`heartbeat_message`]) on a shared cadence:
8//!
9//! - **Subprocess** waits go through [`crate::run`], whose capture loop runs a
10//!   [`run_ticker`] thread. This covers every shelled-out tool — cargo,
11//!   notarytool, docker, snapcraft, …
12//! - **Pure-async** waits (the octocrab / forge HTTP calls that never spawn a
13//!   subprocess) wrap their future in [`with_heartbeat`].
14//!
15//! Both suppress at verbose (the live subprocess tee is already the progress
16//! signal) and when the cadence env is set to `0`.
17
18use std::time::{Duration, Instant};
19
20use crate::config::HumanDuration;
21use crate::log::StageLogger;
22
23/// Default quiet period before the first heartbeat, and the cadence between
24/// subsequent ones.
25pub const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
26
27/// Env override for the heartbeat cadence, in milliseconds. The test hook: the
28/// suite sets a few-ms interval so a heartbeat fires without a 30s wait. `0`
29/// disables heartbeats entirely; a malformed value degrades to the default
30/// (heartbeats are presentation, never worth failing a run over).
31pub const HEARTBEAT_INTERVAL_ENV: &str = "ANODIZER_HEARTBEAT_INTERVAL_MS";
32
33/// Resolve the heartbeat cadence — `None` when disabled (`…_MS=0`).
34pub fn heartbeat_interval() -> Option<Duration> {
35    match std::env::var(HEARTBEAT_INTERVAL_ENV).ok().as_deref() {
36        Some(raw) => match raw.trim().parse::<u64>() {
37            Ok(0) => None,
38            Ok(ms) => Some(Duration::from_millis(ms)),
39            Err(_) => Some(DEFAULT_HEARTBEAT_INTERVAL),
40        },
41        None => Some(DEFAULT_HEARTBEAT_INTERVAL),
42    }
43}
44
45/// Render `d` for a heartbeat line using the tool's canonical compact duration
46/// spelling (`45s`, `2m15s`, `1h5m`) — the same [`HumanDuration`] format config
47/// values round-trip through, so a duration reads identically everywhere
48/// anodizer prints one.
49pub fn format_elapsed(d: Duration) -> String {
50    HumanDuration(d).as_humantime_string()
51}
52
53/// Render one heartbeat line: `still {action} ({elapsed})`. The single template
54/// both drivers print, so the line's shape cannot drift between the subprocess
55/// ticker (`action` = `running cargo (aarch64-…)`) and the async combinator
56/// (`action` = `uploading foo.tar.gz`).
57pub fn heartbeat_message(action: &str, start: Instant) -> String {
58    format!("still {action} ({})", format_elapsed(start.elapsed()))
59}
60
61/// The active heartbeat cadence for `log`, or `None` when heartbeats are off —
62/// suppressed outside Normal verbosity ([`StageLogger::heartbeats_enabled`]) or
63/// when [`heartbeat_interval`] is disabled. The single gate both the subprocess
64/// ticker and [`with_heartbeat`] consult, so the on/off policy lives in one
65/// place rather than being re-derived per driver.
66pub fn heartbeat_period(log: &StageLogger) -> Option<Duration> {
67    log.heartbeats_enabled().then(heartbeat_interval).flatten()
68}
69
70/// Run `on_tick` every `interval` until the paired `Sender` sends or is
71/// dropped, then return. The caller spawns this on whatever thread flavor it
72/// owns (a scoped thread in [`crate::run`], an owned thread in
73/// [`crate::disk::FreeSpaceSampler`]); the sender-drop wake means shutdown is
74/// immediate — the ticker never has to wait out a residual interval. Because
75/// each emission is followed by a fresh full-interval wait, a scheduler stall
76/// can never burst a backlog of ticks: at most one fires per elapsed period.
77pub fn run_ticker(
78    stop_rx: &std::sync::mpsc::Receiver<()>,
79    interval: Duration,
80    mut on_tick: impl FnMut(),
81) {
82    while let Err(std::sync::mpsc::RecvTimeoutError::Timeout) = stop_rx.recv_timeout(interval) {
83        on_tick();
84    }
85}
86
87/// Await `fut`, emitting a [`heartbeat_message`] every [`heartbeat_interval`]
88/// until it resolves, then return its output.
89///
90/// For a single opaque future (a forge asset upload) there is no intermediate
91/// progress signal, so the heartbeat ticks on pure elapsed time and cancels the
92/// instant the future completes — the `select!` drops the timer arm as soon as
93/// the `fut` arm wins. The first heartbeat lands one full interval in (the
94/// immediate zeroth `interval` tick is consumed before the loop), so a fast
95/// upload never prints one. Suppressed at verbose or when the cadence is
96/// disabled: the future is simply awaited.
97pub async fn with_heartbeat<F, T>(log: &StageLogger, label: &str, fut: F) -> T
98where
99    F: std::future::Future<Output = T>,
100{
101    tokio::pin!(fut);
102    let Some(period) = heartbeat_period(log) else {
103        return fut.await;
104    };
105    let start = Instant::now();
106    let mut ticker = tokio::time::interval(period);
107    // Missed ticks (a busy reactor stalling the poll) must not burst a backlog
108    // of heartbeats on resume — one line per real elapsed period is the intent.
109    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
110    // `interval`'s first tick fires immediately; drop it so heartbeat #1 is one
111    // full period in, not at t=0.
112    ticker.tick().await;
113    loop {
114        tokio::select! {
115            out = &mut fut => return out,
116            _ = ticker.tick() => {
117                log.heartbeat(&heartbeat_message(label, start));
118            }
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::test_helpers::env::{EnvGuard, env_mutex};
127
128    #[test]
129    fn interval_env_zero_disables_and_malformed_degrades() {
130        let _lock = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
131        let _g = EnvGuard::set(HEARTBEAT_INTERVAL_ENV, "0");
132        assert_eq!(heartbeat_interval(), None, "0 disables heartbeats");
133        let _g = EnvGuard::set(HEARTBEAT_INTERVAL_ENV, "75");
134        assert_eq!(heartbeat_interval(), Some(Duration::from_millis(75)));
135        let _g = EnvGuard::set(HEARTBEAT_INTERVAL_ENV, "not-a-number");
136        assert_eq!(
137            heartbeat_interval(),
138            Some(DEFAULT_HEARTBEAT_INTERVAL),
139            "malformed cadence degrades to the default"
140        );
141    }
142
143    #[test]
144    fn run_ticker_ticks_until_sender_drops() {
145        use std::sync::Arc;
146        use std::sync::atomic::{AtomicU32, Ordering};
147
148        let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
149        let ticks = Arc::new(AtomicU32::new(0));
150        // Drop the sender only after two ticks have provably fired — a fixed
151        // wall-clock window is not assertable on a loaded CI runner, where
152        // recv_timeout can oversleep past the whole window. The generous
153        // deadline bails the loop so a broken ticker fails the assertion
154        // instead of hanging the test.
155        let observed = Arc::clone(&ticks);
156        let handle = std::thread::spawn(move || {
157            let deadline = std::time::Instant::now() + Duration::from_secs(30);
158            while observed.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
159                std::thread::sleep(Duration::from_millis(5));
160            }
161            drop(stop_tx);
162        });
163        let counter = Arc::clone(&ticks);
164        run_ticker(&stop_rx, Duration::from_millis(10), || {
165            counter.fetch_add(1, Ordering::SeqCst);
166        });
167        handle.join().expect("dropper thread");
168        let ticks = ticks.load(Ordering::SeqCst);
169        assert!(
170            ticks >= 2,
171            "the ticker must keep ticking until the sender drops; got {ticks}"
172        );
173    }
174
175    // The env lock is held across `.await` to serialize the whole test against
176    // other env-mutating cases; safe here because the `current_thread` runtime
177    // has no other task contending the std Mutex within this test.
178    #[allow(clippy::await_holding_lock)]
179    #[tokio::test(flavor = "current_thread")]
180    async fn slow_future_emits_heartbeats_fast_future_does_not() {
181        let _lock = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
182        let _g = EnvGuard::set(HEARTBEAT_INTERVAL_ENV, "30");
183
184        // A future that resolves before the first cadence prints no heartbeat.
185        let (fast_log, fast_cap) = StageLogger::with_capture("t", crate::log::Verbosity::Normal);
186        with_heartbeat(&fast_log, "uploading", async {
187            tokio::time::sleep(Duration::from_millis(5)).await;
188        })
189        .await;
190        assert_eq!(
191            fast_cap.heartbeat_count(),
192            0,
193            "sub-cadence future must not heartbeat"
194        );
195
196        // A future spanning several cadences prints one heartbeat per elapsed
197        // interval. Real-time bound: ≥2 over ~200ms at a 30ms cadence, robust to
198        // scheduler jitter without asserting an exact count.
199        let (slow_log, slow_cap) = StageLogger::with_capture("t", crate::log::Verbosity::Normal);
200        with_heartbeat(&slow_log, "uploading", async {
201            tokio::time::sleep(Duration::from_millis(200)).await;
202        })
203        .await;
204        assert!(
205            slow_cap.heartbeat_count() >= 2,
206            "a multi-cadence future must heartbeat repeatedly; got {}",
207            slow_cap.heartbeat_count()
208        );
209    }
210
211    #[allow(clippy::await_holding_lock)]
212    #[tokio::test(flavor = "current_thread")]
213    async fn verbose_suppresses_heartbeats() {
214        let _lock = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
215        let _g = EnvGuard::set(HEARTBEAT_INTERVAL_ENV, "30");
216        let (log, cap) = StageLogger::with_capture("t", crate::log::Verbosity::Verbose);
217        with_heartbeat(&log, "uploading", async {
218            tokio::time::sleep(Duration::from_millis(150)).await;
219        })
220        .await;
221        assert_eq!(
222            cap.heartbeat_count(),
223            0,
224            "verbose tee is the progress signal; no heartbeat"
225        );
226    }
227}