Skip to main content

rightkit_tauri_shell/
watchdog.rs

1//! Main-thread hang watchdog.
2//!
3//! Base: HeardRight `hang_watchdog` (a dedicated OS thread watches a heartbeat that a recurring
4//! `run_on_main_thread` ping refreshes; the report fires once per stall episode and re-arms after a
5//! fresh beat; neither thread takes an app lock, so the watchdog can observe a deadlock without
6//! joining it). Changes: the report goes to a caller callback and a `rightkit-shell://stall`
7//! event instead of HeardRight's crash-dump module, and the threshold/interval are configurable.
8//! The watchdog thread touches only atomics, its wake-up `Bell` and the callback in callback-only
9//! mode ([`WatchdogConfig::emit_event`] `false`), HeardRight's `hang_watchdog` rule.
10//! HeardRight's opt-in macOS stack-sampling variant (`ui_stall_watchdog`) is not generalised.
11//!
12//! Wake-ups. Neither thread polls. The heartbeat thread sleeps one heartbeat period on the bell and
13//! pings; the watchdog thread sleeps until the earliest moment a stall could be reported (the last
14//! beat plus the threshold), looks at the beat again then, and sleeps to the next such moment. Idle,
15//! that is one heartbeat wake-up per period plus one watchdog wake-up per threshold (about 1.2 a
16//! second with the defaults, where the old 250 ms poll plus the heartbeat made about 5 a second
17//! from these two threads). While a stall episode is open the watchdog thread has nothing to time,
18//! so it waits for the next beat, which wakes it, or for the stop, which also does. Both threads
19//! wait on one `Bell`: a mutex and two condition variables, the same on every platform. The two
20//! threads are kept apart on purpose: the heartbeat thread holds the `AppHandle`, the watchdog
21//! thread never does in callback-only mode, so a call that blocks on app state cannot delay a report.
22
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::{Arc, Condvar, Mutex, MutexGuard};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27use tauri::{AppHandle, Emitter, Wry};
28
29use crate::EVENT_STALL;
30
31#[derive(Debug, Clone, Copy)]
32pub struct StallReport {
33    pub stalled_ms: u64,
34    pub threshold_ms: u64,
35}
36
37pub type StallCallback = Arc<dyn Fn(&StallReport) + Send + Sync>;
38
39/// Default for [`WatchdogConfig::poll_interval`] (HeardRight's cadence): the lateness allowed past
40/// the threshold, not a polling period.
41pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
42
43#[derive(Clone)]
44pub struct WatchdogConfig {
45    /// A heartbeat older than this is a stall.
46    pub threshold: Duration,
47    /// How often the main thread is pinged. The floor is 10 ms.
48    pub heartbeat_interval: Duration,
49    /// The shortest the watchdog thread waits between two looks at the heartbeat, which bounds how
50    /// late a stall is reported past the threshold. Default 250 ms; the floor is 10 ms. The thread
51    /// no longer polls at this rate: idle it sleeps until the last beat plus the threshold can have
52    /// passed, so a larger value only widens the allowed lateness and a smaller one only shrinks it.
53    pub poll_interval: Duration,
54    /// Called once per stall episode with the stall duration (see [`StallReport::stalled_ms`]), on the
55    /// watchdog thread. A host can use it to write a hang dump. It runs before the `rightkit-shell://stall`
56    /// event.
57    pub on_stall: Option<StallCallback>,
58    /// Also emit the `rightkit-shell://stall` event to the webview. Default `true`.
59    ///
60    /// Hazard: the event is emitted from the watchdog thread through the `AppHandle`, and that
61    /// thread exists to observe an app that may be deadlocked. Emitting takes app-side locks, so a
62    /// deadlock in the app can stop the watchdog thread too, at the moment its report matters most.
63    /// Set `false` for callback-only mode: the poll thread then touches only `std` atomics and
64    /// `on_stall` (it is not even given an `AppHandle`). A failed emit never ends the loop in either
65    /// mode; it is counted in [`WatchdogStatus::emit_failures`].
66    pub emit_event: bool,
67}
68
69impl Default for WatchdogConfig {
70    fn default() -> Self {
71        Self {
72            threshold: Duration::from_secs(5),
73            heartbeat_interval: Duration::from_secs(1),
74            poll_interval: DEFAULT_POLL_INTERVAL,
75            on_stall: None,
76            emit_event: true,
77        }
78    }
79}
80
81/// The edge-triggered stall decision, separated from the thread so it can be tested. Feed it each
82/// observation; it reports a stall once per episode and re-arms after a fresh beat.
83#[derive(Debug, Clone)]
84pub struct StallDetector {
85    threshold_ms: u64,
86    fired: bool,
87}
88
89impl StallDetector {
90    pub fn new(threshold: Duration) -> Self {
91        Self {
92            threshold_ms: threshold.as_millis() as u64,
93            fired: false,
94        }
95    }
96
97    /// `last_beat_ms` is the last heartbeat and `now_ms` the current time, both in Unix milliseconds.
98    /// Returns the report on the first observation of a stall episode.
99    pub fn observe(&mut self, last_beat_ms: u64, now_ms: u64) -> Option<StallReport> {
100        if should_fire(last_beat_ms, now_ms, self.fired, self.threshold_ms) {
101            self.fired = true;
102            return Some(StallReport {
103                stalled_ms: now_ms.saturating_sub(last_beat_ms),
104                threshold_ms: self.threshold_ms,
105            });
106        }
107        if self.fired && now_ms.saturating_sub(last_beat_ms) <= self.threshold_ms {
108            self.fired = false;
109        }
110        None
111    }
112}
113
114fn now_ms() -> u64 {
115    SystemTime::now()
116        .duration_since(UNIX_EPOCH)
117        .map(|d| d.as_millis() as u64)
118        .unwrap_or(0)
119}
120
121/// Edge-triggered decision, separated from the loop.
122pub fn should_fire(last_beat_ms: u64, now_ms: u64, already_fired: bool, threshold_ms: u64) -> bool {
123    !already_fired && now_ms.saturating_sub(last_beat_ms) > threshold_ms
124}
125
126/// Heartbeat of the installed watchdog (Unix ms), 0 when none is installed. One watchdog per process.
127static LAST_BEAT_MS: AtomicU64 = AtomicU64::new(0);
128/// Counters of the installed watchdog.
129static COUNTERS: PollCounters = PollCounters::new();
130
131/// Stall and emit-failure counts of one poll loop.
132pub(crate) struct PollCounters {
133    stalls: AtomicU64,
134    emit_failures: AtomicU64,
135}
136
137impl PollCounters {
138    pub(crate) const fn new() -> Self {
139        Self {
140            stalls: AtomicU64::new(0),
141            emit_failures: AtomicU64::new(0),
142        }
143    }
144}
145
146/// Runtime figures of the hang watchdog, computed on call by [`status`].
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct WatchdogStatus {
149    /// A watchdog has been installed in this process.
150    pub installed: bool,
151    /// Milliseconds since the main thread last answered a heartbeat ping (0 when not installed).
152    pub last_beat_age_ms: u64,
153    /// Stall episodes reported since install.
154    pub stalls_total: u64,
155    /// `rightkit-shell://stall` emits that failed. A failure is counted and the watchdog keeps
156    /// running.
157    pub emit_failures: u64,
158}
159
160/// Runtime figures of the shell: the watchdog, plus the resident memory, CPU time and I/O counters
161/// of the whole process (the watchdog's two threads and the shell run in it), or `None` when the
162/// host cannot report them. Computed on call; no polling thread runs for this. See
163/// `rightkit_child_process::ProcessUsage`.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct ShellRuntimeStatus {
166    pub watchdog: WatchdogStatus,
167    pub process: Option<rightkit_child_process::ProcessUsage>,
168}
169
170/// Watchdog state and process resource usage, read now.
171pub fn status() -> ShellRuntimeStatus {
172    let beat = LAST_BEAT_MS.load(Ordering::Relaxed);
173    ShellRuntimeStatus {
174        watchdog: WatchdogStatus {
175            installed: beat != 0,
176            last_beat_age_ms: if beat == 0 {
177                0
178            } else {
179                now_ms().saturating_sub(beat)
180            },
181            stalls_total: COUNTERS.stalls.load(Ordering::Relaxed),
182            emit_failures: COUNTERS.emit_failures.load(Ordering::Relaxed),
183        },
184        process: rightkit_child_process::process_usage().ok(),
185    }
186}
187
188/// The shortest wait of either thread. Also the floor of the configured intervals.
189const MIN_PERIOD: Duration = Duration::from_millis(10);
190
191/// How long a watchdog thread in an open stall episode waits for a beat before it looks again, when
192/// the threshold is shorter. A beat wakes it at once; this only bounds a missed wake-up.
193const EPISODE_RECHECK: Duration = Duration::from_secs(1);
194
195pub fn install(app: AppHandle<Wry>, cfg: WatchdogConfig) {
196    let last = Arc::new(AtomicU64::new(now_ms()));
197    LAST_BEAT_MS.store(now_ms(), Ordering::Relaxed);
198    let bell = Arc::new(Bell::new());
199    {
200        let (app, last, bell, interval) = (
201            app.clone(),
202            Arc::clone(&last),
203            Arc::clone(&bell),
204            cfg.heartbeat_interval.max(MIN_PERIOD),
205        );
206        std::thread::Builder::new()
207            .name("rk-shell-heartbeat".into())
208            .spawn(move || {
209                heartbeat_loop(&bell, interval, || {
210                    let last = Arc::clone(&last);
211                    let bell = Arc::clone(&bell);
212                    // Err means the event loop is gone (app exiting): stop.
213                    app.run_on_main_thread(move || {
214                        let now = now_ms();
215                        last.store(now, Ordering::SeqCst);
216                        LAST_BEAT_MS.store(now, Ordering::Relaxed);
217                        // After the time is stored: the watchdog reads the count, then the time.
218                        bell.beat();
219                    })
220                    .is_ok()
221                })
222            })
223            .ok();
224    }
225    let poll = cfg.poll_interval.max(MIN_PERIOD);
226    // Callback-only mode never hands the poll thread an AppHandle: `make` is not called.
227    let emit = emitter_for(cfg.emit_event, || {
228        let app = app.clone();
229        Box::new(move |report: &StallReport| {
230            app.emit(
231                EVENT_STALL,
232                serde_json::json!({"stalledMs": report.stalled_ms, "thresholdMs": report.threshold_ms}),
233            )
234            .map_err(|e| e.to_string())
235        })
236    });
237    std::thread::Builder::new()
238        .name("rk-shell-watchdog".into())
239        .spawn(move || {
240            poll_loop(
241                &last,
242                StallDetector::new(cfg.threshold),
243                poll,
244                cfg.on_stall.as_ref(),
245                emit.as_deref(),
246                &COUNTERS,
247                &bell,
248            )
249        })
250        .ok();
251}
252
253/// The wait point of the watchdog's two threads, and the channel the main thread's beat uses to
254/// wake the watchdog thread during a stall episode. One mutex and two condition variables (one per
255/// waiter, so a beat never wakes the heartbeat thread); `std` only, with no platform-specific wait.
256/// No lock is held while the watchdog thread runs the stall callback or the emitter.
257pub(crate) struct Bell {
258    state: Mutex<BellState>,
259    /// The watchdog thread waits here.
260    watch: Condvar,
261    /// The heartbeat thread waits here.
262    pulse: Condvar,
263    /// Returns from either wait, for the quiet-window test.
264    #[cfg(test)]
265    wakes: AtomicU64,
266}
267
268#[derive(Default)]
269struct BellState {
270    stop: bool,
271    /// Heartbeats the main thread has answered.
272    beats: u64,
273    /// The watchdog thread is in a stall episode and wants to hear the next beat.
274    watching: bool,
275}
276
277impl Bell {
278    pub(crate) fn new() -> Self {
279        Self {
280            state: Mutex::new(BellState::default()),
281            watch: Condvar::new(),
282            pulse: Condvar::new(),
283            #[cfg(test)]
284            wakes: AtomicU64::new(0),
285        }
286    }
287
288    fn state(&self) -> MutexGuard<'_, BellState> {
289        self.state.lock().unwrap_or_else(|e| e.into_inner())
290    }
291
292    /// End both threads' waits for good. Idempotent. Test-only: the installed watchdog has no
293    /// shutdown handle and runs until the process exits, so production code never stops the bell.
294    #[cfg(test)]
295    pub(crate) fn stop(&self) {
296        self.state().stop = true;
297        self.watch.notify_all();
298        self.pulse.notify_all();
299    }
300
301    pub(crate) fn is_stopped(&self) -> bool {
302        self.state().stop
303    }
304
305    /// The main thread answered a heartbeat. Call it after the beat time is stored. It wakes the
306    /// watchdog thread only while a stall episode is open; a healthy watchdog is never woken by it.
307    pub(crate) fn beat(&self) {
308        let mut state = self.state();
309        state.beats += 1;
310        if state.watching {
311            self.watch.notify_all();
312        }
313    }
314
315    /// Heartbeats answered so far. The watchdog thread reads it before it reads the beat time.
316    pub(crate) fn beats(&self) -> u64 {
317        self.state().beats
318    }
319
320    /// Heartbeat thread: sleep for `period`, or until stopped. `true` means stopped.
321    pub(crate) fn sleep_unless_stopped(&self, period: Duration) -> bool {
322        let state = self.state();
323        let (state, _) = self
324            .pulse
325            .wait_timeout_while(state, period, |s| !s.stop)
326            .unwrap_or_else(|e| e.into_inner());
327        #[cfg(test)]
328        self.wakes.fetch_add(1, Ordering::SeqCst);
329        state.stop
330    }
331
332    /// Watchdog thread: wait for at most `timeout`. With `episode` set to the beat count already
333    /// seen, a newer beat also ends the wait. Stop always does. `true` means stopped.
334    pub(crate) fn wait_watch(&self, timeout: Duration, episode: Option<u64>) -> bool {
335        let mut state = self.state();
336        state.watching = episode.is_some();
337        let (mut state, _) = self
338            .watch
339            .wait_timeout_while(state, timeout, |s| {
340                !s.stop && episode.is_none_or(|seen| s.beats == seen)
341            })
342            .unwrap_or_else(|e| e.into_inner());
343        state.watching = false;
344        #[cfg(test)]
345        self.wakes.fetch_add(1, Ordering::SeqCst);
346        state.stop
347    }
348
349    #[cfg(test)]
350    pub(crate) fn wakes(&self) -> u64 {
351        self.wakes.load(Ordering::SeqCst)
352    }
353}
354
355/// The heartbeat thread's loop: sleep one `interval` on the bell, then `ping` the main thread.
356/// `ping` returns `false` when the event loop is gone; the loop then ends. It also ends, at once,
357/// when the bell is stopped (the installed watchdog never stops it).
358pub(crate) fn heartbeat_loop(bell: &Bell, interval: Duration, mut ping: impl FnMut() -> bool) {
359    while !bell.sleep_unless_stopped(interval) {
360        if !ping() {
361            break;
362        }
363    }
364}
365
366/// How long until a stall can first be reported against the beat at `beat_ms`: the instant
367/// `now - beat` exceeds the threshold ([`should_fire`] is strict, hence the extra millisecond). Never
368/// more than `threshold + 1 ms`, which a fresh beat would give: a clock set back would otherwise
369/// leave the watchdog asleep far past the real deadline.
370fn until_stall(beat_ms: u64, threshold_ms: u64, now_ms: u64) -> Duration {
371    let wait = beat_ms
372        .saturating_add(threshold_ms)
373        .saturating_add(1)
374        .saturating_sub(now_ms);
375    Duration::from_millis(wait.min(threshold_ms.saturating_add(1)))
376}
377
378/// Delivers a stall to the webview; the error text is only counted.
379pub(crate) type EmitDyn = dyn Fn(&StallReport) -> Result<(), String> + Send;
380pub(crate) type EmitFn = Box<EmitDyn>;
381
382/// The emitter for a config, built only when events are on. `make` is where an `AppHandle` is
383/// captured, so callback-only mode never calls it.
384pub(crate) fn emitter_for(emit_event: bool, make: impl FnOnce() -> EmitFn) -> Option<EmitFn> {
385    emit_event.then(make)
386}
387
388/// The watchdog thread's loop. It touches only atomics, the bell, `on_stall` and the optional
389/// emitter, and it never ends because an emit failed: it runs until the bell is stopped (the
390/// installed watchdog never stops it, so it re-arms for the life of the process).
391///
392/// It does not poll. Healthy, it sleeps until the last beat plus the threshold can have passed
393/// (never less than `poll`, which is how late a report may be past the threshold) and looks again
394/// then. After a report it sleeps until the main thread answers a heartbeat, which wakes it, and
395/// re-arms on any beat newer than the one it reported against, even one that has gone stale again.
396pub(crate) fn poll_loop(
397    last: &AtomicU64,
398    mut detector: StallDetector,
399    poll: Duration,
400    on_stall: Option<&StallCallback>,
401    emit: Option<&EmitDyn>,
402    counters: &PollCounters,
403    bell: &Bell,
404) {
405    let threshold_ms = detector.threshold_ms;
406    let episode_wait = Duration::from_millis(threshold_ms).max(EPISODE_RECHECK);
407    // The beat the open stall episode was reported against.
408    let mut reported: Option<u64> = None;
409    while !bell.is_stopped() {
410        // The count first, then the time: a beat that lands between the two is a newer count than
411        // `seen`, so the episode wait below returns at once instead of sleeping through it.
412        let seen = bell.beats();
413        let beat = last.load(Ordering::SeqCst);
414        if detector.fired && reported != Some(beat) {
415            detector.fired = false;
416        }
417        if let Some(report) = detector.observe(beat, now_ms()) {
418            reported = Some(beat);
419            counters.stalls.fetch_add(1, Ordering::Relaxed);
420            if let Some(cb) = on_stall {
421                cb(&report);
422            }
423            if let Some(emit) = emit {
424                if emit(&report).is_err() {
425                    counters.emit_failures.fetch_add(1, Ordering::Relaxed);
426                }
427            }
428        }
429        let stopped = if detector.fired {
430            bell.wait_watch(episode_wait, Some(seen))
431        } else {
432            bell.wait_watch(until_stall(beat, threshold_ms, now_ms()).max(poll), None)
433        };
434        if stopped {
435            break;
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::sync::atomic::AtomicUsize;
444    use std::time::Instant;
445
446    const POLL: Duration = Duration::from_millis(10);
447
448    fn wait_for(what: &str, mut ok: impl FnMut() -> bool) {
449        let deadline = Instant::now() + Duration::from_secs(10);
450        while !ok() {
451            assert!(Instant::now() < deadline, "timed out waiting for {what}");
452            std::thread::sleep(Duration::from_millis(5));
453        }
454    }
455
456    #[test]
457    fn a_failing_emit_is_counted_and_the_next_stall_still_fires() {
458        let last = Arc::new(AtomicU64::new(0)); // stale: stalled from the start
459        let counters = Arc::new(PollCounters::new());
460        let bell = Arc::new(Bell::new());
461        let fired = Arc::new(AtomicUsize::new(0));
462        let emits = Arc::new(AtomicUsize::new(0));
463        let (l, c, b, f, e) = (
464            last.clone(),
465            counters.clone(),
466            bell.clone(),
467            fired.clone(),
468            emits.clone(),
469        );
470        let thread = std::thread::spawn(move || {
471            let on_stall: StallCallback = Arc::new(move |_| {
472                f.fetch_add(1, Ordering::SeqCst);
473            });
474            let emit: EmitFn = Box::new(move |_| {
475                e.fetch_add(1, Ordering::SeqCst);
476                Err("webview gone".into())
477            });
478            poll_loop(
479                &l,
480                StallDetector::new(Duration::from_millis(50)),
481                POLL,
482                Some(&on_stall),
483                Some(&*emit),
484                &c,
485                &b,
486            );
487        });
488
489        wait_for("the first stall", || fired.load(Ordering::SeqCst) == 1);
490        wait_for("its failed emit to be counted", || {
491            counters.emit_failures.load(Ordering::SeqCst) == 1
492        });
493        // A fresh beat ends the episode. It goes stale again after the threshold, and that next
494        // stall must fire even though every emit has failed.
495        last.store(now_ms(), Ordering::SeqCst);
496        bell.beat();
497        wait_for("the second stall", || fired.load(Ordering::SeqCst) == 2);
498        wait_for("its failed emit", || {
499            counters.emit_failures.load(Ordering::SeqCst) == 2
500        });
501        assert!(
502            !thread.is_finished(),
503            "an emit failure must not end the watchdog"
504        );
505        assert_eq!(emits.load(Ordering::SeqCst), 2);
506        assert_eq!(counters.stalls.load(Ordering::SeqCst), 2);
507        bell.stop();
508        thread.join().unwrap();
509    }
510
511    #[test]
512    fn callback_only_mode_never_builds_or_uses_the_handle() {
513        // `make` stands in for cloning the AppHandle; reaching it would panic.
514        let emit = emitter_for(false, || {
515            panic!("callback-only mode must not touch the app handle")
516        });
517        assert!(emit.is_none());
518
519        let last = Arc::new(AtomicU64::new(0));
520        let counters = Arc::new(PollCounters::new());
521        let bell = Arc::new(Bell::new());
522        let fired = Arc::new(AtomicUsize::new(0));
523        let (l, c, b, f) = (last.clone(), counters.clone(), bell.clone(), fired.clone());
524        let thread = std::thread::spawn(move || {
525            let on_stall: StallCallback = Arc::new(move |_| {
526                f.fetch_add(1, Ordering::SeqCst);
527            });
528            poll_loop(
529                &l,
530                StallDetector::new(Duration::from_millis(50)),
531                POLL,
532                Some(&on_stall),
533                emit.as_deref(),
534                &c,
535                &b,
536            );
537        });
538        wait_for("the callback", || fired.load(Ordering::SeqCst) == 1);
539        assert_eq!(counters.emit_failures.load(Ordering::SeqCst), 0);
540        bell.stop();
541        thread.join().unwrap();
542
543        // With events on, the handle is requested exactly once.
544        let built = std::cell::Cell::new(0);
545        let emit = emitter_for(true, || {
546            built.set(built.get() + 1);
547            Box::new(|_| Ok(()))
548        });
549        assert!(emit.is_some());
550        assert_eq!(built.get(), 1);
551    }
552
553    /// Wait for `rx` to report `n` finished threads, each within a generous bound.
554    fn finished(rx: &std::sync::mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
555        let mut done: Vec<&'static str> = (0..n)
556            .map(|_| {
557                rx.recv_timeout(Duration::from_secs(10))
558                    .expect("a thread did not end after the stop")
559            })
560            .collect();
561        done.sort_unstable();
562        done
563    }
564
565    #[test]
566    fn stop_wakes_both_threads_out_of_hour_long_waits() {
567        let last = Arc::new(AtomicU64::new(now_ms()));
568        let counters = Arc::new(PollCounters::new());
569        let bell = Arc::new(Bell::new());
570        let (tx, rx) = std::sync::mpsc::channel();
571        let (l, c, b, t) = (last.clone(), counters.clone(), bell.clone(), tx.clone());
572        let watchdog = std::thread::spawn(move || {
573            poll_loop(
574                &l,
575                StallDetector::new(Duration::from_secs(3600)),
576                POLL,
577                None,
578                None,
579                &c,
580                &b,
581            );
582            let _ = t.send("watchdog");
583        });
584        let (b, t) = (bell.clone(), tx);
585        let heartbeat = std::thread::spawn(move || {
586            heartbeat_loop(&b, Duration::from_secs(3600), || true);
587            let _ = t.send("heartbeat");
588        });
589        // Give both time to reach their waits; stopping before they do ends them at once too.
590        std::thread::sleep(Duration::from_millis(100));
591        bell.stop();
592        assert_eq!(finished(&rx, 2), ["heartbeat", "watchdog"]);
593        watchdog.join().unwrap();
594        heartbeat.join().unwrap();
595        assert_eq!(counters.stalls.load(Ordering::SeqCst), 0);
596    }
597
598    #[test]
599    fn a_heartbeat_wakes_a_stalled_watchdog_at_once() {
600        // A threshold of ten minutes: the episode wait is ten minutes, so only the beat can end it.
601        let last = Arc::new(AtomicU64::new(0)); // stale: stalled from the start
602        let counters = Arc::new(PollCounters::new());
603        let bell = Arc::new(Bell::new());
604        let (l, c, b) = (last.clone(), counters.clone(), bell.clone());
605        let thread = std::thread::spawn(move || {
606            poll_loop(
607                &l,
608                StallDetector::new(Duration::from_secs(600)),
609                POLL,
610                None,
611                None,
612                &c,
613                &b,
614            );
615        });
616        wait_for("the stall", || counters.stalls.load(Ordering::SeqCst) == 1);
617        let woke = bell.wakes();
618        last.store(now_ms(), Ordering::SeqCst);
619        bell.beat();
620        wait_for("the beat to wake the watchdog", || bell.wakes() > woke);
621        // The beat is fresh, so the episode is over and nothing more is reported.
622        assert_eq!(counters.stalls.load(Ordering::SeqCst), 1);
623        bell.stop();
624        thread.join().unwrap();
625    }
626
627    #[test]
628    fn a_healthy_watchdog_is_not_woken_by_beats() {
629        let last = Arc::new(AtomicU64::new(now_ms()));
630        let counters = Arc::new(PollCounters::new());
631        let bell = Arc::new(Bell::new());
632        let (l, c, b) = (last.clone(), counters.clone(), bell.clone());
633        let thread = std::thread::spawn(move || {
634            poll_loop(
635                &l,
636                StallDetector::new(Duration::from_secs(600)),
637                POLL,
638                None,
639                None,
640                &c,
641                &b,
642            );
643        });
644        std::thread::sleep(Duration::from_millis(100));
645        for _ in 0..50 {
646            last.store(now_ms(), Ordering::SeqCst);
647            bell.beat();
648        }
649        std::thread::sleep(Duration::from_millis(100));
650        assert_eq!(
651            bell.wakes(),
652            0,
653            "a beat must not wake a watchdog with no stall open"
654        );
655        bell.stop();
656        thread.join().unwrap();
657    }
658
659    #[test]
660    fn a_quiet_watchdog_wakes_about_once_per_heartbeat() {
661        let last = Arc::new(AtomicU64::new(now_ms()));
662        let counters = Arc::new(PollCounters::new());
663        let bell = Arc::new(Bell::new());
664        let (tx, rx) = std::sync::mpsc::channel();
665        let (l, c, b, t) = (last.clone(), counters.clone(), bell.clone(), tx.clone());
666        let watchdog = std::thread::spawn(move || {
667            poll_loop(
668                &l,
669                StallDetector::new(Duration::from_secs(5)),
670                POLL,
671                None,
672                None,
673                &c,
674                &b,
675            );
676            let _ = t.send("watchdog");
677        });
678        let (l, b, t) = (last.clone(), bell.clone(), tx);
679        let heartbeat = std::thread::spawn(move || {
680            // What the installed ping does on the main thread, once a second.
681            heartbeat_loop(&b, Duration::from_secs(1), || {
682                l.store(now_ms(), Ordering::SeqCst);
683                b.beat();
684                true
685            });
686            let _ = t.send("heartbeat");
687        });
688        let (start, woke_before) = (Instant::now(), bell.wakes());
689        std::thread::sleep(Duration::from_secs(2));
690        let (secs, woke) = (start.elapsed().as_secs_f64(), bell.wakes() - woke_before);
691        // The window can stretch on a loaded machine and the allowance stretches with it. The
692        // 250 ms poll plus the heartbeat woke about ten times in these two seconds.
693        let allowed = 3u64.max(secs.ceil() as u64 + 1);
694        assert!(woke <= allowed, "woke {woke} times in {secs:.1} s");
695        assert_eq!(counters.stalls.load(Ordering::SeqCst), 0);
696        bell.stop();
697        finished(&rx, 2);
698        watchdog.join().unwrap();
699        heartbeat.join().unwrap();
700    }
701
702    #[test]
703    fn a_beat_that_goes_silent_is_reported_at_the_threshold() {
704        // Beat now, then never again: the report comes one threshold later, not a poll period or
705        // a second threshold later.
706        let last = Arc::new(AtomicU64::new(now_ms()));
707        let counters = Arc::new(PollCounters::new());
708        let bell = Arc::new(Bell::new());
709        let reports = Arc::new(Mutex::new(Vec::<StallReport>::new()));
710        let (l, c, b, r) = (
711            last.clone(),
712            counters.clone(),
713            bell.clone(),
714            reports.clone(),
715        );
716        let thread = std::thread::spawn(move || {
717            let on_stall: StallCallback = Arc::new(move |report: &StallReport| {
718                r.lock().unwrap().push(*report);
719            });
720            poll_loop(
721                &l,
722                StallDetector::new(Duration::from_secs(1)),
723                DEFAULT_POLL_INTERVAL,
724                Some(&on_stall),
725                None,
726                &c,
727                &b,
728            );
729        });
730        wait_for("the stall", || !reports.lock().unwrap().is_empty());
731        let report = reports.lock().unwrap()[0];
732        assert_eq!(report.threshold_ms, 1000);
733        assert!(report.stalled_ms > 1000, "{report:?}");
734        // One second of margin over the threshold plus the 250 ms period.
735        assert!(report.stalled_ms < 2000, "reported late: {report:?}");
736        bell.stop();
737        thread.join().unwrap();
738    }
739
740    #[test]
741    fn the_wait_to_the_next_possible_stall_follows_the_beat_and_never_exceeds_the_threshold() {
742        // Beat 1 s ago with a 5 s threshold: a stall can first be reported 4 s from now (+1 ms).
743        assert_eq!(
744            until_stall(9_000, 5_000, 10_000),
745            Duration::from_millis(4_001)
746        );
747        // A beat as old as the threshold or older: already reportable, no wait.
748        assert_eq!(until_stall(1_000, 5_000, 6_001), Duration::ZERO);
749        assert_eq!(until_stall(1_000, 5_000, 99_000), Duration::ZERO);
750        // A beat stamped in the future (the clock was set back) never sleeps past one threshold.
751        assert_eq!(
752            until_stall(1_000_000, 5_000, 10_000),
753            Duration::from_millis(5_001)
754        );
755        // A fresh beat waits the threshold plus the millisecond `should_fire` needs.
756        assert_eq!(
757            until_stall(10_000, 5_000, 10_000),
758            Duration::from_millis(5_001)
759        );
760    }
761}