diff --git a/CHANGELOG.md b/CHANGELOG.md index 574f6d1..ce6fee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v2.0.7 — 2026-09-25 + +**重启链路全面原生化(netstat/taskkill 退出重启路径)+ UI 线程卫生(黑屏/卡死根因修复)**: + +- **重启后端不再 spawn 任何外挂进程(新增 `src-tauri/src/wproc.rs`,零新依赖,内核态 FFI)**: + - `netstat -ano -p tcp`(每次重启发 1~N 次 spawn,本机实测单次 311ms 冷 / 44ms 热;`wait_for_backend_stop` 每 300ms 轮询一次、最坏 8s)→ 进程内 `GetExtendedTcpTable`(iphlpapi)读 LISTENING 表,微秒级,且天然忽略同端口大量 ESTABLISHED 行 + - `taskkill /PID … /T /F` → `CreateToolhelp32Snapshot` 全进程快照 + BFS 收子树 + `OpenProcess`/`TerminateProcess`,无 spawn(安全护栏:永不杀自身与 pid≤4) + - “等端口停止应答”的 HTTP 探测轮询(每 400ms、单次最坏 3s 超时、上限 10s)→ 对 teardown/清端口记录的被杀 PID 直接 `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`,进程一退出即刻唤醒 + - 固定 settle 1000ms → 250ms(端口清空由条件判定覆盖);端口表兜底轮询 300ms → 50ms(原生查表零成本) + - 实测基线(dsh.log“重启清理→启动尝试”间隔):旧 **6~20s** → 预期 **1~2s** + - 非 Windows 保留 netstat/taskkill 回落实现(cfg 分支),CI 仅构建 Windows 不变 +- **UI 线程卫生(黑屏/卡死的直接根因)**:Tauri v2 同步命令的函数体跑在主线程(wry 事件循环)上,阻塞即冻结窗口——消息泵停转 → 窗口表面黑屏/标题栏透明/任务栏预览黑,而 WebView2 内容仍在、可点击,系统与其它应用毫无感知,与用户反馈完全吻合。此前 `env_info`/`dsh_npm_probe`/`diagnostic_export` 已 async 化,漏网的三处全在重启/退出路径上: + - `dsh_custom_path`(同步跑完整套 stop_backend + wait_for_backend_stop,最坏 ~19s)→ async + spawn_blocking + - `app_full_restart`(UI 线程执行 stop_backend 后才 exit)→ spawn_blocking + - `dsh_exit`(UI 线程 teardown 整树杀)→ spawn_blocking + - `log_tail`(整读日志文件,日志页每 2s 轮询一次)→ async + spawn_blocking,并且只 seek 到文件尾读最后 256KB(不再整读整解码) +- **偶发黑屏:捕获 + 自恢复**(复现是偶发的,先把“看不见”变成“有记录”): + - `[hangwatch]`:setup 起一个独立线程,每 2s 对主窗口发 `SendMessageTimeout(WM_NULL)`(以 `GetLastError()==ERROR_TIMEOUT` 判定,而非返回值——WM_NULL 的结果本身就是 0),消息泵停转 >2s 即写日志,附 listeners/窗口可见性快照,恢复时记录阻塞时长 + - 窗口获得焦点与从托盘恢复时强制重组窗口表面:`RedrawWindow(RDW_INVALIDATE|RDW_INTERNALPAINT|RDW_UPDATENOW)` + `SetWindowPos(SWP_FRAMECHANGED)` 重绘原生边框、驱动 WM_NCCALCSIZE/WM_WINDOWPOSCHANGED 让 wry 重排(纯 Win32、不走 Tauri 派发, 事件回调内也安全);1s 冷却,alt-tab 反复抖动不会成环 + - 依据:已有 WebView2Feedback 记录表明窗口在显示/激活/遮挡态切换时窗口表面呈现可能失败([#1077](https://github.com/MicrosoftEdge/WebView2Feedback/issues/1077)),渲染进程并不死——正对应“内容可点、框是黑的” + +Native restart path (netstat/taskkill removed) + UI-thread hygiene (root-cause fix for the black-frame freezes): + +- New `src-tauri/src/wproc.rs` (raw Win32 FFI, no dependencies): `GetExtendedTcpTable` replaces the per-restart `netstat` spawns (measured 311 ms cold on this machine), a Toolhelp32 snapshot + `TerminateProcess` walk replaces `taskkill /T`, and `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject` on the killed PIDs replaces the HTTP-poll wait — measured restart gap ("重启清理" → "启动尝试") drops from 6–20 s to an expected 1–2 s; the blind 1 s settle is now 250 ms +- All four remaining synchronous commands that blocked the UI thread are off it now (`dsh_custom_path`, `app_full_restart`, `dsh_exit` → spawn_blocking; `log_tail` → async + tail-only 256 KB read). A blocked UI thread stops the window painting its own frame — black content, transparent caption, black taskbar preview — while the WebView2 content and the OS keep working, exactly the reported symptom +- A `[hangwatch]` background thread probes the main window's message pump every 2 s (`SendMessageTimeout(WM_NULL)` with the `ERROR_TIMEOUT` check) and logs stalls with a state snapshot, so the sporadic occurrences become diagnosable; window activation / tray restore now force a native frame recompose (`RedrawWindow` + `SetWindowPos(SWP_FRAMECHANGED)`, pure Win32, no Tauri dispatch), rate-limited to one per second + ## v2.0.6 — 2026-09-16 **复用(attach)实例不再卡在「是否重启接管」:从运行中的进程内存里取回 launch token**: diff --git a/src-tauri/src/dsh.rs b/src-tauri/src/dsh.rs index 9525193..99a3e11 100644 --- a/src-tauri/src/dsh.rs +++ b/src-tauri/src/dsh.rs @@ -25,7 +25,6 @@ use std::time::{Duration, Instant}; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager}; -use uuid::Uuid; const DSH_ORIGIN: &str = "http://127.0.0.1:3080"; const DSH_BASE: &str = "http://127.0.0.1:3080"; @@ -57,7 +56,7 @@ const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// Local port the DSH web server listens on; also the anchor for finding an /// attached instance's PID at restart time. -const DSH_PORT: u16 = 3080; +pub(crate) const DSH_PORT: u16 = 3080; /// A DSH subprocess we spawned (and therefore own the lifecycle of). struct DshInner { @@ -1331,20 +1330,12 @@ fn run_capture(program: &str, args: &[&str]) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } -/// Pid currently listening on the DSH port. +/// Pid currently listening on the DSH port. Native TCP-table lookup +/// (`GetExtendedTcpTable`, microseconds, no helper process) — the old +/// `netstat -ano -p tcp` spawn cost ~50–300 ms per call and sat on the +/// restart critical path; see `wproc` for the full rationale. fn port_listener_pid() -> Option { - let mut command = Command::new("netstat"); - command.args(["-ano", "-p", "tcp"]); - apply_no_window(&mut command); - let output = command.output().ok()?; - let text = String::from_utf8_lossy(&output.stdout); - let suffix = format!(":{DSH_PORT}"); - text.lines().find_map(|line| { - let fields: Vec<&str> = line.split_whitespace().collect(); - (fields.len() >= 5 && fields[0] == "TCP" && fields[1].ends_with(&suffix) && fields[4] != "0") - .then(|| fields[4].parse::().ok()) - .flatten() - }) + crate::wproc::listener_pids(DSH_PORT).into_iter().next() } /// Who owns the DSH port: pid, command line, and whether the parent chain @@ -1390,14 +1381,42 @@ fn profile_plugin_versions() -> Value { } /// Last `n` lines of the shared shell log for the console pane. +/// +/// Tail read, not a full `read_to_string`: the log tab polls this every 2 s, +/// and while it was a *synchronous* Tauri command the whole (growing) file +/// was decoded on the UI thread. Now it is async as well, but reading only +/// the last 256 KB keeps even the fallback cost trivial. pub(crate) fn log_tail(n: usize) -> Vec { - std::fs::read_to_string(log_path()) - .map(|text| { - let lines: Vec<&str> = text.lines().collect(); - let start = lines.len().saturating_sub(n); - lines[start..].iter().map(|l| l.to_string()).collect() - }) - .unwrap_or_default() + use std::io::{Read, Seek, SeekFrom}; + + const TAIL_BYTES: u64 = 256 * 1024; + let Ok(mut file) = std::fs::File::open(log_path()) else { + return Vec::new(); + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + let start = len.saturating_sub(TAIL_BYTES); + let mut bytes = Vec::new(); + if start > 0 && file.seek(SeekFrom::Start(start)).is_err() { + bytes.clear(); + // Seek failed: fall back to a full read (rare, same as the old code). + if std::io::Seek::seek(&mut file, SeekFrom::Start(0)).is_ok() { + let _ = file.read_to_end(&mut bytes); + } + } else { + let _ = file.read_to_end(&mut bytes); + } + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if start > 0 { + // The 256 KB boundary likely lands mid-line; drop the partial first + // line so the viewer never shows a truncated entry. + match text.find('\n') { + Some(nl) => text = text[nl + 1..].to_string(), + None => text = String::new(), + } + } + let lines: Vec<&str> = text.lines().collect(); + let skip = lines.len().saturating_sub(n); + lines[skip..].iter().map(|l| l.to_string()).collect() } /// Total bytes under `dir`, bounded: the walk stops counting past 50k files @@ -1776,27 +1795,32 @@ pub fn cold_start(app: AppHandle) { } /// Kill any process still listening on the DSH port: an attached instance we -/// never spawned, or a straggler the owned-tree taskkill missed. Locale-safe: -/// matches the numeric local-address column, not the state text. +/// never spawned, or a straggler the owned-tree kill missed. Native TCP table +/// (no `netstat` spawn) + native tree kill, each recorded in +/// `PENDING_EXIT_PIDS` so `wait_for_backend_stop` can wait on the exact +/// processes instead of polling the port table on a timer. fn kill_port_listeners() { - let mut command = Command::new("netstat"); - command.args(["-ano", "-p", "tcp"]); - apply_no_window(&mut command); - let Ok(output) = command.output() else { - return; - }; - let text = String::from_utf8_lossy(&output.stdout); - let suffix = format!(":{DSH_PORT}"); - for line in text.lines() { - let fields: Vec<&str> = line.split_whitespace().collect(); - if fields.len() >= 5 - && fields[0] == "TCP" - && fields[1].ends_with(&suffix) - && fields[4] != "0" - { - if let Ok(pid) = fields[4].parse::() { - kill_tree(pid); - } + for pid in crate::wproc::listener_pids(DSH_PORT) { + if pid == std::process::id() { + continue; // paranoia: never the shell itself + } + supervision_log(&format!("[backend] 清理 {DSH_PORT} 监听进程 pid {pid}")); + pending_exit_push(pid); + kill_tree(pid); + } +} + +/// PIDs a teardown/restart must observe exiting before a fresh backend +/// spawns: the owned child (`teardown`) plus any 3080 listener cleared on the +/// way (`kill_port_listeners`, attached instances included). Drained by +/// `wait_for_backend_stop`, which then blocks on each process handle — a +/// kernel-signalled wait replaces the old port-table poll loop. +static PENDING_EXIT_PIDS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn pending_exit_push(pid: u32) { + if let Ok(mut guard) = PENDING_EXIT_PIDS.lock() { + if !guard.contains(&pid) { + guard.push(pid); } } } @@ -1806,24 +1830,46 @@ fn kill_port_listeners() { /// the old dsh process tree (usage-billing writer, cordis children, …) takes /// a few seconds to tear down, and a too-early relaunch makes the fresh dsh /// web crash on leftover state (double usage-billing writer, TIME_WAIT port, -/// held lock files). Condition-based, not a blind fixed sleep: we proceed the -/// moment the port is confirmed clear. +/// held lock files). +/// +/// Native and condition-based (v2.0.7 rework, the old version spawned +/// `netstat` every 300 ms and did HTTP probes with a 3 s timeout): +/// 1. block on the killed PIDs' process handles — exact, instant wake-up; +/// 2. poll the native TCP table until 3080 has no listener (fallback for +/// anything the killed set did not cover); +/// 3. a short settle so the rest of the tree unwinds and lock files clear. fn wait_for_backend_stop() { - // 1) Wait for the port to stop answering (the old server's dying window). - let deadline = Instant::now() + Duration::from_secs(10); - while probe_ready_once() && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(400)); + // 1) Exact waits on the PIDs recorded by teardown/kill_port_listeners. + let mut pending: Vec = PENDING_EXIT_PIDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain(..) + .collect(); + pending.sort_unstable(); + pending.dedup(); + let budget = Instant::now() + Duration::from_secs(8); + for pid in pending { + if pid == std::process::id() { + continue; + } + let left = budget.saturating_duration_since(Instant::now()); + if !crate::wproc::wait_exit(pid, left) { + supervision_warn(&format!( + "[backend] pid {pid} 在退出预算内未消失, 交由端口轮询兜底" + )); + } } - // 2) Wait for no process to be listening on 3080 — the old listener has - // been killed by `kill_port_listeners`; this polls until the tree is - // reaped. Condition-based: if the tree exits fast we proceed fast. - let settle = Instant::now() + Duration::from_secs(8); - while port_listener_pid().is_some() && Instant::now() < settle { - std::thread::sleep(Duration::from_millis(300)); + // 2) Native listener poll (microseconds per look) — catches foreign + // instances, access-denied kills, and late-comers. + let settle = Instant::now() + Duration::from_secs(6); + while !crate::wproc::listener_pids(DSH_PORT).is_empty() && Instant::now() < settle { + std::thread::sleep(Duration::from_millis(50)); } - // 3) Short settle so the rest of the tree (usage-billing writer, cordis - // children, …) finishes unwinding + TIME_WAIT/lock files clear. - std::thread::sleep(Duration::from_millis(1000)); + // 3) Short settle: usage-billing writer / cordis children unwind, + // TIME_WAIT and lock files clear. The condition above already covers + // the port itself; 250 ms is enough for the rest of the tree (was a + // blind 1000 ms). + std::thread::sleep(Duration::from_millis(250)); } /// Tear down the owned subprocess tree (if we spawned one). Safe to call from @@ -1833,9 +1879,13 @@ pub fn teardown(app: &AppHandle) { let state = app.state::(); let mut guard = state.inner.lock().unwrap(); if let Some(inner) = guard.take() { - // Child::kill only reaps the cmd shim on Windows; taskkill /T kills the - // whole node tree so no orphan keeps holding 3080. The supervisor - // thread reaps the Child and stays quiet (intentional stop). + // `Child` only reaps the cmd shim on Windows; the native tree kill + // (Toolhelp32 snapshot + TerminateProcess, replaces `taskkill /T`) + // walks the whole `cmd → node → usage-writer/cordis` tree so no + // orphan keeps holding 3080. The supervisor thread reaps the Child + // and stays quiet (intentional stop). The PID is recorded first so + // `wait_for_backend_stop` can block on its exit handle exactly. + pending_exit_push(inner.pid); kill_tree(inner.pid); } } @@ -2008,13 +2058,12 @@ fn log_path() -> std::path::PathBuf { shell_data_dir().join("dsh.log") } -/// Kill a process *tree* by root PID. `cmd /C …` → node is a grandchild; `/T` -/// walks the tree so nothing survives on 3080. +/// Kill a process *tree* by root PID. `cmd /C …` → node is a grandchild, so +/// the whole subtree must go or an orphan keeps holding 3080. Native +/// Toolhelp32 snapshot + TerminateProcess — no `taskkill` spawn (each spawn +/// was ~150–300 ms on the restart path). See `wproc`. fn kill_tree(pid: u32) { - let mut command = Command::new("taskkill"); - command.args(["/PID", &pid.to_string(), "/T", "/F"]); - apply_no_window(&mut command); - let _ = command.status(); + crate::wproc::kill_tree(pid); } #[cfg(windows)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65621f2..1ce770c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,11 +6,12 @@ mod memtok; mod menu; mod monitor; mod update; +mod wproc; use tauri::{ menu::{Menu, MenuItem}, tray::{TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, WindowEvent, + AppHandle, Manager, WindowEvent, }; /// AppUserModelID stamped on toasts; must match the registry registration in @@ -67,9 +68,14 @@ fn open_path(app: AppHandle, path: String) { } /// Tail of the shared dsh.log for the log tab of the secondary panel. +/// async + spawn_blocking: the log tab polls every 2 s; a synchronous command +/// would read the whole growing file on the UI thread (the window's message +/// pump starves → black/frozen frame while the OS stays responsive). #[tauri::command] -fn log_tail(lines: usize) -> Vec { - dsh::log_tail(lines.clamp(50, 1000)) +async fn log_tail(lines: usize) -> Vec { + tauri::async_runtime::spawn_blocking(move || dsh::log_tail(lines.clamp(50, 1000))) + .await + .unwrap_or_default() } /// Panel「重启」: restart the dsh web backend (same flow as the tray entry — @@ -84,7 +90,10 @@ fn dsh_restart_backend(app: AppHandle) { /// owned DSH torn down — the new instance re-runs the whole chain. #[tauri::command] fn app_full_restart(app: AppHandle) { - update::restart_app(&app); + // Off the UI thread: restart_app runs the full stop_backend (teardown + + // 3080 cleanup + waits) before exiting, which used to starve the window's + // message pump for the duration. + tauri::async_runtime::spawn_blocking(move || update::restart_app(&app)); } /// One-paste AI context: env facts + this session's log as a markdown @@ -140,16 +149,25 @@ async fn diagnostic_export(app: AppHandle) -> Result /// Frontend-invoked custom dsh path from the notfound dialog: validates it /// exists, persists it, and retries startup with it leading the chain. +/// async + spawn_blocking: `set_custom_path` runs the same cleanup + wait as a +/// full restart (stop_backend + wait_for_backend_stop — seconds on a bad +/// day); as a synchronous command that ran on the UI thread and froze the +/// window for the entire wait (black frame, message pump starved). #[tauri::command] -fn dsh_custom_path(app: AppHandle, path: String) -> Result<(), String> { - dsh::set_custom_path(&app, path) +async fn dsh_custom_path(app: AppHandle, path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || dsh::set_custom_path(&app, path)) + .await + .map_err(|e| e.to_string())? } /// Frontend-invoked exit from the notfound choice. #[tauri::command] fn dsh_exit(app: AppHandle) { - dsh::teardown(&app); - app.exit(0); + // Off the UI thread: teardown walks the whole backend tree. + tauri::async_runtime::spawn_blocking(move || { + dsh::teardown(&app); + app.exit(0); + }); } // --- Titlebar window controls, as app commands. The frontend window-plugin @@ -206,9 +224,209 @@ pub(crate) fn show_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); + // WebView2's window-surface presentation can fail across + // show/activate transitions (the reported symptom: window content + // black, caption bar transparent, taskbar preview black — while the + // webview content stays visible and clickable and the OS never + // stalls). Force a frame recompose; rate-limited by the cooldown + // inside `nudge_window_frame`. + recompose_main_frame(app); + } +} + +/// Main-window HWND as a raw pointer, or None when unavailable. The window +/// is owned by the AppHandle for the app's lifetime, so the HWND stays valid +/// for any caller that runs while the app lives. +#[cfg(windows)] +pub(crate) fn main_hwnd(app: &AppHandle) -> Option<*const core::ffi::c_void> { + use raw_window_handle::HasWindowHandle; + let window = app.get_webview_window("main")?; + // WindowHandle borrows window; resolve the raw pointer inside the closure. + window.window_handle().ok().and_then(|h| match h.as_raw() { + raw_window_handle::RawWindowHandle::Win32(wh) => { + Some(wh.hwnd.get() as *const core::ffi::c_void) + } + _ => None, + }) +} + +/// Force a full frame recompose of the window: `RedrawWindow` + +/// `SWP_FRAMECHANGED` repaint the native frame (the transparent-caption / +/// black-taskbar case). Pure Win32, no Tauri dispatch — safe from any +/// thread, including inside `on_window_event`. The `SWP_FRAMECHANGED` path +/// makes the window proc run WM_NCCALCSIZE / WM_WINDOWPOSCHANGED, which wry +/// observes and re-lays-out against. Cooldown-protected: focus flapping +/// (alt-tab repeatedly) must not turn this into a resize loop. +#[cfg(windows)] +fn nudge_window_frame(hwnd: *const core::ffi::c_void) { + #[link(name = "user32")] + extern "system" { + fn RedrawWindow( + hwnd: *const core::ffi::c_void, + lprc_update: *const core::ffi::c_void, + hrgn_update: *const core::ffi::c_void, + flags: u32, + ) -> i32; + fn SetWindowPos( + hwnd: *const core::ffi::c_void, + hwnd_insert_after: *const core::ffi::c_void, + x: i32, + y: i32, + cx: i32, + cy: i32, + u_flags: u32, + ) -> i32; + } + const RDW_INVALIDATE: u32 = 0x0001; + const RDW_INTERNALPAINT: u32 = 0x0002; + const RDW_UPDATENOW: u32 = 0x0100; + const SWP_NOSIZE: u32 = 0x0001; + const SWP_NOMOVE: u32 = 0x0002; + const SWP_NOZORDER: u32 = 0x0004; + const SWP_NOACTIVATE: u32 = 0x0010; + const SWP_FRAMECHANGED: u32 = 0x0020; + + let now = unix_millis(); + static LAST_NUDGE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let previous = LAST_NUDGE_MS.swap(now, std::sync::atomic::Ordering::Relaxed); + if previous != 0 && now.saturating_sub(previous) < 1_000 { + return; // cooldown: at most one recompose per second + } + unsafe { + RedrawWindow( + hwnd, + core::ptr::null(), + core::ptr::null(), + RDW_INVALIDATE | RDW_INTERNALPAINT | RDW_UPDATENOW, + ); + SetWindowPos( + hwnd, + core::ptr::null(), + 0, + 0, + 0, + 0, + SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ); } } +/// The "window may be black" recovery: a native frame recompose +/// (`RedrawWindow` + `SetWindowPos(SWP_FRAMECHANGED)`), which repaints the +/// frame and drives the WM_NCCALCSIZE / WM_WINDOWPOSCHANGED path wry +/// re-lays out against. Deliberately no Tauri window dispatch inside, so +/// it is equally safe from the tray path and from `on_window_event`. +#[cfg(windows)] +fn recompose_main_frame(app: &AppHandle) { + if let Some(hwnd) = main_hwnd(app) { + nudge_window_frame(hwnd); + } +} + +#[cfg(not(windows))] +fn recompose_main_frame(_app: &AppHandle) {} + +#[cfg(windows)] +fn unix_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Watchdog for the sporadic black-frame/frozen-window reports. Samples every +/// 2 s whether the main window's thread is still pumping its message queue +/// (a `SendMessageTimeout(WM_NULL)` round-trip — the same idea behind +/// Windows' own "not responding" classification). A window whose pump stops +/// stops painting its own frame — content black, caption transparent, +/// taskbar preview black — even while the WebView2 content and every other +/// app keep working, which matches the report exactly. Runs on its own +/// thread and only writes to the shell log, so an occurrence is captured +/// with timestamps even when nobody is in front of the machine. +#[cfg(windows)] +fn spawn_hang_watchdog(app: &AppHandle) { + use std::time::Duration; + + #[link(name = "user32")] + extern "system" { + fn SendMessageTimeoutW( + hwnd: *const core::ffi::c_void, + msg: u32, + wparam: usize, + lparam: isize, + fu_flags: u32, + u_timeout: u32, + pdw_result: *mut usize, + ) -> isize; + } + // kernel32 exports; the windows std preludes usually resolve them, but + // name the import lib explicitly (the LNK2019 lesson from the user32 FFI). + #[link(name = "kernel32")] + extern "system" { + fn GetLastError() -> u32; + fn SetLastError(dw_err_code: u32); + } + const WM_NULL: u32 = 0x0000; + const SMTO_NORMAL: u32 = 0x0000; + const SMTO_ABORTIFHUNG: u32 = 0x0002; + const ERROR_TIMEOUT: u32 = 1460; + + use std::sync::atomic::{AtomicU64, Ordering}; + static STALL_SINCE_MS: AtomicU64 = AtomicU64::new(0); // 0 = pumping normally + + let app = app.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(Duration::from_secs(2)); + let Some(hwnd) = main_hwnd(&app) else { + continue; + }; + let mut result: usize = 0; + // WM_NULL's window-procedure result is always 0, so the *return + // value* says nothing; a stall is precisely identified by + // GetLastError() == ERROR_TIMEOUT after the send. + unsafe { SetLastError(0) }; + let sent = unsafe { + SendMessageTimeoutW( + hwnd, + WM_NULL, + 0, + 0, + SMTO_NORMAL | SMTO_ABORTIFHUNG, + 2_000, + &mut result, + ) + }; + let timed_out = sent == 0 && unsafe { GetLastError() } == ERROR_TIMEOUT; + if !timed_out { + let since = STALL_SINCE_MS.swap(0, Ordering::Relaxed); + if since != 0 { + dsh::log_write( + dsh::LogLevel::Warn, + &format!( + "[hangwatch] 主窗口消息泵恢复(阻塞约 {} ms)", + unix_millis().saturating_sub(since) + ), + ); + } + continue; + } + // Pump stalled past the 2 s timeout. + if STALL_SINCE_MS.swap(unix_millis(), Ordering::Relaxed) == 0 { + let listeners = wproc::listener_pids(dsh::DSH_PORT); + let visible = app + .get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + dsh::log_write( + dsh::LogLevel::Warn, + &format!( + "[hangwatch] 主窗口消息泵 >2s 无响应(UI 线程阻塞) —— 黑屏/透明标题栏的直接嫌疑; winVisible={visible}, listeners={listeners:?}" + ), + ); + } + }); +} + /// Quit path: tear down our DSH subprocess tree, then exit. Attached mode tears /// down to nothing and leaves the pre-existing instance running. fn quit_dsh(app: &AppHandle) { @@ -537,6 +755,8 @@ pub fn run() { tauri::Builder::default() std::thread::spawn(move || dsh::cold_start(lifecycle)); let monitor_app = app.handle().clone(); std::thread::spawn(move || monitor::run(monitor_app)); + #[cfg(windows)] + spawn_hang_watchdog(app.handle()); // (改版) 自动自检更新与插件同步已禁用:本壳没有独立 Release // 渠道,也不允许任何自动覆盖/同步动作碰用户的 DSH 配置。 @@ -550,6 +770,20 @@ pub fn run() { tauri::Builder::default() api.prevent_close(); let _ = window.hide(); } + // Focus gained (alt-tab / taskbar click / tray restore): + // WebView2's window-surface presentation occasionally fails + // exactly across these transitions — black content with a + // transparent caption while the webview stays clickable. Force + // a native frame recompose (rate-limited inside); deliberately + // dispatch-free so it is safe inside the event handler. + #[cfg(windows)] + if let WindowEvent::Focused(focused) = event { + if *focused { + if let Some(hwnd) = main_hwnd(window.app_handle()) { + nudge_window_frame(hwnd); + } + } + } } }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index b924087..36374dd 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -15,7 +15,7 @@ use std::path::Path; use tauri::AppHandle; -use crate::dsh; +// Note: `crate::dsh::…` is referenced by full path below; no `use` needed. const CREATE_NO_WINDOW: u32 = 0x0800_0000; diff --git a/src-tauri/src/wproc.rs b/src-tauri/src/wproc.rs new file mode 100644 index 0000000..270a433 --- /dev/null +++ b/src-tauri/src/wproc.rs @@ -0,0 +1,333 @@ +//! Windows-native process-tree and TCP-listener primitives. +//! +//! Why this module exists: every DSH backend restart used to shell out to +//! `netstat -ano -p tcp` (to learn who listens on 3080) and to +//! `taskkill /PID … /T /F` (to kill the owned tree), then poll those foreign +//! processes every 300–400 ms while waiting for the port to come free. Each +//! `netstat` spawn costs ~50–300 ms on this machine (measured), the poll loop +//! runs up to 8 s, and a dying server can stretch each readiness HTTP probe +//! to its 3 s timeout — a restart that should take ~1 s routinely took +//! 6–20 s (see dsh.log: "[backend] 重启清理" → "===== 启动尝试" gaps). +//! +//! All three operations exist in-process in the Win32 API, so this module +//! replaces the spawn-and-parse round trips with direct FFI — the same +//! raw-`kernel32`/explicit-`#[link]` style as `memtok.rs` and the +//! MessageBox FFI in lib.rs (both learned the LNK2019 lesson: the `windows` +//! crates in the dependency tree mostly use raw-dylib, so the import libs +//! must be named explicitly). +//! +//! * [`listener_pids`] — who is LISTENING on a TCP port +//! (`GetExtendedTcpTable`, iphlpapi). Microseconds, no process spawn. +//! Matches `netstat -ano -p tcp` semantics for the IPv4 listener case +//! (the DSH web server binds 127.0.0.1) while ignoring the many +//! ESTABLISHED rows that share the same local port. +//! * [`kill_tree`] — kill a process and its descendants natively +//! (Toolhelp32 snapshot + `TerminateProcess`), replacing `taskkill /T`. +//! * [`wait_exit`] — block on a process handle until it exits +//! (`OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject`), so the restart +//! chain proceeds the instant the kernel reaps the old backend instead +//! of polling the port table on a timer. + +#[cfg(windows)] +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Windows implementation +// --------------------------------------------------------------------------- + +/// PIDs currently LISTENING on `port` (IPv4, TCP). Empty when nothing +/// listens. Never spawns a helper process. +#[cfg(windows)] +pub(crate) fn listener_pids(port: u16) -> Vec { + // AF_INET = 2, TCP_TABLE_OWNER_PID_LISTENER = 5. + const AF_INET: u32 = 2; + const TCP_TABLE_OWNER_PID_LISTENER: u32 = 5; + const ERROR_INSUFFICIENT_BUFFER: u32 = 122; + const NO_ERROR: u32 = 0; + const MIB_LISTENING: u32 = 2; + + #[repr(C)] + struct MibTcpRowOwnerPid { + dw_state: u32, + dw_local_addr: u32, + // 16-bit port stored in network byte order. + dw_local_port: u32, + dw_remote_addr: u32, + dw_remote_port: u32, + dw_owning_pid: u32, + } + + #[link(name = "iphlpapi")] + extern "system" { + fn GetExtendedTcpTable( + p_tcp_table: *mut core::ffi::c_void, + pdw_size: *mut u32, + b_order: i32, + ul_af: u32, + table_class: u32, + reserved: u32, + ) -> u32; + } + + unsafe { + // Sizing call: the required size comes back in pdw_size. + let mut size: u32 = 0; + let rc = GetExtendedTcpTable( + core::ptr::null_mut(), + &mut size, + 0, + AF_INET, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ); + if size == 0 || (rc != NO_ERROR && rc != ERROR_INSUFFICIENT_BUFFER) { + // Empty table, or an unexpected failure — degrade to "no + // listeners"; the caller's readiness probe stays authoritative. + return Vec::new(); + } + + let mut buffer: Vec = vec![0u8; size as usize]; + let rc = GetExtendedTcpTable( + buffer.as_mut_ptr() as *mut core::ffi::c_void, + &mut size, + 0, + AF_INET, + TCP_TABLE_OWNER_PID_LISTENER, + 0, + ); + if rc != NO_ERROR { + return Vec::new(); + } + + // MIB_TCPTABLE_OWNER_PID = { DWORD dwNumEntries; MIB_TCPROW_OWNER_PID table[]; } + // The rows come from a byte buffer (1-byte aligned), so every read is + // unaligned-safe; the trailing-state filter also copes with table + // classes that return non-listener rows. + let base = buffer.as_ptr(); + let num_entries = (base as *const u32).read_unaligned(); + if num_entries == 0 { + return Vec::new(); + } + let rows = base.add(4) as *const MibTcpRowOwnerPid; + let row_len = std::mem::size_of::(); + // Guard against a truncated buffer: never read past what the API wrote. + let available = (size as usize).saturating_sub(4) / row_len; + let count = (num_entries as usize).min(available); + let wanted = port.to_be() as u32; + + let mut pids = Vec::new(); + for i in 0..count { + let row = core::ptr::read_unaligned(rows.add(i)); + if row.dw_state == MIB_LISTENING && row.dw_local_port == wanted { + if !pids.contains(&row.dw_owning_pid) { + pids.push(row.dw_owning_pid); + } + } + } + pids + } +} + +/// Kill `root_pid` and its whole descendant tree, deepest first, natively. +/// Safety guards: never the current process, never the idle/System pids. +/// Failures (access denied, already gone) are silent — the caller's port +/// poll is the final authority on whether the port actually came free. +#[cfg(windows)] +pub(crate) fn kill_tree(root_pid: u32) { + const TH32CS_SNAPPROCESS: u32 = 0x0000_0002; + const PROCESS_TERMINATE: u32 = 0x0000_0001; + const INVALID_HANDLE_VALUE: isize = -1; + + #[repr(C)] + struct ProcessEntry32W { + dw_size: u32, + cnt_usage: u32, + th32_process_id: u32, + th32_default_heap_id: usize, + th32_module_id: u32, + cnt_threads: u32, + th32_parent_process_id: u32, + pc_pri_class_base: i32, + dw_flags: u32, + sz_exe_file: [u16; 260], + } + + #[link(name = "kernel32")] + extern "system" { + fn CreateToolhelp32Snapshot(dw_flags: u32, th32_process_id: u32) -> *mut core::ffi::c_void; + fn Process32FirstW(h_snapshot: *mut core::ffi::c_void, lppe: *mut ProcessEntry32W) -> i32; + fn Process32NextW(h_snapshot: *mut core::ffi::c_void, lppe: *mut ProcessEntry32W) -> i32; + fn CloseHandle(h_object: *mut core::ffi::c_void) -> i32; + } + + if root_pid == 0 || root_pid == 4 || root_pid == std::process::id() { + return; + } + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot.is_null() || (snapshot as isize) == INVALID_HANDLE_VALUE { + // Fall back to terminating the root alone: better than nothing + // and matches the old taskkill-without-/T behavior on failure. + terminate_one(root_pid, PROCESS_TERMINATE); + return; + } + + // One consistent (pid -> ppid) table from the snapshot. + let mut table: Vec<(u32, u32)> = Vec::with_capacity(256); + let mut entry = std::mem::zeroed::(); + entry.dw_size = std::mem::size_of::() as u32; + if Process32FirstW(snapshot, &mut entry) != 0 { + loop { + table.push((entry.th32_process_id, entry.th32_parent_process_id)); + if Process32NextW(snapshot, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snapshot); + + // Children map for a BFS from the root. + let mut ordered: Vec = vec![root_pid]; + let mut i = 0; + while i < ordered.len() { + let parent = ordered[i]; + for (pid, ppid) in &table { + if *ppid == parent && *pid != std::process::id() && !ordered.contains(pid) { + ordered.push(*pid); + } + } + i += 1; + } + + // Terminate deepest first so children die before the (potentially + // respawning) parent; the snapshot list is a fixed point. + for pid in ordered.iter().rev() { + if *pid == std::process::id() || *pid <= 4 { + continue; + } + terminate_one(*pid, PROCESS_TERMINATE); + } + } +} + +/// Open + terminate one process; silent on any failure. +#[cfg(windows)] +unsafe fn terminate_one(pid: u32, access: u32) { + #[link(name = "kernel32")] + extern "system" { + fn OpenProcess( + dw_desired_access: u32, + b_inherit_handle: i32, + dw_process_id: u32, + ) -> *mut core::ffi::c_void; + fn TerminateProcess(h_process: *mut core::ffi::c_void, u_exit_code: u32) -> i32; + fn CloseHandle(h_object: *mut core::ffi::c_void) -> i32; + } + if pid == 0 || pid == 4 || pid == std::process::id() { + return; + } + let handle = OpenProcess(access, 0, pid); + if handle.is_null() { + return; // already gone, or access denied + } + let _ = TerminateProcess(handle, 1); + CloseHandle(handle); +} + +/// Block until `pid` exits or `budget` elapses. Returns true when the +/// process is gone. An `OpenProcess` failure means it is already reaped. +#[cfg(windows)] +pub(crate) fn wait_exit(pid: u32, budget: Duration) -> bool { + const SYNCHRONIZE: u32 = 0x0010_0000; + const WAIT_OBJECT_0: u32 = 0; + + #[link(name = "kernel32")] + extern "system" { + fn OpenProcess( + dw_desired_access: u32, + b_inherit_handle: i32, + dw_process_id: u32, + ) -> *mut core::ffi::c_void; + fn WaitForSingleObject(h_handle: *mut core::ffi::c_void, dw_milliseconds: u32) -> u32; + fn CloseHandle(h_object: *mut core::ffi::c_void) -> i32; + } + + if pid == 0 || pid == 4 || pid == std::process::id() { + return true; + } + + unsafe { + let handle = OpenProcess(SYNCHRONIZE, 0, pid); + if handle.is_null() { + // Already exited and reaped — that is the state we wanted. + return true; + } + let remaining = budget.as_millis().min(u32::MAX as u128) as u32; + let rc = WaitForSingleObject(handle, remaining); + CloseHandle(handle); + rc == WAIT_OBJECT_0 + } +} + +// --------------------------------------------------------------------------- +// Non-Windows fallbacks: the previous spawn-and-parse implementations. +// The shipped shell is Windows-only; these keep the crate compiling +// cross-platform (and CI honest about cfg-gating). +// --------------------------------------------------------------------------- + +/// Listener PID on `port` via `netstat` (non-Windows fallback). +#[cfg(not(windows))] +pub(crate) fn listener_pids(port: u16) -> Vec { + let mut command = std::process::Command::new("netstat"); + command.args(["-ano", "-p", "tcp"]); + let Ok(output) = command.output() else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + let suffix = format!(":{port}"); + let mut pids = Vec::new(); + for line in text.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() >= 5 + && fields[0] == "TCP" + && fields[1].ends_with(&suffix) + && fields[4] != "0" + { + if let Ok(pid) = fields[4].parse::() { + if !pids.contains(&pid) { + pids.push(pid); + } + } + } + } + pids +} + +/// Tree kill via `taskkill /T /F` (non-Windows fallback). +#[cfg(not(windows))] +pub(crate) fn kill_tree(pid: u32) { + let mut command = std::process::Command::new("taskkill"); + command.args(["/PID", &pid.to_string(), "/T", "/F"]); + let _ = command.status(); +} + +/// Best-effort exit wait via a short poll (non-Windows fallback). +#[cfg(not(windows))] +pub(crate) fn wait_exit(pid: u32, budget: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + budget; + while std::time::Instant::now() < deadline { + let alive = std::process::Command::new("ps") + .args(["-p", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !alive { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + false +}