时间戳指控
构建机上发现 12 个卡死的 node.exe 进程。看一下 PID:
| PID | 启动时间 | 间隔 |
|---|---|---|
| 17312 | 21:30:53 | — |
| 18456 | 21:31:01 | 8s |
| 19340 | 21:31:08 | 7s |
| ... | ... | 1~3s |
| 27140 | 21:33:11 | 1s |
12 个进程连续分布、间隔 1~3 秒。每一个都对应用户点一次"立即更新"。
用户机器 A 的 modal 卡在屏幕上显示"升级成功,面板将自动重启"。但 version.json 仍是 0.14.15,modal 本身还活着。三个信号同时出现(modal 活跃、版本号未变、UI 说成功)——按设计这不可能发生。如果真成功了,panel 该已退出。
现象与诊断
0.14.13~0.14.16 版本的用户在弹窗中点"立即更新"后,看到"升级成功,面板将自动重启"的提示,但 panel 进程并未退出。launcher worker 被设计为必须等待 panel 完全退出才能进入 Phase 2(文件替换阶段);由于 panel 苟活,worker 永远卡住。用户看弹窗几秒没反应,以为系统出问题,再点一次"立即更新"。又 spawn 了一个新的 worker。反复几次,队伍长到 12 个。
构建机上的现场证据非常直观:12 个名为 node.exe 的卡死进程,PID 连续分布在 1731227140 之间,启动时间从 21:30:53 到 21:33:11,时间间隔正好 13 秒。这些时间点与用户每次点击"立即更新"的间隔一致,说明每次点击都精确地 spawn 了一个新 worker。
为什么 panel 不退出
OTA 设计要求 panel 在 ready_to_apply 阶段调用 requestSelfExit() 或 app.exit(0) 杀死自己。launcher worker 等待这个退出,然后进入 Phase 2。
modal 说"升级成功"意味着下载没失败。问题是:panel 为什么还活着?
前端代码 ota-force-modal.js 的 onFinished 回调(第 204~222 行):
ota.onFinished(payload => {
if (payload && payload.outcome === 'succeeded') {
msgEl.textContent = '升级成功,面板将自动重启';
setTimeout(() => window.location.reload(), 1200);
}
...
})
window.location.reload() 是页面刷新,不是进程退出。这是致命的意图偏差。前端代码作者可能认为"重启面板"等于"刷新页面"。但 OS 层面,刷新页面 ≠ 杀进程。
reload() 执行时,panel 进程活得好好的。modal 还在显示。state.json 的 phase 没改到 done。launcher worker 永远卡在"等待进程退出"的系统调用里,永远进不了 Phase 2。
OTA 整体设计流程是:
- Panel 在
ready_to_apply阶段调用requestSelfExit()→app.exit(0)→ panel 进程退出 - Launcher worker 通过
OpenProcess wait或类似机制检测到 panel 已退出 → 进入 Phase 2 安装阶段 - Phase 2 完成后,spawn 新的 launcher → 新 launcher 启动新 panel
如果 step 1 没有真正退出,整个链路就断掉了。
更深的问题
直接原因是前端用了错误的操作。但背后有更深的设计脆弱点。
onFinished 被设计为异步事件回调,在 OTA 下载完成后由 Rust 后端触发。理想情况下,它应该到达刚刚完成下载的那个 panel 实例,告诉它"去退出吧,新版本已经准备好了"。但如果 panel 的 ready_to_apply 阶段因为某些原因(Tauri 运行时被 prevent_exit 阻断、tokio 任务阻塞、异步调度延迟)没有成功退出,那么 onFinished 回调实际上会被同一个老 panel 实例再次接收。
这时候前端没有能力区分"这是针对我的通知还是针对下一个 panel 的通知"——它只是机械地执行了 reload(),结果是:老 panel 的页面刷了一次,但进程照样活着;state.json 里的 selfExitRequested 标志被设置为 true,panel 主循环再也不会尝试重新进入 OTA 流程;modal 卡死在那里,等待一个永远不会来的进程退出。
用户看着 modal 卡了几秒钟没反应,以为系统出了问题,再点一下"立即更新"按钮。这一次,launcher 从头开始 OTA 流程,ota.startDownload() 再跑一遍,又 spawn 了一个新的 worker。老 worker 还在卡着,新 worker 又加入排队。每次用户重复点击,队伍就长一个。
ready_to_apply 路径的代码(第 143~149 行)也存在 UX 问题:1500 毫秒的 setTimeout 后直接调 requestSelfExit(),中间没有任何倒计时或视觉反馈。用户不知道系统在做什么,可能在这 1.5 秒的空窗期内通过其他途径(比如 VBS 脚本)重新拉起 launcher,造成更糟的并发状态。
修复策略
修复包括两个层面。
第一层:前端必须调进程退出命令而非页面刷新
onFinished 回调里,不管是 succeeded 还是 no-outcome 的兼容路径,都改为调用 exitApp():
ota.onFinished(payload => {
if (payload && payload.outcome === 'succeeded') {
msgEl.textContent = '升级成功,面板将自动重启';
setTimeout(() => exitApp(), 1200);
}
// 或 payload 为 undefined 时
exitApp();
})
exitApp() 走的是 invoke('plugin:process|exit', {code: 0}) → Tauri 运行时 → 强制退出进程。这个调用几乎不会失败——即便前端 WebView 处于任意状态,Tauri runtime 都能保证进程被杀掉。
为什么选这个方案而不是等待 requestSelfExit() 的结果?关键在于信号的清晰性。onFinished 这个事件本身就表示"OTA 流程已完毕,不管什么结果,panel 现在应该滚开"。老 panel 收到这个消息时,正确的状态是它早就该死了——如果还活着,说明 self_exit 链路出了问题,最稳妥的做法是再死一次,而不是等待一个可能永远不会成功的后端调用。
第二层:前端 UX + 防重入
ready_to_apply 阶段改为 3 秒倒计时,同时禁用所有交互按钮。这不仅是 UX 反馈,更重要的是给用户一个明确的预期:"现在不要再点",以及防止多个并发的退出请求造成的竞态条件:
if (phase === 'ready_to_apply' && !selfExitRequested) {
selfExitRequested = true;
updateBtn.disabled = true;
quitBtn.disabled = true;
let countdown = 3;
msgEl.textContent = `面板将在 ${countdown} 秒后自动关闭并重启,请勿操作...`;
const ticker = setInterval(() => {
countdown -= 1;
if (countdown <= 0) {
clearInterval(ticker);
msgEl.textContent = '面板正在关闭...';
ota.requestSelfExit().catch(() => exitApp());
} else {
msgEl.textContent = `面板将在 ${countdown} 秒后自动关闭并重启,请勿操作...`;
}
}, 1000);
}
倒计时的目的不仅是 UX 反馈,更重要的是给用户一个明确的预期:"你现在点了按钮,系统要在 3 秒内关闭,在这期间不要重复点击"。禁用按钮则是硬性防止。兜底的 requestSelfExit().catch(() => exitApp()) 处理的是"后端自杀失败就用前端硬杀"的极端情况。
防止 worker 堆积本身需要 launcher 端的支持,但那超出这篇的范围(属于 worker 并发管理,是独立的改进项)。这篇修复的是"单个 panel 必须可靠退出"这个前置条件。
防回归
测试(ota-force-modal.test.js)检验两个场景:
outcome=succeeded时触发exitApp()而非reload()→ 断言plugin:process|exit被 invokeoutcome缺失时也调exitApp()→ 同上
实机验证:0.14.17 版本 hot-deploy 到构建机和用户 A 的 USB。当 USB 版本等于当前版本时不弹 modal;后续任何 force-update OTA 都能完整跑完,panel 正常退出,worker 正常进 Phase 2。
已堆积的卡死 worker 需手工清:
Stop-Process -Name node,WakouPanel,launcher -Force
下次正常启动后系统会恢复。Phase 2 已写入缓存的 binary 是有效的(worker 是在 ready_to_apply 之后才卡死的),所以缓存里的 panel/launcher 实际上已经是新版本,只是没替换到 USB 而已。这个清理步骤是必要的,因为卡死的 worker 进程会阻止 launcher 继续执行,导致系统始终无法进入正常的 panel 运行状态。
测试(ota-force-modal.test.js):
- 用例:"outcome=succeeded 时触发 exitApp 而非 reload" → 断言
plugin:process|exit被 invoke - 用例:"无 outcome 时触发 exitApp" → 同上
实机验证: 0.14.17 版本 hot-deploy 到构建机和用户 A 的 USB。当 USB 版本等于当前版本时,不弹 modal;后续任何 force-update OTA 都能跑完完整的链路,panel 正常退出,worker 正常进入 Phase 2。下一个 launcher 启动时,panel 被新版本替换,用户进入正常的应用界面,不再看到循环弹窗。
预防规则:
- 任何"panel 自杀让 launcher 接管"的代码路径都必须走
exitApp()或 Rust 侧的app.exit(0),禁止window.location.reload()。这个规则应该在代码审查和静态检查中强制。 - 倒计时与进度反馈是这种自杀类操作的强制 UX 标准,不让用户处于"什么都没发生"的盲区。
- 后续任何 OTA 功能扩展(比如 hot-reload、partial update)都必须在 panel 端有明确的进程退出路径,不能借 reload 暗渡陈仓。
链路脆弱点
这个故障与另一个 OTA 故障(launcher 读 USB 版本号导致无限弹 modal)是独立根因——那是 launcher 端问题,这是前端问题。同一用户身上可能两个同时出现,但修复的层级完全不同。这次修复对了"panel 退出"这一层,但还需要 launcher 侧的 worker 并发管理来完全避免堆积。比起"下一个 panel 退不出来怎么办",更脆弱的其实是"已经有一个 worker 在跑,用户又点更新该怎么办"——这个隐式契约("等待外部进程退出")在整个链路里是最松散的一环。
■