use std::io::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
pub use rs_teststand_sys::{DialogInfo, Raised};
#[must_use]
pub fn find_blocking_dialog() -> Option<DialogInfo> {
rs_teststand_sys::find_blocking_dialog()
}
#[must_use]
pub fn surface_blocking_dialog() -> Option<(DialogInfo, Raised)> {
rs_teststand_sys::surface_blocking_dialog()
}
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub const TIMEOUT_EXIT_CODE: i32 = 75;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DialogPolicy {
#[default]
Surface,
Terminate,
}
const fn should_terminate(
elapsed: Duration,
timeout: Duration,
cancelled: bool,
dialog: bool,
policy: DialogPolicy,
) -> bool {
matches!(policy, DialogPolicy::Terminate)
&& !cancelled
&& dialog
&& elapsed.as_millis() >= timeout.as_millis()
}
#[derive(Debug)]
pub struct Watchdog {
cancelled: Arc<AtomicBool>,
}
impl Watchdog {
#[must_use]
pub fn start(timeout: Duration, context: &'static str) -> Self {
Self::start_with(timeout, context, DialogPolicy::Surface)
}
#[must_use]
pub fn start_with(timeout: Duration, context: &'static str, policy: DialogPolicy) -> Self {
let cancelled = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancelled);
thread::spawn(move || {
let started = Instant::now();
let mut announced = Announced::default();
loop {
if flag.load(Ordering::Relaxed) {
return;
}
let overdue = started.elapsed() >= timeout;
match policy {
DialogPolicy::Surface => {
Self::surface(context, timeout, overdue, &mut announced);
}
DialogPolicy::Terminate if overdue => {
let dialog = rs_teststand_sys::find_blocking_dialog();
if should_terminate(
started.elapsed(),
timeout,
flag.load(Ordering::Relaxed),
dialog.is_some(),
policy,
) {
Self::terminate(context, timeout, dialog.as_ref());
}
}
DialogPolicy::Terminate => {}
}
thread::sleep(POLL_INTERVAL);
}
});
Self { cancelled }
}
fn surface(context: &'static str, timeout: Duration, overdue: bool, announced: &mut Announced) {
let Some((info, raised)) = rs_teststand_sys::surface_blocking_dialog() else {
*announced = Announced::default();
return;
};
if !announced.appeared {
announced.appeared = true;
Self::announce(context, &info, raised);
}
if overdue && !announced.overdue {
announced.overdue = true;
let mut stderr = std::io::stderr();
let _ = writeln!(
stderr,
"rs-teststand: '{context}' is still waiting on '{}' after {timeout:?}. Left on \
screen to be answered.",
info.title
);
}
}
fn announce(context: &'static str, info: &DialogInfo, raised: Raised) {
let placement = if raised.topmost {
"in front of all windows"
} else {
"raised, but not confirmed on top"
};
let focus = if raised.foreground {
" and focused"
} else {
"; focus was refused, so it has to be clicked"
};
let mut stderr = std::io::stderr();
let _ = writeln!(
stderr,
"rs-teststand: '{context}' is waiting on a dialog, now {placement}{focus}."
);
let _ = writeln!(stderr, " dialog title: {}", info.title);
for line in info.body.lines() {
let _ = writeln!(stderr, " dialog text : {line}");
}
}
#[allow(
clippy::exit,
reason = "terminating is the only exit from a wedged COM apartment"
)]
fn terminate(context: &'static str, timeout: Duration, dialog: Option<&DialogInfo>) -> ! {
let mut stderr = std::io::stderr();
let _ = writeln!(
stderr,
"rs-teststand: '{context}' blocked past {timeout:?} on a modal dialog that cannot \
be answered in an unattended host."
);
if let Some(info) = dialog {
let _ = writeln!(stderr, " dialog title: {}", info.title);
for line in info.body.lines() {
let _ = writeln!(stderr, " dialog text : {line}");
}
}
let _ = writeln!(stderr, "Terminating with code {TIMEOUT_EXIT_CODE}.");
let _ = stderr.flush();
let _ = std::io::stdout().flush();
std::process::exit(TIMEOUT_EXIT_CODE);
}
pub fn cancel(self) {
drop(self);
}
}
impl Drop for Watchdog {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
}
}
#[derive(Debug, Default)]
struct Announced {
appeared: bool,
overdue: bool,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::{DialogPolicy, Watchdog, should_terminate};
#[test]
fn surfacing_is_the_default_so_a_popup_is_never_killed() {
assert_eq!(DialogPolicy::default(), DialogPolicy::Surface);
assert!(!should_terminate(
Duration::from_secs(86_400),
Duration::from_millis(1),
false,
true,
DialogPolicy::Surface,
));
}
#[test]
fn fires_once_the_deadline_passes() {
assert!(should_terminate(
Duration::from_millis(101),
Duration::from_millis(100),
false,
true,
DialogPolicy::Terminate,
));
}
#[test]
fn does_not_fire_before_the_deadline() {
assert!(!should_terminate(
Duration::from_millis(99),
Duration::from_millis(100),
false,
true,
DialogPolicy::Terminate,
));
}
#[test]
fn never_fires_once_disarmed() {
assert!(!should_terminate(
Duration::from_secs(3600),
Duration::from_millis(1),
true,
true,
DialogPolicy::Terminate,
));
}
#[test]
fn a_slow_call_with_no_dialog_is_never_killed() {
assert!(!should_terminate(
Duration::from_secs(86_400),
Duration::from_millis(1),
false,
false,
DialogPolicy::Terminate,
));
}
#[test]
fn dropping_the_guard_disarms_it() {
let guard = Watchdog::start(Duration::from_secs(3600), "test");
let flag = Arc::clone(&guard.cancelled);
assert!(
!flag.load(Ordering::Relaxed),
"started guard reports cancelled"
);
drop(guard);
assert!(flag.load(Ordering::Relaxed), "drop did not cancel");
}
#[test]
fn explicit_disarm_matches_drop() {
let guard =
Watchdog::start_with(Duration::from_secs(3600), "test", DialogPolicy::Terminate);
let flag = Arc::clone(&guard.cancelled);
guard.cancel();
assert!(flag.load(Ordering::Relaxed), "cancel did not set the flag");
}
}