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}