feat(diag): UI-thread hang watchdog with minidump capture (#120)

The Windows freeze in #120 never reproduces locally and a hung app
prints nothing, so terminal runs and logs came back empty. A watchdog
thread now watches a heartbeat the UI thread bumps on every processed
message (plus a 5 s liveness timer); 25 s of silence writes a minidump
with every thread's stack to %LOCALAPPDATA%\OpenCADStudio plus a note
telling the reporter to attach it to the issue. One dump per run,
armed only after the first beat, suspend-aware, OCS_NO_WATCHDOG=1
disables. Windows-only; windows-sys gains the Debug/Threading binding
features (no new crates).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-22 22:13:00 +03:00
1 parent 805da6428a
commit b3decf45ec
7 files changed
+172 -1

No files matched your search

+1 -1
View File
@@ -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
+3
View File
@@ -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).
+10
View File
@@ -118,6 +118,12 @@ impl OpenCADStudio {
}
pub fn update(&mut self, msg: Message) -> Task<Message> {
// 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;
+9
View File
@@ -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 {
+140
View File
@@ -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-<unix-secs>.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<Instant> = 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(
&note,
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()
);
}
+2
View File
@@ -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;
+7
View File
@@ -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.