ATRULE 技术博客ATRULE 技术博客
首页
博客
文档
关于
首页
博客
文档
关于
  • 技术文章

    • VuePress 2.x 完全指南
    • Gemini3 & GPT5 & DeepSeek3:AI时代程序员的身份转变
    • Agent 架构选型指南:单智能体 vs 多智能体
    • Agent 工程师转型学习路线图 (Full-Stack to Agent Engineer)
    • Agent 工程师学习笔记
    • 📖 Agent 系统架构与工程实践面试速记卡片
    • Agent 流式对话前端踩坑记录
    • RAG 检索工程问题记录
    • Agent 后端问题记录(Koa / MongoDB)
    • LLM 调用与 Agent 编排问题记录
    • Electron 桌面端问题排查记录
    • AI Agent 全栈开发问题排查记录
    • ES2026 与 TypeScript 进阶问题记录
    • 浏览器渲染与 V8 内存问题记录
    • React 19 与 Vue 4 新内核问题记录
    • 前端工程化与微前端问题记录
    • 性能、安全与可观测性问题记录
    • 前端架构实战问题记录
  • 项目实战

    • Node.js + Koa 抖音直播弹幕Agent 五大核心模块(面试项目完整版,TS技术栈)★★★★★
    • Poiclaw 项目蓝图:自主编程实体
    • AI旅行助手

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 老用户"的幸存者偏差,灰度比例与崩溃对比同步看
最后更新: 2026/9/5 17:19
Prev
LLM 调用与 Agent 编排问题记录
Next
AI Agent 全栈开发问题排查记录