deepseek harness桌面端改造(三)Electron 壳的生命周期管理:单实例锁、空闲端口,以及把 sidecar 子进程“体面地送走“
(三)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 做"壳 + 子进程"架构时,有没有把子进程优雅停掉却没做到的翻车经历?
更多推荐


所有评论(0)