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}