fix(gui): 修「长时间挂后台后打开白屏」—— 补 renderer/GPU 崩溃自愈

## 实测故障

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(),纯字符串匹配命中注释而非代码
This commit is contained in:
JianFeeeee
2026-10-05 21:56:14 +08:00
parent 878fd770bc
commit 7bd569eccd
2 changed files with 286 additions and 11 deletions

View File

@ -442,16 +442,60 @@ function createWindow(show = true) {
}); });
// 静默时不显示;非静默时在首帧就绪后显示,避免白屏闪现。 // 静默时不显示;非静默时在首帧就绪后显示,避免白屏闪现。
if (show) { //
mainWindow.once("ready-to-show", () => { // ★ 用 on 而不是 once(2026-10-05 修白屏):once 只等一次,首帧之后
try { // 监听就没了。而窗口可能被系统回收后重建、或GPU 重启后重新 load,
// 那时都需要再show 一次 —— once 的写法让那些路径静默失败。
mainWindow.on("ready-to-show", () => {
if (!show) return;
try {
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
mainWindow.show(); mainWindow.show();
} catch (e) {} }
}); } catch (e) {}
} });
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html")); 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")) { if (process.argv.includes("--dev")) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
@ -3377,9 +3421,30 @@ function initTray() {
function showMainWindow() { function showMainWindow() {
try { try {
if (mainWindow && !mainWindow.isDestroyed()) { 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(); if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus(); try {
mainWindow.focus();
} catch (e) {}
return; return;
} }
} catch (e) { } catch (e) {
@ -3508,6 +3573,25 @@ app.on("activate", () => {
createWindow(); 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 原生)============ // ============ 系统通知(Electron 原生)============
// //
// 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、 // 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、
@ -3540,10 +3624,9 @@ ipcMain.handle("notify:show", (_, payload) => {
urgency: p.urgent ? "critical" : "normal", urgency: p.urgent ? "critical" : "normal",
}); });
n.on("click", () => { n.on("click", () => {
if (mainWindow && !mainWindow.isDestroyed()) { // 走 showMainWindow 而非直接 show:它带renderer 健康检查,
if (!mainWindow.isVisible()) mainWindow.show(); // 直接 show 在 renderer 已被回收时会开出一个白窗口。
mainWindow.focus(); showMainWindow();
}
try { try {
mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" }); mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" });
} catch (e) {} } catch (e) {}

View File

@ -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("全部通过");