(三)Electron 壳的生命周期管理:单实例锁、空闲端口,以及把 sidecar 子进程"体面地送走"

承接系列前两篇。载荷装配好之后,Electron 壳的关键职责只剩一件事:管好 sidecar 这个子进程的整个生命周期——从只允许一个实例、到挑一个空闲端口把它拉起来、再到用户点关闭时让它把会话 flush 干净再退出、最后到它崩溃了还能自动原地复活。这些都是"用了才知道疼"的细节。

只允许一个实例

app.requestSingleInstanceLock() 在任何窗口创建之前调用,抢不到锁的直接退出;抢到锁的,通过 second-instance 事件把已开窗口唤醒(最小化则还原、再聚焦):

if (!app.requestSingleInstanceLock()) { app.quit(); return }
app.on('second-instance', () => {
  const w = mainWindow
  if (w === undefined) return
  if (w.isMinimized()) w.restore()
  w.focus()
})

挑一个空闲的回环端口

桌面版是"壳 + 本地服务"两进程,通信走 127.0.0.1。端口不能写死(会撞车),启动时 pickFreePort() 挑一个空闲的,再显式传给 sidecar:

dsh --profile web --host 127.0.0.1 --port <空闲端口>

为什么这里能"无代理直连"

一个容易忽略的点:应用自己的流量不该被系统代理拦走。机器级代理(受限网络常见)会夹在窗口和 sidecar 之间。所以启动时把 Electron 的会话代理显式设为直连:

await session.defaultSession.setProxy({ mode: 'direct' })

而就绪探测干脆不走 Chromium 网络栈——用纯 Node socket 轮询 POST /api/host.describe,这样探测本身也独立于任何系统代理。

把子进程"体面地送走":优雅退出

用户在 before-quit 里把 quitting 置位、abort 掉在途的等待,然后 preventDefault(),转而去先把 sidecar 停干净,再真正退出:

app.on('before-quit', (event) => {
  if (quitting) return
  quitting = true; quitSignal.abort(); event.preventDefault()
  void sidecar.stop({ gracefulTimeoutMs: 5000 })
    .finally(() => { sidecarLog?.end(); app.quit() })
})

关键是停子进程的方式因平台而异:

  • POSIX:先 SIGTERM 给它优雅收尾(把会话 flush 到 SQLite WAL),超时才 SIGKILL;
  • Windows:因为 Node 在 Windows 上把所有信号都映射成了强杀 TerminateProcess,根本没有"优雅信号"可用——所以改成整树 taskkill /T /F(连它派生的 shell 子进程一起端掉),崩溃一致性交给持久层(追加式会话日志)兜底。

崩溃自动重启:让"不可控"变"自愈"

sidecar 是另一个进程,理论上随时可能崩。壳接受的现实是:崩了就原地重启,而不是把错误甩给用户。策略:冷却 1 秒 → 同一端口重启 → 重载窗口(会话历史在磁盘上,视图可重建):

async function handleUnexpectedExit(code) {
  if (isQuitting()) return
  consecutiveStartFailures += 1
  if (consecutiveStartFailures > 3) { fatal('sidecar keeps crashing ...'); return }
  await delay(1000)
  if (isQuitting()) return
  await startSidecar()            // 同端口重启
  mainWindow?.webContents.reload() // 视图重建
}

连续 3 次仍起不来才弹错误框放弃——给用户一个明确的出口,而不是无限自愈掩盖问题。

别让"退出"被"在探"卡住

进程管理里最容易被忽略的时序 bug:启动中的探测还在 await,用户已经点了退出。解决办法是让退出信号贯穿整个启动链路:

  • 一个 AbortController(quitSignal)在程序启动时创建;
  • 启动/探测/重启的所有等待都传这个 signal;
  • before-quit 一上来就 quitSignal.abort(),在途等待立刻被取消,不会出现"退出卡在等一个永远不会来的探测"。

一个只有 Electron 才有的启动坑:ESM 顶层 await 死锁

最后是这个框架独有的、很容易让人摸不着头脑的一课:Electron 要等 ESM 主模块求值完才会发 ready 事件。如果你在模块顶层写 await app.whenReady(),就永远等不到 ready——模块死锁了事件本身。

解法很朴素:不要用顶层 await,把初始化全部包进一个显式的 boot() 函数再调用:

// 顶层只做一次调用,保持「同步求值到完成」
void boot().catch(err => fatal(`the desktop shell failed to boot: ${err}`))

小结

壳对子进程的管理,本质是三条纪律:入口唯一(单实例)、退得体面(先 flush 再强杀)、崩了自愈(重启 + 重载 + 次数上限)。再加上"退出不能被在途探测阻塞"这样一个时序细节,就把"一个跟着另一个进程的产品"做得像"一个让用户放心的桌面产品"。

欢迎在评论区聊聊:你用 Electron 做"壳 + 子进程"架构时,有没有把子进程优雅停掉却没做到的翻车经历?

更多推荐