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}