Skip to main content

rs_teststand/
watchdog.rs

1//! Guard for engine calls that can block on a modal dialog.
2//!
3//! The engine runs in-process on a single-threaded apartment. When a sequence
4//! raises a modal dialog, the calling thread is stuck *inside* COM: the call
5//! does not return, so there is nothing to time out and no Rust code can stop
6//! it. `Engine::new` disables the dialogs the engine raises on its own, but it
7//! cannot disable the ones a sequence asks for, a `MessagePopup` step exists
8//! precisely to stop and ask a person something.
9//!
10//! [`Watchdog`] watches for those dialogs and responds according to a
11//! [`DialogPolicy`]:
12//!
13//! - [`Surface`](DialogPolicy::Surface), the default, puts the dialog in front
14//!   of every other window so it can be answered. Nothing is killed. This is
15//!   what keeps a host usable alongside a front end: the question reaches the
16//!   operator instead of the run being destroyed for asking it.
17//! - [`Terminate`](DialogPolicy::Terminate) is the blunt backstop for a host
18//!   that genuinely has nobody to answer, a CI job, an unattended service. It
19//!   captures the dialog's text and **ends the process**, because termination is
20//!   the only exit from a wedged apartment. Anything that must survive it should
21//!   run the engine in a worker process and let a supervisor restart it, so the
22//!   guard bounds the worker rather than the service.
23
24use std::io::Write as _;
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::thread;
28use std::time::{Duration, Instant};
29
30pub use rs_teststand_sys::{DialogInfo, Dismissed, Raised};
31
32/// Reports a dialog currently stopping this process, without touching it.
33///
34/// Plain data, a title, the text of the controls, and the window class, so it
35/// crosses no COM boundary and can be logged or sent anywhere. `None` means no
36/// dialog is up.
37///
38/// The match is by shape: any visible, non-minimised, captioned top-level
39/// window this process owns. A message popup is **not** a standard dialog box,
40/// so matching the dialog class would miss the case that matters. The cost is
41/// that a host with windows of its own matches those too.
42#[must_use]
43pub fn find_blocking_dialog() -> Option<DialogInfo> {
44    rs_teststand_sys::find_blocking_dialog()
45}
46
47/// Brings a blocking dialog to the front and reports what it says.
48///
49/// What [`Watchdog`] does under [`DialogPolicy::Surface`], exposed for a host
50/// that would rather drive it itself, from its own user interface thread, or
51/// on its own schedule. See [`Raised`] for what "in front" is guaranteed to
52/// mean, since Z-order and focus are not equally strong.
53#[must_use]
54pub fn surface_blocking_dialog() -> Option<(DialogInfo, Raised)> {
55    rs_teststand_sys::surface_blocking_dialog()
56}
57
58/// Asks a dialog that is holding up this process to close, and reports what it
59/// was showing.
60///
61/// The opposite choice to [`surface_blocking_dialog`], for a host with nobody
62/// in front of it. Some engine dialogs appear before any caller code runs, the
63/// warning about sequence files left unreleased by a previous process is raised
64/// while the engine object is being created, and no station option turns it
65/// off, so a host that only surfaces them still waits forever.
66///
67/// This answers without reading, which is why it is not the default anywhere a
68/// person might be present. Log the returned [`DialogInfo`]: a host that
69/// dismisses dialogs silently will eventually dismiss one that mattered.
70#[must_use]
71pub fn dismiss_blocking_dialog() -> Option<Dismissed> {
72    rs_teststand_sys::dismiss_blocking_dialog()
73}
74
75/// How often the guard thread wakes to re-check. Small enough that a dialog is
76/// raised as soon as it appears, large enough not to spin.
77const POLL_INTERVAL: Duration = Duration::from_millis(50);
78
79/// Exit code used when [`DialogPolicy::Terminate`] fires. Distinct from a normal
80/// failure so a supervisor can tell "wedged" from "returned an error".
81pub const TIMEOUT_EXIT_CODE: i32 = 75;
82
83/// What a [`Watchdog`] does when it finds a modal dialog.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum DialogPolicy {
86    /// Bring the dialog to the front and leave it there to be answered.
87    ///
88    /// The default, because a dialog is usually a sequence asking a question
89    /// rather than a fault, and a host that kills the process on sight can
90    /// never be driven by a user interface. The dialog is moved into the
91    /// always-on-top band, so an external front end cannot bury it.
92    ///
93    /// The deadline still means something: passing it logs that the dialog has
94    /// gone unanswered, without acting on it.
95    #[default]
96    Surface,
97    /// End the process once the deadline passes with a dialog on screen.
98    ///
99    /// For a host with nobody to answer, where blocking forever is worse than
100    /// dying. The dialog's text is written to stderr first, so the log says what
101    /// was being asked rather than only that the process died.
102    Terminate,
103}
104
105/// Decides whether the guard should end the process.
106///
107/// Deliberately requires **all** of: the terminating policy, the deadline
108/// passed, and a dialog present. Elapsed time alone is not evidence of a wedge, /// a sequence can legitimately sit for many minutes on a long-running test, and
109/// killing those would be a false positive.
110///
111/// Split from the thread body so the rule is testable without ending the test
112/// process.
113const fn should_terminate(
114    elapsed: Duration,
115    timeout: Duration,
116    canceled: bool,
117    dialog: bool,
118    policy: DialogPolicy,
119) -> bool {
120    matches!(policy, DialogPolicy::Terminate)
121        && !canceled
122        && dialog
123        && elapsed.as_millis() >= timeout.as_millis()
124}
125
126/// A guard around a call that may stop on a modal dialog.
127///
128/// Dropping the guard cancels it, so the normal path costs nothing:
129///
130/// ```
131/// use std::time::Duration;
132/// use rs_teststand::Watchdog;
133///
134/// // Any popup the sequence raises is brought to the front, not killed.
135/// let guard = Watchdog::start(Duration::from_secs(30), "running MainSequence");
136/// // ... engine call ...
137/// drop(guard); // canceled; process continues
138/// ```
139#[derive(Debug)]
140pub struct Watchdog {
141    canceled: Arc<AtomicBool>,
142}
143
144impl Watchdog {
145    /// Starts a guard that keeps any modal dialog in front of the operator.
146    ///
147    /// Equivalent to [`start_with`](Self::start_with) under
148    /// [`DialogPolicy::Surface`]. `context` names the operation, so a log line
149    /// says which call was waiting rather than just that something was.
150    #[must_use]
151    pub fn start(timeout: Duration, context: &'static str) -> Self {
152        Self::start_with(timeout, context, DialogPolicy::Surface)
153    }
154
155    /// Starts a guard with an explicit response to modal dialogs.
156    ///
157    /// Use [`DialogPolicy::Terminate`] only where no one can answer a dialog and
158    /// blocking forever is the worse outcome; it ends the process.
159    #[must_use]
160    pub fn start_with(timeout: Duration, context: &'static str, policy: DialogPolicy) -> Self {
161        let canceled = Arc::new(AtomicBool::new(false));
162        let flag = Arc::clone(&canceled);
163        // Detached on purpose: the guard must not be joined, and the thread
164        // exits on its own as soon as the flag is set.
165        thread::spawn(move || {
166            let started = Instant::now();
167            let mut announced = Announced::default();
168            loop {
169                if flag.load(Ordering::Relaxed) {
170                    return;
171                }
172                let overdue = started.elapsed() >= timeout;
173                match policy {
174                    DialogPolicy::Surface => {
175                        Self::surface(context, timeout, overdue, &mut announced);
176                    }
177                    // Only look once the deadline has passed, so the common case
178                    // costs nothing.
179                    DialogPolicy::Terminate if overdue => {
180                        let dialog = rs_teststand_sys::find_blocking_dialog();
181                        if should_terminate(
182                            started.elapsed(),
183                            timeout,
184                            flag.load(Ordering::Relaxed),
185                            dialog.is_some(),
186                            policy,
187                        ) {
188                            Self::terminate(context, timeout, dialog.as_ref());
189                        }
190                    }
191                    DialogPolicy::Terminate => {}
192                }
193                thread::sleep(POLL_INTERVAL);
194            }
195        });
196        Self { canceled }
197    }
198
199    /// Keeps any dialog at the front, announcing each one once.
200    ///
201    /// The raise is repeated every poll on purpose: moving a window into the
202    /// always-on-top band also moves it to the front *of* that band, so
203    /// re-asserting is what stops an always-on-top front end from covering the
204    /// question. The announcements are not repeated, or a dialog left up for a
205    /// minute would fill the log.
206    fn surface(context: &'static str, timeout: Duration, overdue: bool, announced: &mut Announced) {
207        let Some((info, raised)) = rs_teststand_sys::surface_blocking_dialog() else {
208            // The dialog closed: re-arm, so the next one is announced too.
209            *announced = Announced::default();
210            return;
211        };
212        if !announced.appeared {
213            announced.appeared = true;
214            Self::announce(context, &info, raised);
215        }
216        if overdue && !announced.overdue {
217            announced.overdue = true;
218            let mut stderr = std::io::stderr();
219            let _ = writeln!(
220                stderr,
221                "rs-teststand: '{context}' is still waiting on '{}' after {timeout:?}. Left on \
222                 screen to be answered.",
223                info.title
224            );
225        }
226    }
227
228    /// Reports a dialog that was just brought forward.
229    ///
230    /// Says what the dialog asks and how far forward it actually got, because
231    /// the Z-order move is a guarantee and the focus change is not.
232    fn announce(context: &'static str, info: &DialogInfo, raised: Raised) {
233        let placement = if raised.topmost {
234            "in front of all windows"
235        } else {
236            "raised, but not confirmed on top"
237        };
238        let focus = if raised.foreground {
239            " and focused"
240        } else {
241            "; focus was refused, so it has to be clicked"
242        };
243        let mut stderr = std::io::stderr();
244        let _ = writeln!(
245            stderr,
246            "rs-teststand: '{context}' is waiting on a dialog, now {placement}{focus}."
247        );
248        let _ = writeln!(stderr, "  dialog title: {}", info.title);
249        for line in info.body.lines() {
250            let _ = writeln!(stderr, "  dialog text : {line}");
251        }
252    }
253
254    /// Flushes what we have and ends the process.
255    ///
256    /// Output is flushed first so results already produced survive; the
257    /// apartment is wedged, so no orderly shutdown is possible.
258    #[allow(
259        clippy::exit,
260        reason = "terminating is the only exit from a wedged COM apartment"
261    )]
262    fn terminate(context: &'static str, timeout: Duration, dialog: Option<&DialogInfo>) -> ! {
263        let mut stderr = std::io::stderr();
264        let _ = writeln!(
265            stderr,
266            "rs-teststand: '{context}' blocked past {timeout:?} on a modal dialog that cannot \
267             be answered in an unattended host."
268        );
269        // The dialog's own text is the actual diagnosis, a .NET crash message
270        // or engine error. Losing it would leave only "the process died".
271        if let Some(info) = dialog {
272            let _ = writeln!(stderr, "  dialog title: {}", info.title);
273            for line in info.body.lines() {
274                let _ = writeln!(stderr, "  dialog text : {line}");
275            }
276        }
277        let _ = writeln!(stderr, "Terminating with code {TIMEOUT_EXIT_CODE}.");
278        let _ = stderr.flush();
279        let _ = std::io::stdout().flush();
280        std::process::exit(TIMEOUT_EXIT_CODE);
281    }
282
283    /// Cancels explicitly. Equivalent to dropping the guard.
284    pub fn cancel(self) {
285        drop(self);
286    }
287}
288
289impl Drop for Watchdog {
290    fn drop(&mut self) {
291        self.canceled.store(true, Ordering::Relaxed);
292    }
293}
294
295/// Which messages have already been written for the dialog currently on screen.
296///
297/// Reset when the dialog closes, so a second popup is reported like the first.
298#[derive(Debug, Default)]
299struct Announced {
300    /// The dialog has been reported as appearing.
301    appeared: bool,
302    /// The dialog has been reported as outliving the deadline.
303    overdue: bool,
304}
305
306#[cfg(test)]
307mod tests {
308    use std::sync::Arc;
309    use std::sync::atomic::Ordering;
310    use std::time::Duration;
311
312    use super::{DialogPolicy, Watchdog, should_terminate};
313
314    #[test]
315    fn surfacing_is_the_default_so_a_popup_is_never_killed() {
316        // The contract this module exists for: a sequence that stops to ask a
317        // question must not cost the caller its process.
318        assert_eq!(DialogPolicy::default(), DialogPolicy::Surface);
319        assert!(!should_terminate(
320            Duration::from_secs(86_400),
321            Duration::from_millis(1),
322            false,
323            true,
324            DialogPolicy::Surface,
325        ));
326    }
327
328    #[test]
329    fn fires_once_the_deadline_passes() {
330        assert!(should_terminate(
331            Duration::from_millis(101),
332            Duration::from_millis(100),
333            false,
334            true,
335            DialogPolicy::Terminate,
336        ));
337    }
338
339    #[test]
340    fn does_not_fire_before_the_deadline() {
341        assert!(!should_terminate(
342            Duration::from_millis(99),
343            Duration::from_millis(100),
344            false,
345            true,
346            DialogPolicy::Terminate,
347        ));
348    }
349
350    #[test]
351    fn never_fires_once_disarmed() {
352        // The important case: a slow-but-successful call must not be killed
353        // just because the guard outlived its deadline in a debug build.
354        assert!(!should_terminate(
355            Duration::from_secs(3600),
356            Duration::from_millis(1),
357            true,
358            true,
359            DialogPolicy::Terminate,
360        ));
361    }
362
363    #[test]
364    fn a_slow_call_with_no_dialog_is_never_killed() {
365        // The false-positive guard: a legitimate long-running sequence blows
366        // past any deadline, but with no dialog up it must be left alone.
367        assert!(!should_terminate(
368            Duration::from_secs(86_400),
369            Duration::from_millis(1),
370            false,
371            false,
372            DialogPolicy::Terminate,
373        ));
374    }
375
376    #[test]
377    fn dropping_the_guard_disarms_it() {
378        let guard = Watchdog::start(Duration::from_secs(3600), "test");
379        let flag = Arc::clone(&guard.canceled);
380        assert!(
381            !flag.load(Ordering::Relaxed),
382            "started guard reports canceled"
383        );
384        drop(guard);
385        assert!(flag.load(Ordering::Relaxed), "drop did not cancel");
386    }
387
388    #[test]
389    fn explicit_disarm_matches_drop() {
390        let guard =
391            Watchdog::start_with(Duration::from_secs(3600), "test", DialogPolicy::Terminate);
392        let flag = Arc::clone(&guard.canceled);
393        guard.cancel();
394        assert!(flag.load(Ordering::Relaxed), "cancel did not set the flag");
395    }
396}