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).
|
# (IApplicationAssociationRegistrationUI dialog).
|
||||||
# Win32_System_Registry — HKCU registration that lists the app under
|
# Win32_System_Registry — HKCU registration that lists the app under
|
||||||
# "Open with" for the portable .exe.
|
# "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]
|
[patch.crates-io]
|
||||||
# The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main
|
# The `acadrust` crate now tracks the OpenAEC-Foundation/acadifc fork's main
|
||||||
|
|||||||
@@ -1922,6 +1922,9 @@ pub enum Message {
|
|||||||
MTextCaretMove(i32),
|
MTextCaretMove(i32),
|
||||||
/// Timer tick toggling the preview caret's blink phase.
|
/// Timer tick toggling the preview caret's blink phase.
|
||||||
MTextCaretBlink,
|
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.
|
/// Commit the editor: create or update the MText entity.
|
||||||
MTextOk,
|
MTextOk,
|
||||||
/// Apply the buffer to the entity but leave the editor open (the button).
|
/// 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> {
|
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
|
// A modal dialog must capture the keyboard the same way it already
|
||||||
// captures the mouse. Otherwise keystrokes from the global key
|
// captures the mouse. Otherwise keystrokes from the global key
|
||||||
// subscription leak past the modal into the command line and fire as
|
// subscription leak past the modal into the command line and fire as
|
||||||
@@ -2396,6 +2402,10 @@ impl OpenCADStudio {
|
|||||||
self.mtext_caret_move(d);
|
self.mtext_caret_move(d);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
// Handled (and returned) in `update` before dispatch; kept for
|
||||||
|
// match exhaustiveness.
|
||||||
|
Message::WatchdogBeat => Task::none(),
|
||||||
|
|
||||||
Message::MTextCaretBlink => {
|
Message::MTextCaretBlink => {
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||||
ed.caret_blink_on = !ed.caret_blink_on;
|
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);
|
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let single_instance = Subscription::none();
|
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([
|
iced::Subscription::batch([
|
||||||
frames,
|
frames,
|
||||||
history_tick,
|
history_tick,
|
||||||
@@ -1728,6 +1736,7 @@ impl OpenCADStudio {
|
|||||||
web_fonts,
|
web_fonts,
|
||||||
autosave,
|
autosave,
|
||||||
single_instance,
|
single_instance,
|
||||||
|
watchdog,
|
||||||
event::listen_with(|ev, status, win_id| {
|
event::listen_with(|ev, status, win_id| {
|
||||||
use iced::event::Status;
|
use iced::event::Status;
|
||||||
match ev {
|
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+).
|
// Default save format: DWG (AC1032 / R2018+).
|
||||||
|
|
||||||
pub mod file_association;
|
pub mod file_association;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub mod hang_watchdog;
|
||||||
pub mod obj;
|
pub mod obj;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod single_instance;
|
pub mod single_instance;
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ fn main() -> iced::Result {
|
|||||||
std::env::set_var("WGPU_BACKEND", "dx12,vulkan");
|
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.
|
// Headless modes exit without ever creating a window.
|
||||||
if args.serve {
|
if args.serve {
|
||||||
// `app::serve` reads --port itself from the raw args.
|
// `app::serve` reads --port itself from the raw args.
|
||||||
|
|||||||
Reference in new issue
Block a user