Electron 桌面端问题排查记录
记录 Electron 桌面端在守护、卡死检测、上线监控上踩过的坑,每个问题都是实际遇到过并解决掉的。
问题一:主进程崩溃后,自动拉起逻辑没生效
现象:主进程崩溃应用直接退出,写在主进程里的 app.relaunch() 没有执行。
原因:主进程是所有子进程的父进程,它一崩全家灭。app.relaunch() 和 app.exit() 之间的进程已经死透,没有人替你执行拉起。开机自启只是"死了下次开机才起来",自启 ≠ 守护。
解决:守护逻辑必须放在主进程之外:
- renderer 崩溃:main 监听
render-process-gone捕获并重建窗口(main 还活着,这层能自愈) - main 崩溃:只能靠 OS 层拉起——Windows 用看门狗 exe,macOS 用登录项 + launchd KeepAlive,Linux 用用户级 systemd
// renderer 自愈:main 捕获重建
app.on('render-process-gone', (_e, wc, details) => {
log.error('render gone', details.reason) // crashed / oom / killed
wc.reload() // 重建失败再走销毁重开窗口
})
延伸:utilityProcess.fork 也救不了 main——它同样是主进程的孩子。
问题二:双击图标又开了第二个实例
现象:应用已常驻托盘,用户双击桌面图标又启动了一个新实例。
原因:没做单实例锁,或者锁做了但没处理 second-instance 事件里的"唤起意图"。
解决:
if (!app.requestSingleInstanceLock()) {
app.quit() // 拿不到锁 = 已有实例在跑
} else {
app.on('second-instance', (_e, argv) => {
const win = getMainWindow()
if (win.isMinimized()) win.restore()
if (argv.includes('--hidden')) return // 登录项静默拉起,不抢焦点
win.show()
win.focus()
})
}
延伸:
- 判断"本次是不是登录项拉起":macOS 看
app.getLoginItemSettings().wasOpenedAtLogin,Windows 靠注册表 Run 键里写入的--hidden参数比对process.argv - 登录静默启动用
setLoginItemSettings({ openAtLogin: true, args: ['--hidden'] }),macOS 加openAsHidden: true - macOS 无 Dock 常驻用
app.setActivationPolicy('accessory'),注意它会带来"唤不到前台"的连锁坑,唤起时要切回regular
问题三:窗口最小化后心跳大量误报
现象:窗口最小化 5 分钟,监控平台收到一批"页面卡死"告警,实际进程活得好好的。
原因:Chromium 对隐藏页面有定时器节流(backgroundThrottling 默认开):隐藏页定时器被压到约 1 次/分钟,requestAnimationFrame 直接停。放在 renderer 的心跳必然断流。
解决:心跳/保活放 main 进程(不受渲染页节流影响),renderer 只被动响应探活;同时 webContents 的 unresponsive/responsive 事件只做辅助参考(跨平台触发时机不一致)。不全局关节流——那是以电量换心跳。
问题四:systemd/pm2 拉起后进程在,窗口不显示
现象:用 systemd 或 NSSM 守护 Electron,重启后进程存在但窗口死活不出来。
原因:Electron 是依赖 GUI 会话的复合进程组,不是普通 Node 进程。Windows 服务会话没有交互式 WindowStation;Linux 容器缺 DISPLAY 且 Chromium 沙箱依赖 user namespace。
解决:桌面端用"登录项 / 用户级 launchd KeepAlive / Squirrel 更新器 / 自研看门狗"守护,不用服务管理器。CI 容器里跑冒烟测试加 --no-sandbox,这个参数绝不带到生产。
问题五:"卡死 3 秒"阈值拍脑袋,误报率失控
现象:卡死检测阈值设 3 秒,后台窗口疯狂误报;调到 15 秒,用户早把应用杀了自己才收到告警。
原因:把"忙"(主线程被占,最终能恢复)和"死"(进程 crash/OOM/被回收)混为一谈,且阈值没和窗口可见性绑定。
解决:
- 两类分开监控:卡顿用
PerformanceObserver('longtask')采样(Long Task > 50ms 即记录),无响应用心跳/unresponsive检测 - 阈值绑定可见性:前台窗口 5s 判无响应,后台窗口降为分钟级
- 恢复动作分级:先等
responsive→webContents.reload()→ 最后才强制杀渲染进程
问题六:主线程死循环时,心跳自己先发不出去了
现象:renderer 主线程死循环 10 秒,setInterval 心跳第 3 秒就断。主进程分不清"页面死了"还是"页面忙到没空理你",恢复后还把它误 reload 了。
原因:用被测线程自己发心跳,测不出它自己卡死。IPC 消息也要主线程处理,main 发的探活同样在排队。
解决:Web Worker 发 ping、主线程回 pong、Worker 侧量往返延迟。Worker 是独立线程,主线程死循环它照样能计时:
// worker.js:主线程忙 → RTT 拉长;真死 → pong 永远不回
let lastPong = Date.now()
setInterval(() => {
const rtt = Date.now() - lastPong
port.postMessage('ping')
}, 1000)
port.onmessage = () => { lastPong = Date.now() }
判死语义分两档:RTT 拉长 = 忙碌中(不判死,只上报性能事件);RTT 超过硬超时或进程被回收 = 失联(触发恢复)。
问题七:unresponsive 回调里弹同步对话框,整 App 假死
现象:渲染进程 unresponsive 后弹"是否重载",Linux 上对话框点不到,应用整个卡死,自愈路径也堵死了。
原因:dialog.showMessageBoxSync 会起嵌套事件循环把 main 的事件循环占住;且 unresponsive 在持续无响应时反复触发,对话框叠对话框。
解决:主进程回调里绝不放同步阻塞操作。用异步 showMessageBox + 重入闸门(同一时刻只允许一个恢复流程):
let recovering = false
win.webContents.on('unresponsive', async () => {
if (recovering) return
recovering = true
try {
const { response } = await dialog.showMessageBox(win, { /* ... */ })
if (response === 0) win.webContents.forcefullyCrashRenderer()
} finally {
recovering = false
}
})
问题八:RSS 一直涨,Heap Snapshot 却找不到大对象
现象:窗口内存线性上涨,DevTools 堆快照比对没有明显泄漏点。
原因:把 rss 当 JS 堆大小了。Electron renderer 的内存大头经常在 V8 之外:GPU/Canvas/WebGL backing store、DOM 与 Blink 对象、图片解码缓存、Code cache、"GC 了但没归还 OS"的堆。RSS 涨不等于泄漏。
解决:
- 指标分开看:
process.getProcessMemoryInfo()的heapUsed / external / rss / workingSetSize,必要时加--enable-precise-memory-info - 多窗口各算各的,GPU 进程单独看;"关窗重开 RSS 降回去"是缓存正常回收,不是泄漏
- 泄漏排查别只盯 JS:native 模块、离屏 Canvas、事件监听器累积、被定时器/performance 条目引用的闭包
问题九:"崩溃率 0.01%" 的口径是错的
现象:监控平台显示崩溃率 0.01%,用户反馈却频繁闪退。
原因:把"JS 异常数 / 会话数"当崩溃率上报了。JS Error(业务代码抛错)、render-process-gone(渲染进程被杀)、Native Crash(minidump)是三条完全不同的通道。
解决:
- 崩溃率口径统一为 crash-free session / crash-free users,只统计 native crash + render-process-gone,JS fatal 单列
crashReporter.start()必须在 main 最早期调用;symbol(Windows PDB / macOS dSYM)随发布流水线上传,否则 minidump 没人看得懂- renderer 的
onerror/unhandledrejection通过 preload 桥统一传到 main 出口上报
问题十:用户退出 App,队列里 200 条埋点丢了
现象:退出瞬间未上报的事件全部丢失,漏斗数据系统性偏低。
原因:will-quit 里发网络请求来不及完成,同步阻塞又会卡退出。
解决:离线优先——事件先落本地(文件/SQLite),后台批量上传,退出时只做磁盘 flush,下次启动续传;事件带 uuid 服务端去重,接受强杀丢最后几秒并量化这个误差:
app.on('will-quit', () => queue.flushToDisk()) // 只落盘,不发网络请求
问题十一:5 条产品线接监控,每个都 fork 改一遍
现象:公司 5 个 Electron 项目都要崩溃/性能/埋点,复制粘贴维护不过来。
原因:没有把"每家都不一样的东西"(上报地址、采样率、开关、schema)和"大家都一样的东西"(队列、重试、去重)分层。
解决:SDK 分三层——Core(采集通道、本地队列、批量上报、重试去重)+ 插件(错误/生命周期/性能/埋点)+ 项目配置(上报地址/token/采样率/远程开关)。关键设计:上报永不阻塞业务、配置可远程下发(紧急关采集、灰度放量)、requestId 贯穿主/渲染两进程串起一次操作。另外 SDK 自身采集代码全部 try/catch,它的崩溃率单独监控——不能让监控拖垮宿主。
问题十二:新版本 crash-free 掉了,回滚后还在涨
现象:灰度发版后 crash-free 从 99.9% 掉到 97%,回滚版本崩溃率没降反升。
原因:只看了监控面板的"版本号",没验证线上真实运行版本——更新器可能把坏版本缓存后继续下发,或旧版本被强制升级。
解决:
- autoUpdater 全链路埋点(checking / update-available / 下载失败 / 安装失败),更新挂了是桌面端最容易被忽略的隐形故障
- 客户端每次上报携带
app.getVersion()的真实值,服务端按实际值而非预期版本聚合 - 新旧版本对比时排除"新用户 vs 老用户"的幸存者偏差,灰度比例与崩溃对比同步看
