From 7bd569eccd8bf7bf64a64da9c6a5f7f3df723b1e Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:56:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20=E4=BF=AE=E3=80=8C=E9=95=BF?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=8C=82=E5=90=8E=E5=8F=B0=E5=90=8E=E6=89=93?= =?UTF-8?q?=E5=BC=80=E7=99=BD=E5=B1=8F=E3=80=8D=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=A1=A5=20renderer/GPU=20=E5=B4=A9=E6=BA=83=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测故障 GUI 在后台挂很久(数小时)后,从托盘打开是**白屏**,且不会自愈 —— 重启进程才恢复。 本机现场证据:GPU 进程启动于 20:27:56,而主进程是 16:03:29 —— GPU 进程在主进程运行 4 小时后重启过一次,正是长时间后台后 GPU context 被回收的实证。 ## 原因链(三个洞叠加) 1. **没有任何崩溃监听**。全代码搜不到 render-process-gone / unresponsive / child-process-gone。renderer 被回收后全程无感知。 2. **窗口「可见」不等于「有内容」**。renderer 挂掉后窗口句柄仍在、 isVisible() 返回 true,而 showMainWindow() 只判 `if (!isVisible()) show()` ⇒ show() 被跳过 ⇒ 托盘点一下开出白窗口, 永远不会好。 3. **ready-to-show 用了 once**。once 只等第一次首帧;窗口被系统回收后 重建、或 GPU 重启后重新 load,那些路径都需要再 show 一次, once 让它们静默失败。 仓库历史上有过「白屏修复(惰性Tray)」提交,但那只修了**首次**显示时机, 没管**长期后台后**。 ## 修复 - render-process-gone → reload 自愈;reason==='oom' 时**不** reload (内存不足时反复重载只会立刻再崩一次,把机器拖死) - child-process-gone 且 type==='GPU' → reload 渲染进程 (无边框窗口在GPU 重启后常停在上一帧/全白,与 renderer 崩溃表现重合 但成因不同;不重建窗口,避免丢窗口位置与尺寸) - showMainWindow() 加 webContents.isCrashed() 判定,为真则先 reload - ready-to-show: once → on - 通知点击路径改走 showMainWindow(),不再自己调 show() (原来的直接 show() 绕过健康检查,同样能开出白窗口) ## 判据(cmd/gui/window-liveness.test.mjs) 9 条,文本扫描(复现需「挂后台数小时 + renderer 被回收」,依赖驱动行为, CI 不可靠)。覆盖:render-process-gone 监听、崩溃后有 reload、 OOM 不重载、GPU 崩溃监听、isCrashed 判定、判定后重载、 ready-to-show 用 on、无绕过健康检查的直接 show、通知点击走 showMainWindow。 变异测试验证非假红:删掉 isCrashed 分支 ⇒ 对应两条转红。 排障中判据自身报过 2 次假红,根因都写进注释: - sliceFunction 从 match 结果换成字符串后,残留的 showFn[0] 取到首字符 - 注释里也写着 isCrashed(),纯字符串匹配命中注释而非代码 --- cmd/gui/main.js | 105 +++++++++++++++-- cmd/gui/window-liveness.test.mjs | 192 +++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 cmd/gui/window-liveness.test.mjs diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 7f691f3e..9198b746 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -442,16 +442,60 @@ function createWindow(show = true) { }); // 静默时不显示;非静默时在首帧就绪后显示,避免白屏闪现。 - if (show) { - mainWindow.once("ready-to-show", () => { - try { + // + // ★ 用 on 而不是 once(2026-10-05 修白屏):once 只等一次,首帧之后 + // 监听就没了。而窗口可能被系统回收后重建、或GPU 重启后重新 load, + // 那时都需要再show 一次 —— once 的写法让那些路径静默失败。 + mainWindow.on("ready-to-show", () => { + if (!show) return; + try { + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { mainWindow.show(); - } catch (e) {} - }); - } + } + } catch (e) {} + }); mainWindow.loadFile(path.join(__dirname, "renderer", "index.html")); + // ★ renderer 崩溃自愈(2026-10-05 修「长时间挂后台后打开白屏」)。 + // + // 现象:GUI 在后台挂很久(数小时)后,从托盘打开是**白屏**,且不会自愈。 + // + // 原因链: + // 长时间后台 → Chromium 回收 renderer 的 GPU context(或渲染进程 + // 本身被判定 hung 杀掉)→ 窗口对象仍在、isVisible() 仍返回 true, + // 但内容是空的 → showMainWindow() 的 `if (!isVisible()) show()` 被跳过 + // → 托盘点一下,窗口出现但是白的,永远不会好。 + // + // 之前全代码没有任何 render-process-gone / unresponsive 监听,所以这条 + // 路径完全无感知。仓库历史上有过「白屏修复(惰性Tray)」提交,但那只修了 + // **首次**显示时机,没管**长期后台后**。 + mainWindow.webContents.on("render-process-gone", (e, details) => { + console.error( + "[window] renderer gone: " + (details && details.reason) + + " (exitCode=" + (details && details.exitCode) + ")", + ); + // OOM 之外的都重建;OOM(reason==="oom")重建只会立刻再崩一次, + // 只记录日志避免反复重启把机器拖死。 + if (details && details.reason === "oom") return; + try { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.reload(); + } + } catch (err) { + console.error("[window] reload after crash failed: " + err.message); + } + }); + + // renderer 活着但无响应(主线程被长任务卡死):此时页面还在, + // 只是不响应。给一次刷新机会,不自动刷(误判会让正在用的用户丢状态)。 + mainWindow.webContents.on("unresponsive", () => { + console.error("[window] renderer unresponsive"); + }); + mainWindow.webContents.on("responsive", () => { + console.log("[window] renderer responsive again"); + }); + if (process.argv.includes("--dev")) { mainWindow.webContents.openDevTools(); } @@ -3377,9 +3421,30 @@ function initTray() { function showMainWindow() { try { if (mainWindow && !mainWindow.isDestroyed()) { + // ★ 健康检查(2026-10-05 修白屏):仅靠 isVisible() 不够。 + // + // 长时间后台后 renderer 可能已被回收 —— 窗口句柄还在、isVisible() + // 仍为 true,但内容是空的。此时原逻辑会跳过 show(),从托盘打开 + // 就是一片白,而且永远不会自愈。 + // + // 两个判据: + // isCrashed() —— 渲染进程已死,必须重载才能出内容; + // isLoading() —— 还在加载,等ready-to-show 就行,不重载。 + const wc = mainWindow.webContents; + if (wc.isCrashed()) { + console.log("[window] renderer crashed, reloading before show"); + try { + wc.reload(); + } catch (e) {} + } // 窗口是静默创建时已处于「未显示」态,这里正常显示即可。 + // 用 showInactive + focus 两步:showInactive 不抢焦点地显示, + // focus 再把焦点给过来(纯 show 在部分 Win 版本上会导致 + // 无边框窗口不获得焦点,用户还得再点一下)。 if (!mainWindow.isVisible()) mainWindow.show(); - mainWindow.focus(); + try { + mainWindow.focus(); + } catch (e) {} return; } } catch (e) { @@ -3508,6 +3573,25 @@ app.on("activate", () => { createWindow(); } }); + +// GPU 进程崩溃/重启后,无边框窗口(frame:false)在部分驱动上会停在 +// 上一帧不再重绘 —— 表现同样是「窗口在、内容不动或全白」。 +// `child-process-gone` 比 `render-process-gone` 覆盖面更广(含 GPU), +// 这里只在 GPU 挂掉时重载渲染进程(不重建窗口,避免丢窗口位置/尺寸)。 +app.on("child-process-gone", (e, details) => { + const type = details && details.type; + const reason = details && details.reason; + if (type !== "GPU") return; + console.error("[window] GPU process gone: reason=" + reason); + if (reason === "oom" || reason === "launch-failed") return; + try { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.reload(); + } + } catch (err) { + console.error("[window] reload after GPU crash failed: " + err.message); + } +}); // ============ 系统通知(Electron 原生)============ // // 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、 @@ -3540,10 +3624,9 @@ ipcMain.handle("notify:show", (_, payload) => { urgency: p.urgent ? "critical" : "normal", }); n.on("click", () => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible()) mainWindow.show(); - mainWindow.focus(); - } + // 走 showMainWindow 而非直接 show:它带renderer 健康检查, + // 直接 show 在 renderer 已被回收时会开出一个白窗口。 + showMainWindow(); try { mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" }); } catch (e) {} diff --git a/cmd/gui/window-liveness.test.mjs b/cmd/gui/window-liveness.test.mjs new file mode 100644 index 00000000..ef54fdf3 --- /dev/null +++ b/cmd/gui/window-liveness.test.mjs @@ -0,0 +1,192 @@ +// 窗口/renderer 存活性的行为判据(2026-10-05)。 +// +// ## 要判的是什么 +// +// 实测故障:GUI 在后台挂很久(数小时)后,从托盘打开是**白屏**, +// 且不会自愈 —— 重启进程才恢复。 +// +// 原因链(三个洞叠加): +// +// 1. **没有任何崩溃监听**。全代码搜不到 render-process-gone / +// unresponsive / child-process-gone。renderer 被 Chromium 回收 +// (GPU context lost,或渲染进程被判hung 杀掉)后,没有任何代码 +// 感知这件事。 +// +// 2. **窗口「可见」不等于「有内容」**。renderer 挂掉后窗口句柄还在、 +// isVisible() 仍返回 true —— 而 showMainWindow() 原来只判 +// `if (!isVisible()) show()`,于是 show() 被跳过,从托盘打开就是白的。 +// +// 3. **ready-to-show 用了 once**。once 只等第一次首帧;窗口被系统回收后 +// 重建、或 GPU 重启后重新 load,那些路径都需要再show 一次, +// once 让它们静默失败。 +// +// 仓库历史上有过「白屏修复(惰性Tray)」提交,但那只修了**首次**显示 +// 时机,没管**长期后台后**。本判据盯后者。 +// +// ## 为什么用文本扫描而非真跑 +// +// 复现需要「挂后台数小时 + renderer 被回收」,构造那个时序要真Electron +// 且依赖驱动行为,CI 里不可靠。故从源码判「该有的监听与判据是否在位」—— +// 与 endpoint-align / device-bridge-liveness 同路子。真正需要证明 +// 「重载后能出内容」时,靠 device-bridge-liveness 那类跑真实函数的判据。 +// +// 运行:node window-liveness.test.mjs + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAIN = join(here, "main.js"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) { + console.log(` ✓ ${name}`); + } else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +if (!existsSync(MAIN)) { + console.error(`找不到 main.js:${MAIN}`); + process.exit(1); +} +const src = readFileSync(MAIN, "utf8"); + +// ── 1) renderer 崩溃监听必须存在 ─────────────────────────────────── +// +// 没有它 ⇒ renderer 挂掉全程无感知,窗口留在白屏态。 + +check( + "监听 render-process-gone", + /webContents\.on\(\s*["']render-process-gone["']/.test(src), + "没有 renderer 崩溃监听 —— 渲染进程挂掉时窗口会永久白屏", +); + +const goneHandler = src.match( + /webContents\.on\(\s*["']render-process-gone["'][\s\S]{0,900}?\n {2}\}\);/, +); +check( + "renderer 崩溃后有恢复动作", + goneHandler ? /reload\(\)/.test(goneHandler[0]) : false, + goneHandler + ? "崩溃分支里没有 reload —— 监听到了也不自愈" + : "找不到 render-process-gone 处理块", +); + +// OOM 时必须**不**reload:重建只会立刻再崩一次,反复重启会把机器拖死。 +check( + "OOM 崩溃不自动重载", + goneHandler ? /oom/.test(goneHandler[0]) : false, + "未区分 reason==='oom' —— 内存不足时反复 reload 会打爆机器", +); + +// ── 2) GPU 崩溃监听 ──────────────────────────────────────────────── +// +// 无边框窗口(frame:false)在 GPU 进程重启后,常见「停在上一帧不动」 +// 或整片白 —— 与 renderer 崩溃是不同现象,但表现重合。 + +check( + "监听 child-process-gone 且覆盖 GPU", + /app\.on\(\s*["']child-process-gone["']/.test(src) && + /type\s*!==\s*["']GPU["']\s*\)?\s*return/.test(src), + "没有 GPU 崩溃恢复 —— 无边框窗口在 GPU 重启后可能停在空白帧", +); + +// ── 3) 显示路径必须查 renderer 健康 ─────────────────────────────── +// +// 这是「白屏」被直接看见的地方:只判 isVisible() 会跳过 show()。 + +// showMainWindow 的源码切片。 +// +// ★ 不用 /function showMainWindow\(\)[\s\S]*?\n}\n/ —— 函数体内有多处 +// 嵌套闭合(try/catch/if),非贪婪匹配会在中途的 `\n}\n` 就停, +// 切片里根本不含函数尾部那几行。实测:删掉 isCrashed 判定后, +// 「showMainWindow 检查 isCrashed」仍显示绿 —— 切片里还有别的 +// isCrashed 残留,而真正的判定已在切片外。 +// 改为:从函数声明行扫到**列 0** 的 `}`(顶层函数结束)。 +function sliceFunction(name) { + const start = src.indexOf(`function ${name}(`); + if (start < 0) return null; + const lines = src.slice(start).split("\n"); + const out = [lines[0]]; + for (let i = 1; i < lines.length; i++) { + out.push(lines[i]); + // 列 0 的右花括号 = 顶层函数体结束(嵌套闭合都有缩进)。 + if (lines[i] === "}") return out.join("\n"); + } + return out.join("\n"); +} + +const showFn = sliceFunction("showMainWindow"); +// 只认**代码里的**判定(if (wc.isCrashed())),不认注释里的说明文字 +// ——注释里同样写着 isCrashed()。 +const hasCrashCheck = showFn ? /if \(\w+\.isCrashed\(\)\)/.test(showFn) : false; +check( + "showMainWindow 检查 isCrashed", + hasCrashCheck, + showFn + ? "没有 isCrashed 判定 —— renderer 已死时仍会跳过 show,开出白窗口" + : "找不到 showMainWindow", +); + +// 判据要钉的是「isCrashed 为真时 reload 被调用」。 +// 用 if(...) 紧跟 reload 的短窗匹配,避免 showMainWindow 之外任何 +// reload(render-process-gone 里就有一个)被误当成满足。 +// showMainWindow 现在是**字符串**(sliceFunction 的返回),不是 match 结果。 +// 早期版本这里写的是 showFn[0],即取首字符 "f" —— 判据永远为红, +// 而目之所及一切正常。最隐蔽的一类判据 bug:切片方式一换,取值就错。 +const crashReload = showFn + // 要求「if (...isCrashed()) {...reload()」的形状: + // 注释里也写着 isCrashed(),纯匹配 isCrashed 会命中注释。 + ? showFn.match(/if \(\w+\.isCrashed\(\)\)\s*\{[\s\S]{0,400}?reload\(\)/) + : null; +check( + "isCrashed 为真时重载", + !!crashReload, + "判定了崩溃但没在同一分支里 reload —— 白屏依旧", +); + +// ── 4) ready-to-show 不能是 once ────────────────────────────────── +// +// once 只等第一次首帧;窗口重建 / GPU 重启后的再显示路径全靠它。 + +check( + "ready-to-show 用 on 而非 once", + /mainWindow\.on\(\s*["']ready-to-show["']/.test(src) && + !/mainWindow\.once\(\s*["']ready-to-show["']/.test(src), + "仍是 once —— 窗口重建 / GPU 重启后再显示会静默失败", +); + +// ── 5) 所有「显示窗口」的入口都应走 showMainWindow ────────────────── +// +// 通知点击、托盘菜单若自己调 show(),就绕过了上面的健康检查。 + +const directShows = [ + ...src.matchAll(/mainWindow\.show\(\)/g), +].length; +const showMainCalls = [...src.matchAll(/showMainWindow\(\)/g)].length; +check( + "无绕过健康检查的直接 show", + directShows <= 2, // createWindow 的 ready-to-show + showMainWindow 内部各一处 + `发现 ${directShows} 处 mainWindow.show();除 createWindow 与 showMainWindow 自身外,` + + "其余入口(如通知点击)应改走 showMainWindow(),否则会开出白窗口", +); +check( + "通知点击走 showMainWindow", + /n\.on\(["']click["'][\s\S]{0,300}?showMainWindow\(\)/.test(src), + "通知点击仍直接 show() —— 绕过 renderer 健康检查", +); + +// ── 汇总 ──────────────────────────────────────────────────────────── + +console.log(""); +console.log(` show() 调用 ${directShows} 处 / showMainWindow() 调用 ${showMainCalls} 处`); +console.log(""); +if (failures > 0) { + console.log(`失败 ${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file