main
Colby Knox 0.2.41: licence_expired catalog entry + native WM_CLOSE fallback for UIA-refusing OK boxes 5230f68 1mo ago
//! Freeze-safe native window/process introspection (adom/altium-bridge#6, adom/adom-bridge#122).
//!
//! The close watchdog's whole job is to act DURING a wedged Altium shutdown, and that is exactly
//! when ab's `desktop_enum_windows` / `process_list` hang or return empty: an empty answer read as
//! "no window" is how the 0.2.8 watchdog missed both freezes on 2026-09-02. This module asks
//! Win32 directly, using ONLY non-messaging calls:
//!
//! * `EnumWindows` walks the shell's window list without touching any window's thread.
//! * `GetClassNameW`, `GetWindowThreadProcessId`, `IsWindowVisible` read cached/kernel state.
//! * NEVER `GetWindowTextW`: it SENDS WM_GETTEXT into the window's (possibly hung) thread and
//!   inherits the hang. Titles are deliberately not available here; match by class + pid.
//!
//! Everything else in the bridge keeps going through ab; this module exists solely so the
//! safety path cannot be blinded by the very freeze it guards against.

#[cfg(windows)]
mod imp {
    use std::sync::Mutex;

    type Hwnd = isize;

    #[link(name = "user32")]
    extern "system" {
        fn EnumWindows(cb: extern "system" fn(Hwnd, isize) -> i32, lparam: isize) -> i32;
        fn GetClassNameW(hwnd: Hwnd, buf: *mut u16, len: i32) -> i32;
        fn GetWindowThreadProcessId(hwnd: Hwnd, pid: *mut u32) -> u32;
        fn IsWindowVisible(hwnd: Hwnd) -> i32;
        fn IsHungAppWindow(hwnd: Hwnd) -> i32;
        fn IsWindowEnabled(hwnd: Hwnd) -> i32;
        fn PostThreadMessageW(tid: u32, msg: u32, wparam: usize, lparam: isize) -> i32;
        fn PostMessageW(hwnd: Hwnd, msg: u32, wparam: usize, lparam: isize) -> i32;
    }

    const WM_QUIT: u32 = 0x0012;
    const WM_CLOSE: u32 = 0x0010;

    /// Post WM_CLOSE to one window — the dismissal its titlebar X performs, POSTED so it cannot
    /// inherit a hang. The dialog fallback lane: some Information boxes carry an OK that exposes
    /// no UIA Invoke (measured 2026-09-21 on the expired-licence box), and closing the box IS the
    /// acknowledgement.
    pub fn post_message_close(hwnd: i64) -> bool {
        unsafe { PostMessageW(hwnd as Hwnd, WM_CLOSE, 0, 0) != 0 }
    }

    /// Ask a UI thread to leave its message loop — Delphi's `Application.Terminate`, reached from
    /// outside. POSTED (never sent), so it cannot inherit a hang, and unlike TerminateProcess the
    /// app runs its own shutdown: measured 2026-09-21 on AD 26.10.1, Altium exited and RELEASED
    /// the On-Demand licence seat (a taskkill strands it). This is the only way out of a modal
    /// orphan — see `pid_main_window_disabled`.
    pub fn post_quit_to_thread(tid: u32) -> bool {
        unsafe { PostThreadMessageW(tid, WM_QUIT, 0, 0) != 0 }
    }

    /// One visible top-level window: pid, class, hwnd, and whether its UI thread is HUNG. The
    /// freeze in #6 leaves the main window PRESENT but non-responding (measured 2026-09-02: X2
    /// alive, TDocumentForm still visible, the desktop wedged) — so "gone" is not the signal;
    /// "hung" is. `IsHungAppWindow` reports the thread's ghost state WITHOUT sending a message,
    /// so it stays truthful during the freeze it detects.
    #[derive(Clone, Debug)]
    pub struct WinRow {
        pub pid: u32,
        pub class: String,
        pub hwnd: i64,
        pub hung: bool,
        /// Windows disables EVERY other top-level window of a process while one of them is modal.
        /// A disabled main window with no dialog on screen is the "modal orphan" signature.
        pub enabled: bool,
        /// The window's UI thread, for `post_quit_to_thread`.
        pub tid: u32,
    }

    // EnumWindows wants a plain function pointer; rows are collected through a mutex the
    // callback can reach. Enumeration is short (hundreds of windows) and single-threaded per
    // call, guarded by CALL_LOCK so two watchdog ticks can never interleave collections.
    static CALL_LOCK: Mutex<()> = Mutex::new(());
    static ROWS: Mutex<Vec<WinRow>> = Mutex::new(Vec::new());

    extern "system" fn on_window(hwnd: Hwnd, _lparam: isize) -> i32 {
        unsafe {
            if IsWindowVisible(hwnd) == 0 {
                return 1; // keep enumerating
            }
            let mut pid: u32 = 0;
            let tid = GetWindowThreadProcessId(hwnd, &mut pid);
            if pid == 0 {
                return 1;
            }
            let mut buf = [0u16; 128];
            let n = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
            let class = String::from_utf16_lossy(&buf[..n.max(0) as usize]);
            let hung = IsHungAppWindow(hwnd) != 0;
            if let Ok(mut rows) = ROWS.lock() {
                let enabled = IsWindowEnabled(hwnd) != 0;
                rows.push(WinRow { pid, class, hwnd: hwnd as i64, hung, enabled, tid });
            }
        }
        1
    }

    /// Every VISIBLE top-level window as (pid, class). `None` only if the enumeration itself
    /// failed — an empty Vec is a real "no windows", which is the distinction ab's lane loses.
    pub fn visible_windows() -> Option<Vec<WinRow>> {
        let _guard = CALL_LOCK.lock().ok()?;
        if let Ok(mut rows) = ROWS.lock() {
            rows.clear();
        }
        let ok = unsafe { EnumWindows(on_window, 0) };
        if ok == 0 {
            return None;
        }
        ROWS.lock().ok().map(|r| r.clone())
    }
}

#[cfg(windows)]
mod proc_imp {
    //! Native process enumeration + termination (kernel32 toolhelp), so the zombie-breaker can
    //! see and end a wedged X2 even when ab's process_list is empty or hung mid-freeze.
    #[repr(C)]
    struct ProcessEntry32W {
        dw_size: u32,
        cnt_usage: u32,
        th32_process_id: u32,
        th32_default_heap_id: usize,
        th32_module_id: u32,
        cnt_threads: u32,
        th32_parent_process_id: u32,
        pc_pri_class_base: i32,
        dw_flags: u32,
        sz_exe_file: [u16; 260],
    }

    #[link(name = "kernel32")]
    extern "system" {
        fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> isize;
        fn Process32FirstW(snap: isize, entry: *mut ProcessEntry32W) -> i32;
        fn Process32NextW(snap: isize, entry: *mut ProcessEntry32W) -> i32;
        fn CloseHandle(h: isize) -> i32;
        fn OpenProcess(access: u32, inherit: i32, pid: u32) -> isize;
        fn TerminateProcess(h: isize, code: u32) -> i32;
    }

    const TH32CS_SNAPPROCESS: u32 = 0x2;
    const PROCESS_TERMINATE: u32 = 0x0001;

    /// Pids of every process whose image name matches `image` case-insensitively (e.g. "X2.EXE").
    /// `None` only if the snapshot itself failed.
    pub fn pids_of(image: &str) -> Option<Vec<u32>> {
        let want = image.to_ascii_lowercase();
        unsafe {
            let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
            if snap == -1 || snap == 0 {
                return None;
            }
            let mut out = Vec::new();
            let mut entry: ProcessEntry32W = std::mem::zeroed();
            entry.dw_size = std::mem::size_of::<ProcessEntry32W>() as u32;
            let mut ok = Process32FirstW(snap, &mut entry);
            while ok != 0 {
                let name_len = entry.sz_exe_file.iter().position(|&c| c == 0).unwrap_or(260);
                let name = String::from_utf16_lossy(&entry.sz_exe_file[..name_len]).to_ascii_lowercase();
                if name == want {
                    out.push(entry.th32_process_id);
                }
                ok = Process32NextW(snap, &mut entry);
            }
            CloseHandle(snap);
            Some(out)
        }
    }

    /// Terminate `pid`. Returns whether the terminate call succeeded.
    pub fn kill(pid: u32) -> bool {
        unsafe {
            let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
            if h == 0 {
                return false;
            }
            let ok = TerminateProcess(h, 1);
            CloseHandle(h);
            ok != 0
        }
    }
}

#[cfg(windows)]
pub use imp::{post_message_close, post_quit_to_thread, visible_windows, WinRow};
#[cfg(windows)]
pub use proc_imp::{kill, pids_of};

#[cfg(not(windows))]
pub fn pids_of(_image: &str) -> Option<Vec<u32>> {
    None
}

#[cfg(not(windows))]
pub fn kill(_pid: u32) -> bool {
    false
}

#[cfg(not(windows))]
pub fn post_message_close(_hwnd: i64) -> bool {
    false
}

#[cfg(not(windows))]
pub fn post_quit_to_thread(_tid: u32) -> bool {
    false
}

#[cfg(not(windows))]
#[derive(Clone, Debug)]
pub struct WinRow {
    pub pid: u32,
    pub class: String,
    pub hwnd: i64,
    pub hung: bool,
    pub enabled: bool,
    pub tid: u32,
}

#[cfg(not(windows))]
pub fn visible_windows() -> Option<Vec<WinRow>> {
    None // non-Windows builds (tests on the Linux build container) have no window session
}

/// Does `pid` own a visible top-level window of Altium's main-window class? The main document
/// frame is `TDocumentForm` (measured on AD26 Designer and ADDevelop alike); during shutdown it
/// is destroyed well before X2.EXE exits, which is the closing signature the watchdog keys on.
pub fn pid_has_main_window(pid: u32, rows: &[WinRow]) -> bool {
    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm")
}

/// Is `pid`'s main window present but NON-RESPONDING? This is the actual freeze signature on
/// this build (2026-09-02, live): the frame stays visible while its thread hangs and the
/// desktop wedges around it. The 0.2.8-era "window gone" heuristic never fires in this state.
pub fn pid_main_window_hung(pid: u32, rows: &[WinRow]) -> bool {
    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && w.hung)
}

/// Is `pid`'s main window DISABLED? Windows disables a process's other top-level windows while
/// one of them is modal, so with no dialog on screen this means Altium is stuck inside a modal
/// loop whose dialog is gone — the app accepts no input, no menu, no Alt+F4, and every `-R`
/// hand-off bounces off "another instance ... is busy" (measured 2026-09-21 on AD 26.10.1, after
/// a close-time save prompt: the single licence seat stayed held and nothing but a kill looked
/// possible). `close` escapes it with `post_quit_to_thread`.
pub fn pid_main_window_disabled(pid: u32, rows: &[WinRow]) -> bool {
    rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && !w.enabled)
}

/// The UI thread that owns `pid`'s main window.
pub fn pid_ui_thread(pid: u32, rows: &[WinRow]) -> Option<u32> {
    rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.tid)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pid_matching_is_class_scoped() {
        let rows = vec![
            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: true, tid: 100 },
            WinRow { pid: 11, class: "#32770".into(), hwnd: 2, hung: false, enabled: false, tid: 101 },
        ];
        assert!(pid_has_main_window(10, &rows));
        assert!(!pid_has_main_window(11, &rows), "a dialog is not a main window");
        assert!(!pid_has_main_window(12, &rows));
    }

    #[test]
    fn modal_orphan_is_the_disabled_main_window() {
        let rows = vec![
            WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: false, tid: 100 },
            WinRow { pid: 11, class: "TDocumentForm".into(), hwnd: 2, hung: false, enabled: true, tid: 101 },
        ];
        assert!(pid_main_window_disabled(10, &rows), "disabled main window = stuck in a modal loop");
        assert!(!pid_main_window_disabled(11, &rows), "a healthy Altium is never disabled");
        assert_eq!(pid_ui_thread(10, &rows), Some(100));
        assert_eq!(pid_ui_thread(99, &rows), None);
    }
}