mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 实测故障 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(),纯字符串匹配命中注释而非代码
192 lines
8.1 KiB
JavaScript
192 lines
8.1 KiB
JavaScript
// 窗口/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("全部通过"); |