Skip to main content

entracte_lib/scheduler/
overlay_watchdog.rs

1//! Render-readiness watchdog for the break overlay (#196, #226).
2//!
3//! [`super::overlay::fire_break`] shows an `always_on_top` overlay window,
4//! grabs focus, and pauses media *before* the overlay's webview has rendered
5//! anything. If that webview never paints — its content process crashes
6//! (macOS / WKWebView, #196) or the surface is never realised (Linux, #226) —
7//! the break is *invisible but active*: media paused, focus grabbed, the
8//! screen covered, and no UI to dismiss it. The desktop is frozen until the
9//! app is force-quit.
10//!
11//! This guards against that regardless of *why* rendering failed. Each
12//! overlay break [`arm`s](OverlayAck::arm) a monotonically increasing epoch;
13//! the overlay frontend [`ack`s](OverlayAck::ack) once it has rendered the
14//! break (any successful IPC from the overlay proves the webview is alive and
15//! executing). A watchdog task captures the armed epoch and, after a grace
16//! period, tears the break down iff that epoch is still current and unacked —
17//! i.e. nothing ever rendered.
18
19use std::sync::atomic::{AtomicU64, Ordering};
20
21/// Two monotonic counters tracking whether the overlay reported in for the
22/// most recently fired break. `armed` advances on every fired break; `acked`
23/// is raised to the latest `armed` value when the overlay renders. A captured
24/// epoch is "stranded" when it is still the armed break and `acked` never
25/// caught up to it.
26#[derive(Debug)]
27pub struct OverlayAck {
28    armed: AtomicU64,
29    acked: AtomicU64,
30}
31
32impl OverlayAck {
33    pub const fn new() -> Self {
34        Self {
35            armed: AtomicU64::new(0),
36            acked: AtomicU64::new(0),
37        }
38    }
39
40    /// Arm a freshly-fired break and return its epoch for a watchdog task to
41    /// capture. The first armed break is epoch 1 (0 is "never fired").
42    pub fn arm(&self) -> u64 {
43        self.armed.fetch_add(1, Ordering::SeqCst) + 1
44    }
45
46    /// Record that the overlay rendered the current break — raise `acked` to
47    /// the latest armed epoch. Also used as a defensive disarm on a normal
48    /// `end_break`, so a late watchdog can never tear down an already-ended
49    /// break. `fetch_max` keeps it monotonic under any interleaving.
50    pub fn ack(&self) {
51        let armed = self.armed.load(Ordering::SeqCst);
52        self.acked.fetch_max(armed, Ordering::SeqCst);
53    }
54
55    /// Whether `epoch` is still the armed break and no ack has caught up to
56    /// it — the overlay never rendered. A newer break (`armed > epoch`) makes
57    /// the captured epoch inert: that break has its own watchdog.
58    pub fn is_stranded(&self, epoch: u64) -> bool {
59        self.armed.load(Ordering::SeqCst) == epoch && self.acked.load(Ordering::SeqCst) < epoch
60    }
61}
62
63impl Default for OverlayAck {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69/// Process-wide instance: armed by [`super::overlay::fire_break`], acked by
70/// the `notify_overlay_rendered` command, read by the watchdog task. A global
71/// (like [`crate::media`]'s pause state) so the synchronous, scheduler-free
72/// `fire_break` can arm it without threading a handle through every caller.
73pub static OVERLAY_ACK: OverlayAck = OverlayAck::new();
74
75/// Grace period before a never-rendered overlay is torn down. Comfortably
76/// above a healthy cold mount — React boot, the overlay's `get_settings` /
77/// `get_current_break` IPC round-trips, and first paint all land well under a
78/// second even on a loaded machine — so a slow-but-live overlay is never
79/// killed, yet short enough that a genuine freeze self-clears quickly.
80///
81/// On the Windows test build both users — the `#[cfg(not(test))]` watchdog
82/// spawn and the `abort_stranded_break` rig test (excluded on Windows, where
83/// the `tauri` test feature can't boot a mock app) — are compiled out, so this
84/// reads as dead there; the real build and other-OS test builds keep it live.
85#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
86pub const RENDER_GRACE_SECS: u64 = 5;
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn default_matches_a_fresh_unfired_ack() {
94        // `Default` mirrors `new()` (kept for clippy's `new_without_default`);
95        // a fresh ack has fired nothing, so no epoch is stranded yet.
96        let ack = OverlayAck::default();
97        assert!(!ack.is_stranded(1));
98    }
99
100    #[test]
101    fn first_armed_break_is_epoch_one() {
102        let ack = OverlayAck::new();
103        assert_eq!(ack.arm(), 1);
104        assert_eq!(ack.arm(), 2);
105    }
106
107    #[test]
108    fn unacked_armed_break_is_stranded() {
109        let ack = OverlayAck::new();
110        let epoch = ack.arm();
111        assert!(ack.is_stranded(epoch));
112    }
113
114    #[test]
115    fn acked_break_is_not_stranded() {
116        let ack = OverlayAck::new();
117        let epoch = ack.arm();
118        ack.ack();
119        assert!(!ack.is_stranded(epoch));
120    }
121
122    #[test]
123    fn a_newer_break_makes_an_old_epoch_inert() {
124        // The first break's watchdog must NOT tear down the second break:
125        // once a newer break arms, the captured epoch is no longer current.
126        let ack = OverlayAck::new();
127        let first = ack.arm();
128        let _second = ack.arm();
129        assert!(
130            !ack.is_stranded(first),
131            "a superseded epoch is inert even while unacked"
132        );
133    }
134
135    #[test]
136    fn ack_for_a_previous_break_does_not_clear_a_newer_one() {
137        // An ack raises `acked` to the *current* armed epoch. A break that
138        // arms afterwards starts out stranded until its own ack lands.
139        let ack = OverlayAck::new();
140        let _first = ack.arm();
141        ack.ack();
142        let second = ack.arm();
143        assert!(ack.is_stranded(second), "the newer break needs its own ack");
144        ack.ack();
145        assert!(!ack.is_stranded(second));
146    }
147
148    #[test]
149    fn ack_is_monotonic_and_never_regresses() {
150        let ack = OverlayAck::new();
151        let first = ack.arm();
152        ack.ack();
153        // A spurious second ack after the same arm is harmless.
154        ack.ack();
155        assert!(!ack.is_stranded(first));
156    }
157}