Skip to main content

rs_teststand_sys/window/
dialog.rs

1//! Detects a dialog that has stopped this process to ask something.
2//!
3//! A blocked host and a legitimately slow one look identical from a timer: a
4//! sequence can sit for many minutes on a real message-popup step or a long
5//! test. What distinguishes them is a *visible window owned by this process*,
6//! so that is what is checked, and its text is captured, because "the engine
7//! stopped" is far less useful than the question it stopped to ask.
8//!
9//! The match is deliberately broad: any visible, non-minimised, captioned
10//! top-level window this process owns. Keying on the standard dialog class
11//! `#32770` was tried first and misses the case that matters, because the
12//! engine's own message popups are not standard dialog boxes; measured against
13//! a live engine, a `MessagePopup` step produces a captioned overlapped window
14//! from the runtime the engine's user interface is built on, with an owner that
15//! stays *enabled*. So neither the dialog class nor the usual Win32 modality
16//! signature (a disabled owner) identifies it, and both would report "nothing
17//! is blocking" while a popup sat on screen.
18//!
19//! The cost of the broad rule is that a host which creates windows of its own in
20//! the same process will match those too. That is safe under the surfacing
21//! policy, which only reorders windows; a host with its own user interface
22//! should not pair this with a policy that acts destructively.
23
24use windows::Win32::Foundation::{HWND, LPARAM};
25use windows::Win32::System::Threading::GetCurrentProcessId;
26use windows::Win32::UI::WindowsAndMessaging::{
27    EnumChildWindows, EnumWindows, GWL_STYLE, GetClassNameW, GetWindowLongW, GetWindowTextW,
28    GetWindowThreadProcessId, IsIconic, IsWindowVisible, WS_CAPTION,
29};
30
31/// Longest window text captured, in UTF-16 units.
32const TEXT_LIMIT: usize = 512;
33
34/// What a blocking dialog was showing.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct DialogInfo {
37    /// The dialog's title bar.
38    pub title: String,
39    /// Visible text of the dialog's child controls, joined by newlines. This is
40    /// where a message popup's question, or an engine error, actually appears.
41    pub body: String,
42    /// Win32 class name of the window that matched. Recorded because the match
43    /// is by shape rather than by class, so a caller diagnosing a false match
44    /// can see what was picked up.
45    pub class: String,
46}
47
48/// Returns the first visible dialog owned by this process, if any.
49///
50/// `None` means no dialog is up, the operation is slow, not wedged, and a
51/// caller should keep waiting rather than terminate.
52#[must_use]
53pub fn find_blocking_dialog() -> Option<DialogInfo> {
54    first_visible_dialog().map(describe)
55}
56
57/// Returns the window handle of the first visible dialog owned by this process.
58///
59/// Split from [`find_blocking_dialog`] because raising a dialog needs the
60/// handle, and re-finding it by its text would race with it closing.
61pub(crate) fn first_visible_dialog() -> Option<HWND> {
62    let mut found: Option<HWND> = None;
63    let target = (&raw mut found) as isize;
64
65    // SAFETY: `enumerate` matches the WNDENUMPROC signature; `target` is a
66    // pointer to `found`, which outlives the call because EnumWindows is
67    // synchronous. A failure just means no window matched.
68    let _ = unsafe { EnumWindows(Some(enumerate), LPARAM(target)) };
69    found
70}
71
72/// Reads what a dialog is showing.
73pub(crate) fn describe(handle: HWND) -> DialogInfo {
74    DialogInfo {
75        title: window_text(handle),
76        body: child_text(handle),
77        class: class_name(handle),
78    }
79}
80
81/// Callback: records the first visible dialog belonging to this process.
82unsafe extern "system" fn enumerate(handle: HWND, target: LPARAM) -> windows_core::BOOL {
83    // SAFETY: `handle` is supplied by the OS and valid for this callback.
84    let visible = unsafe { IsWindowVisible(handle) }.as_bool();
85    if !visible || !looks_like_a_dialog(handle) || !belongs_to_this_process(handle) {
86        return true.into();
87    }
88    // SAFETY: `target` is the `*mut Option<HWND>` passed to EnumWindows, still
89    // alive for the duration of this synchronous enumeration.
90    unsafe {
91        *(target.0 as *mut Option<HWND>) = Some(handle);
92    }
93    // Stop enumerating; the first match is enough.
94    false.into()
95}
96
97/// Whether the window has the shape of something asking for an answer.
98///
99/// A title bar and a place on screen, in other words. Minimised windows are
100/// excluded: they occupy the Z-order but show nothing, so raising one would
101/// report a question the operator still cannot read.
102fn looks_like_a_dialog(handle: HWND) -> bool {
103    // SAFETY: `handle` is supplied by the OS enumeration and valid here.
104    if unsafe { IsIconic(handle) }.as_bool() {
105        return false;
106    }
107    // SAFETY: `handle` is valid; this only reads a style word.
108    let style = unsafe { GetWindowLongW(handle, GWL_STYLE) };
109    u32::try_from(style).is_ok_and(|bits| bits & WS_CAPTION.0 == WS_CAPTION.0)
110}
111
112/// The window's class name, recorded for diagnosis.
113fn class_name(handle: HWND) -> String {
114    let mut buffer = [0u16; 128];
115    // SAFETY: `handle` is valid; the buffer bounds the write.
116    let written = unsafe { GetClassNameW(handle, &mut buffer) };
117    let end = usize::try_from(written).unwrap_or(0).min(buffer.len());
118    buffer
119        .get(..end)
120        .map(String::from_utf16_lossy)
121        .unwrap_or_default()
122}
123
124/// Whether the window was created by this process.
125fn belongs_to_this_process(handle: HWND) -> bool {
126    let mut owner = 0u32;
127    // SAFETY: `handle` is valid; `owner` is a live local receiving the id.
128    unsafe { GetWindowThreadProcessId(handle, Some(&raw mut owner)) };
129    // SAFETY: no preconditions.
130    owner == unsafe { GetCurrentProcessId() }
131}
132
133/// The window's own text (its title, for a dialog).
134fn window_text(handle: HWND) -> String {
135    let mut buffer = [0u16; TEXT_LIMIT];
136    // SAFETY: `handle` is valid; the buffer bounds the write.
137    let written = unsafe { GetWindowTextW(handle, &mut buffer) };
138    let end = usize::try_from(written).unwrap_or(0).min(buffer.len());
139    buffer
140        .get(..end)
141        .map(String::from_utf16_lossy)
142        .unwrap_or_default()
143}
144
145/// Text of every child control, which is where the message body lives.
146fn child_text(parent: HWND) -> String {
147    let mut lines: Vec<String> = Vec::new();
148    let target = (&raw mut lines) as isize;
149    // SAFETY: `collect_child` matches WNDENUMPROC; `target` points at `lines`,
150    // which outlives this synchronous enumeration.
151    unsafe {
152        // The BOOL result only reports whether enumeration ran to completion;
153        // a partial list of child captions is still usable diagnostics.
154        let _ = EnumChildWindows(Some(parent), Some(collect_child), LPARAM(target));
155    }
156    lines.retain(|line| !line.trim().is_empty());
157    lines.join("\n")
158}
159
160/// Callback: appends one child control's text.
161unsafe extern "system" fn collect_child(handle: HWND, target: LPARAM) -> windows_core::BOOL {
162    let text = window_text(handle);
163    if !text.is_empty() {
164        // SAFETY: `target` is the `*mut Vec<String>` passed to
165        // EnumChildWindows, alive for the duration of the enumeration.
166        unsafe {
167            (*(target.0 as *mut Vec<String>)).push(text);
168        }
169    }
170    true.into()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::find_blocking_dialog;
176
177    #[test]
178    fn reports_no_dialog_in_a_headless_test_process() {
179        // A test binary shows no dialogs, so this must be None. The point of
180        // the check: absence must never be reported as a wedge, or every slow
181        // operation would be killed.
182        assert!(
183            find_blocking_dialog().is_none(),
184            "test process should have no modal dialog"
185        );
186    }
187}