Skip to content

🔒 fix(desktop): 单实例锁——双开不再双 sidecar 并发写 ~/.lume - #417

Merged
CavinHuang merged 7 commits into
mainfrom
fix/290-single-instance-lock
Aug 22, 2026
Merged

🔒 fix(desktop): 单实例锁——双开不再双 sidecar 并发写 ~/.lume#417
CavinHuang merged 7 commits into
mainfrom
fix/290-single-instance-lock

Conversation

@CavinHuang

Copy link
Copy Markdown
Owner

Closes #290

问题

settings 有自己的 lockfile(fail-closed),但 sessions/memory/audit 的 JSONL 与 sqlite 均无跨进程锁。双开应用即双 sidecar 并发写同一 ~/.lume 数据目录:JSONL 追加交错、事件 seq 按文件续读错乱、sqlite 锁冲突——用户视角是"偶尔丢消息",损坏难以归因。

方案

标准 Electron 单实例模式:

  • app.requestSingleInstanceLock() 在 app ready 申请;未获锁直接 app.quit(),且 whenReady 回调短路,避免窗口/sidecar 创建竞速
  • 首实例监听 second-instance:无窗口则重建,最小化则 restore,最后聚焦——复用 activate 分支的既有模式

已知边界:锁按 Electron 默认 userData 作用域,不区分 launcher config dir。多 profile 并存是开发者场景且各写各的目录、本无并发写冲突;如未来需要 config-dir 粒度可在锁前同步解析 launcher config 后 setPath('userData') 再申请。

测试

  • desktop 全量 290 tests 过(2 skip 为既有);typecheck 绿

真实双开行为需打包后手工验证一次(bun dev 双进程不走同一构建产物路径,行为可能与打包态不同)。

TaTaLiao and others added 2 commits August 22, 2026 15:58
settings 有 lockfile 但 sessions/memory/audit 的 JSONL 与 sqlite 均无跨进程锁,
双开应用即双 sidecar 并发追加同一数据目录,交错写入与事件 seq 续读错乱
难以归因。标准 requestSingleInstanceLock:第二实例直接退出,首实例经
second-instance 聚焦/恢复既有窗口承接。锁在 app ready 前申请;未获锁时
whenReady 回调短路,避免窗口创建竞速。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CavinHuang

Copy link
Copy Markdown
Owner Author

Code Review 结论:✅ 实现正确,可合并(附一条健壮性建议)

已验证的点

  • ✅ 锁 + whenReady()if (!gotSingleInstanceLock) return 双保险,第二实例不会执行任何启动逻辑
  • second-instance handler 复用既有模式:窗口存在 → restore + showMainWindow()(内部即 captureQuickInputContext + ensureMainWindowVisible,main.ts:1063);窗口不存在 → captureQuickInputContext() + createMainWindow(),与现有调用序列一致
  • ✅ 插入点安全性实测通过:requestSingleInstanceLock 虽位于 main.ts 模块尾部(~3208 行),但扫描其之前的全部顶层立即执行语句(appendSwitch / registerSchemesAsPrivileged / ipcMain.on/handle 注册)均无盘写副作用;logDesktopStartup 仅在设置 LUME_DESKTOP_STARTUP_LOG 环境变量时才同步 append,默认安全
  • ✅ 与 lume:relaunchapp.relaunch() + app.exit(0),main.ts:3087)无竞态:旧进程退出远早于新进程走到拿锁处
  • ✅ 项目未注册系统协议深链(无 setAsDefaultProtocolClient / open-url),忽略 second-instance 的 argv 不丢功能

建议(P2):锁申请位置上移到模块顶部

当前锁申请在 main.ts:3208+(模块尾部),保护是"位置敏感"的——今天恰好前置顶层代码无副作用,但未来任何人在 main.ts 前部加顶层初始化(开 sqlite、spawn sidecar 等)都会静默绕过单实例锁。注释写着"锁必须在 app ready 前申请",建议代码位置也体现这一约束:import 区之后立即申请(如 DESKTOP_ROOT 常量之前)。

备注:CI 6/6 全绿、MERGEABLE。

锁原位于 main.ts 尾部(child-process-gone 注册之后),恰因前置顶层
语句均无盘写副作用而侥幸安全;上移到 import 区之后、任何顶层初始化
之前,使"锁必须最早申请"的约束由代码位置保证——后续在文件前部新增
顶层副作用(开库、spawn 等)不再可能绕过单实例保护。注释同步说明
该位置约束的原因。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CavinHuang

Copy link
Copy Markdown
Owner Author

Code Review

可合。验证过程:锁请求位于 main.ts:161(import 之后、第一条语句之前);全 desktop src grep 模块顶层磁盘 I/O 零命中(无模块在拿锁前做盘写);唯一一处 whenReady 已加短路守卫;second-instance 处理器与既有 activate 分支逐行一致;electron-builder 无 protocol 注册、main.ts 全文无 argv 处理,second-instance 忽略 argv 无实际损失。平台语义完备(三平台锁均在 ready 前生效;macOS Finder 二次点击走 activate 不受影响),退出码为标准 app.quit()。CI 6/6 绿。

两个 P3 记录:

[P3] 锁粒度不感知 LUME_CONFIG_DIR,不同数据目录的实例被过度互斥
锁按 Electron 默认 userData 作用域,而实际数据目录可经 LUME_CONFIG_DIR 重定向——带不同数据目录的两个实例本无并发写冲突却会被误杀。PR 描述已承认此边界并定性为开发者场景,可接受;后续要做的话锁前同步解析 env 再 setPath('userData') 即可。

[P3] 「早于任何顶层初始化副作用」的承诺依赖一条未强制的不变量
锁位于 ~152 行 import 之后,「早于副作用」实际依赖「所有被 import 模块无顶层 I/O」的约定,当前成立但无 lint/测试护栏。建议注释补一句约束说明或加 ESLint 规则。

@CavinHuang
CavinHuang merged commit be9cb5e into main Aug 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 desktop: 无单实例锁,双开应用导致双 sidecar 并发写 ~/.lume

2 participants