Skip to content

fix(shell): make the freeze watchdog actually report, and stop the recovery from blocking the thread it rescues - #3

Closed
masknull wants to merge 2 commits into
mainfrom
fix/hangwatch-forensics-and-ui-thread
Closed

masknull wants to merge 2 commits into
mainfrom
fix/hangwatch-forensics-and-ui-thread

Conversation

@masknull

Copy link
Copy Markdown
Owner

背景:为什么"修了几次还在犯"

黑屏/卡死反复出现的结构性原因是:为抓它而加的探针写不出日志。

Windows 自己已判定过这个应用"无响应"(Application Hang,事件 ID 1002,2026-08-23 与 08-24 各一次),所以机制真实存在。但 v2.0.7 的 [hangwatch] 在写日志之前先问 Tauri 要窗口可见性,而 Tauri 的窗口 getter 会派发回主线程并等待应答——卡死时那个线程正是不应答的那个。于是探针卡在自己的取证步骤里,一条都没写出来。

所以"日志里没有 [hangwatch]"从来不能证明"没发生卡死":没被测量到的情形,恰恰就是要调查的那一种。

改动

1. 让探针在卡死时真的能报告

  • HWND 改用 Win32 EnumWindows(按本进程 + 可见 + 无属主过滤)获取并缓存,全程零派发;不再走 get_webview_window
  • 日志先于任何其它工作;可见性改用 Win32 IsWindowVisible(纯读取)
  • 记录阻塞持续时长,持续期间每 10 秒重报;首报按一个采样周期回推,不再声称 0 ms——这样"卡了 3 秒"和"卡到用户手动杀掉"可以区分
  • SEND_TIMEOUT_MS(1.5s) 降到 SAMPLE_MS(2s) 之下;旧的 2s 超时配 2s 周期会整整晚一个节拍

2. 救援手段不再阻塞它要救的线程

  • nudge_window_frame 的冷却先写时间戳再判断,导致亚秒级抖动(鼠标划过窗口、焦点抖动)不断刷新时间戳,恰好在事件密集、最需要它时被锁死。改为先读、判断、再落章
  • 移除 SetWindowPos(SWP_FRAMECHANGED):按 MSDN 它即使尺寸未变也会同步发送 WM_NCCALCSIZE,且 SetWindowPos 总会发送 WM_WINDOWPOSCHANGED——救援路径在往它要救的线程发同步消息。改用 RedrawWindow + RDW_FRAME | RDW_ALLCHILDREN,重绘边框与子窗口且无往返

3. 时机与 UI 线程卫生

  • show() 是派发后立即返回,所以重绘跑在窗口尚未映射时:空操作,却仍消耗了冷却名额,等窗口真正出现时恢复已被锁死。改为后台线程等到窗口真正可见再执行
  • window_close() → async + spawn_blocking。Tauri 维护者明确指出 close() 是唯一必须离开 UI 线程的窗口操作
  • on_window_event 的 focus 分支不再调用会派发的查找:该回调本身运行在主线程,回调内等待"派发回主线程"就是等自己(自死锁)

验证

本机无 Rust 工具链(cargo/rustup/MSVC 均不存在),因此以 CI(build-shell.yml,windows-latest)作为编译验证。本地人工审查覆盖:括号平衡、被替换辅助函数的每一处调用点、已废弃 main_hwnd() 的移除。

说明

这些改动让下一条证据能被写出来,还不等于"已证明治好"。真正的验证是:下次黑屏时日志中出现带持续时长的 [hangwatch] 记录。若届时仍无记录,则是强证据指向 DWM/合成层,排查方向需要整体更换。

同时并存一个独立故障(不在本 PR 范围):后端 3080 Node 进程周期性 CPU 饱和(dsh-client-modules 的 compose/newlineCount 逐字符迭代 + partitionComboRecords 的 O(n²) URL 构造)。

deepseek added 2 commits September 29, 2026 12:03
…covery from blocking the thread it rescues

The black-frame/reproducible-freeze reports survived several rounds of fixing
because the instrument added to catch them could not report. Windows itself
does classify this app as hung (Application Hang, event 1002, twice on
2026-08-23/24), so the mechanism is real — but the v2.0.7 probe asked Tauri
for window state before logging, and Tauri window getters dispatch to the
main thread and wait. During a stall that thread is exactly the one not
answering, so the probe hung inside its own capture step and no line was
ever written. "No [hangwatch] entries" was therefore never evidence of "no
stall" — the unmeasured case was the very case under investigation.

Watchdog (lib.rs):
- Resolve the HWND via Win32 instead of Tauri: EnumWindows filtered to this
  process, visible, unowned. Cache it after the first success. No dispatcher
  round trip on any path.
- Log BEFORE any other work, and read visibility with Win32
  IsWindowVisible (a plain read) rather than is_visible().
- Report the stall duration, repeating every 10 s while it persists. The
  first report back-dates by one sampling period instead of claiming 0 ms,
  so "wedged 3 s" is distinguishable from "wedged until the user killed it".
- SEND_TIMEOUT_MS (1.5 s) now sits below SAMPLE_MS (2 s); the old 2 s
  timeout against a 2 s period drifted a whole tick behind.

Frame recompose (nudge_window_frame):
- The cooldown stamped its timestamp before testing it, so sub-second churn
  (pointer crossing the window, focus flapping) kept refreshing the stamp
  and locked the recompose out precisely when a burst of events made it
  necessary. Read, decide, then claim.
- Drop SetWindowPos(SWP_FRAMECHANGED): per MSDN it sends WM_NCCALCSIZE
  synchronously even when the size is unchanged, and SetWindowPos always
  sends WM_WINDOWPOSCHANGED — the rescue path was posting synchronous
  messages to the thread it was meant to rescue. RedrawWindow with
  RDW_FRAME | RDW_ALLCHILDREN repaints frame and children with no round trip.

Recompose timing / UI-thread hygiene:
- show() dispatches and returns immediately, so the repaint ran before the
  window was mapped: a no-op that still consumed the cooldown slot, leaving
  the recovery locked out by the time the window appeared. It now waits on a
  background thread until the window is really visible.
- window_close() is async + spawn_blocking. Tauri maintainers single out
  close() as the one window operation that must not run on the UI thread.
- on_window_event's focus branch no longer calls a dispatching lookup:
  inside that callback the main thread is the caller, so waiting on a
  dispatch back to it is a self-deadlock.

Verified: no Rust toolchain on this machine, so compilation is validated by
CI (build-shell.yml, windows-latest). Local review covered brace balance,
every call site of the replaced helpers, and removal of the superseded
main_hwnd().
…7 *const c_void is not Send)

CI caught what local review could not (no Rust toolchain on the reporting
machine): a raw pointer is not Send, so moving it into std::thread::spawn
does not compile.

    error[E0277]: *const c_void cannot be sent between threads safely
      --> src\lib.rs:404:24

The handle now crosses the boundary as usize and is cast back inside the
closure. The window outlives the thread, so the address stays valid. Same
pattern already used for MAIN_HWND_CACHE and the watchdog's cached handle.

This was the only compile error in the run.
@masknull masknull closed this Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant