diff --git a/Cargo.toml b/Cargo.toml index c5a4434fa..924bc51fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,7 @@ lyon_tessellation = "1.0.20" # (IApplicationAssociationRegistrationUI dialog). # Win32_System_Registry — HKCU registration that lists the app under # "Open with" for the portable .exe. -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_Debug", "Win32_System_Threading", "Win32_Storage_FileSystem", "Win32_System_Kernel", "Win32_System_Memory"] } [patch.crates-io] # The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main diff --git a/src/app/mod.rs b/src/app/mod.rs index d366dd001..4c4217bc3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1922,6 +1922,9 @@ pub enum Message { MTextCaretMove(i32), /// Timer tick toggling the preview caret's blink phase. MTextCaretBlink, + /// Periodic liveness ping for the hang watchdog (#120): processed = the + /// UI thread is pumping messages. + WatchdogBeat, /// Commit the editor: create or update the MText entity. MTextOk, /// Apply the buffer to the entity but leave the editor open (the button). diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 297f96cec..b3e7c0f6c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -118,6 +118,12 @@ impl OpenCADStudio { } pub fn update(&mut self, msg: Message) -> Task { + // Every processed message proves the UI thread is alive (#120). + #[cfg(target_os = "windows")] + crate::io::hang_watchdog::beat(); + if matches!(msg, Message::WatchdogBeat) { + return Task::none(); + } // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -2396,6 +2402,10 @@ impl OpenCADStudio { self.mtext_caret_move(d); Task::none() } + // Handled (and returned) in `update` before dispatch; kept for + // match exhaustiveness. + Message::WatchdogBeat => Task::none(), + Message::MTextCaretBlink => { if let Some(ed) = self.mtext_editor.as_mut() { ed.caret_blink_on = !ed.caret_blink_on; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1c09aeeb0..64545de7d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1718,6 +1718,14 @@ impl OpenCADStudio { let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); #[cfg(target_arch = "wasm32")] let single_instance = Subscription::none(); + // Liveness ping for the hang watchdog (#120): a timer message the UI + // thread can only process while its event loop is actually pumping, + // so an idle-but-healthy app keeps beating. + #[cfg(target_os = "windows")] + let watchdog = iced::time::every(std::time::Duration::from_secs(5)) + .map(|_| Message::WatchdogBeat); + #[cfg(not(target_os = "windows"))] + let watchdog = Subscription::none(); iced::Subscription::batch([ frames, history_tick, @@ -1728,6 +1736,7 @@ impl OpenCADStudio { web_fonts, autosave, single_instance, + watchdog, event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { diff --git a/src/io/hang_watchdog.rs b/src/io/hang_watchdog.rs new file mode 100644 index 000000000..ca67586fa --- /dev/null +++ b/src/io/hang_watchdog.rs @@ -0,0 +1,140 @@ +//! UI-thread hang watchdog (#120). +//! +//! The Windows freeze reports (#120) all share one signature: the event loop +//! stops pumping (`AppHangB1` — "Top level window is idle"), one core spins, +//! and the app can only be killed from Task Manager. It never reproduces on +//! our machines, so this module turns a reporter's hang into a debuggable +//! artifact: a background thread watches a heartbeat that the UI thread bumps +//! on every processed message (plus a 5-second timer message so an idle but +//! healthy app keeps beating), and when the heartbeat goes stale for +//! [`STALL_AFTER_MS`] it writes a minidump — with every thread's stack — next +//! to the config, then leaves the app alone (it may still recover). +//! +//! The dump lands in `%LOCALAPPDATA%\OpenCADStudio\` as +//! `OpenCADStudio-hang-.dmp`, alongside a small `.txt` telling the +//! user what it is. One dump per run. `OCS_NO_WATCHDOG=1` disables the whole +//! thing. +//! +//! False-positive guards: +//! * not armed until the first beat (startup shader compiles can be slow); +//! * a watchdog sleep that overshoots wildly means the machine suspended — +//! every thread was paused, so the stale beat proves nothing and the +//! baseline is reset instead of dumping. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +static START: OnceLock = OnceLock::new(); +static BEAT_MS: AtomicU64 = AtomicU64::new(0); +static DUMPED: AtomicBool = AtomicBool::new(false); + +const CHECK_EVERY: Duration = Duration::from_secs(5); +/// How long the UI thread must stay silent before it counts as hung. Long +/// enough that a slow synchronous save of a huge drawing doesn't trip it. +const STALL_AFTER_MS: u64 = 25_000; + +fn now_ms() -> u64 { + START.get_or_init(Instant::now).elapsed().as_millis() as u64 +} + +/// Called from the UI thread for every processed message. `max(1)` keeps the +/// value nonzero so zero can mean "no beat yet" (watchdog not armed). +pub fn beat() { + BEAT_MS.store(now_ms().max(1), Ordering::Relaxed); +} + +/// Spawn the watchdog thread. Call once at startup, before the event loop. +pub fn start() { + if std::env::var_os("OCS_NO_WATCHDOG").is_some() { + return; + } + let _ = START.get_or_init(Instant::now); + let _ = std::thread::Builder::new() + .name("hang-watchdog".into()) + .spawn(|| { + // Beats older than this floor are ignored — bumped after a + // detected system suspend so the paused interval doesn't read + // as a hang. + let mut floor_ms: u64 = 0; + loop { + let before = Instant::now(); + std::thread::sleep(CHECK_EVERY); + if before.elapsed() > CHECK_EVERY * 3 { + floor_ms = now_ms(); + continue; + } + let beat = BEAT_MS.load(Ordering::Relaxed); + if beat == 0 { + continue; + } + let age = now_ms().saturating_sub(beat.max(floor_ms)); + if age > STALL_AFTER_MS && !DUMPED.swap(true, Ordering::SeqCst) { + write_dump(age); + } + } + }); +} + +/// Directory the dump goes to: `%LOCALAPPDATA%\OpenCADStudio`, or the temp +/// dir when that can't be created. +fn dump_dir() -> std::path::PathBuf { + std::env::var_os("LOCALAPPDATA") + .map(|d| std::path::PathBuf::from(d).join("OpenCADStudio")) + .filter(|d| std::fs::create_dir_all(d).is_ok()) + .unwrap_or_else(std::env::temp_dir) +} + +fn write_dump(stall_ms: u64) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::Diagnostics::Debug::{ + MiniDumpWithDataSegs, MiniDumpWithIndirectlyReferencedMemory, MiniDumpWithThreadInfo, + MiniDumpWriteDump, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId}; + + let dir = dump_dir(); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let path = dir.join(format!("OpenCADStudio-hang-{stamp}.dmp")); + let Ok(file) = std::fs::File::create(&path) else { + return; + }; + let ok = unsafe { + MiniDumpWriteDump( + GetCurrentProcess(), + GetCurrentProcessId(), + file.as_raw_handle(), + MiniDumpWithThreadInfo | MiniDumpWithDataSegs | MiniDumpWithIndirectlyReferencedMemory, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + ) + }; + drop(file); + if ok == 0 { + let _ = std::fs::remove_file(&path); + return; + } + let note = dir.join(format!("OpenCADStudio-hang-{stamp}.txt")); + let _ = std::fs::write( + ¬e, + format!( + "OpenCADStudio detected that its interface stopped responding for \ + {} seconds and captured a diagnostic snapshot:\n\n {}\n\n\ + Please attach that .dmp file to the GitHub issue \ + https://github.com/HakanSeven12/OpenCADStudio/issues/120 — it \ + contains the exact state of every thread at the moment of the \ + hang and no drawing contents.\n", + stall_ms / 1000, + path.display() + ), + ); + eprintln!( + "hang-watchdog: UI thread unresponsive for {}s — minidump written to {}", + stall_ms / 1000, + path.display() + ); +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 10375bb80..39a5d50d1 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -4,6 +4,8 @@ // Default save format: DWG (AC1032 / R2018+). pub mod file_association; +#[cfg(target_os = "windows")] +pub mod hang_watchdog; pub mod obj; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; diff --git a/src/main.rs b/src/main.rs index 1b1c737a6..5533cf433 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,6 +94,13 @@ fn main() -> iced::Result { std::env::set_var("WGPU_BACKEND", "dx12,vulkan"); } + // Hang watchdog (#120): captures a minidump when the UI thread + // stops pumping, so unreproducible Windows freezes become + // debuggable. Started before the event loop; headless modes below + // don't mind the extra parked thread. + #[cfg(target_os = "windows")] + io::hang_watchdog::start(); + // Headless modes exit without ever creating a window. if args.serve { // `app::serve` reads --port itself from the raw args.