forked from 1b-agent/opencadstudio-upstream
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:
1 parent
805da6428a
commit
b3decf45ec
7 files changed
+172
-1
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
¬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()
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user