Skip to content

fix: 修复多窗口并发导致的全局设置静默丢失 - #2

Merged
Lianues merged 2 commits into
Lianues:mainfrom
gaogao-gao:fix/settings-concurrent-overwrite
Jul 29, 2026
Merged

fix: 修复多窗口并发导致的全局设置静默丢失#2
Lianues merged 2 commits into
Lianues:mainfrom
gaogao-gao:fix/settings-concurrent-overwrite

Conversation

@gaogao-gao

Copy link
Copy Markdown
Contributor

问题

多窗口使用时,全局设置(渠道配置、压缩配置、MCP 服务等)会被静默覆盖,表现为「刚加的渠道过一会儿就没了」。

复现

  1. A、B 两个 VS Code 窗口都打开 LimCode 全局设置 → 渠道页,此时两边列表均为 [P0]
  2. A 新增渠道 P1 并保存,磁盘变为 [P0, P1]
  3. B 未感知到变化(无跨窗口同步),面板仍是 [P0]
  4. B 新增渠道 P2,提交 [P0, P2]
  5. 结果:P1 被删除,最终只剩 [P0, P2]

根因

存储层是全量保存语义。saveLlmProviderConfigsSettings 会把「不在提交列表里的记录」判定为用户删除并真删:

const previous = await loadLlmProviderConfigsSettings(paths);   // 盘上最新 [P0, P1]
const nextIds  = new Set(configs.map((c) => c.id));             // B 的陈旧列表 [P0, P2]
for (const prev of previous.settings.configs) {
  if (!nextIds.has(prev.id)) await removeRecordStoreRecord(...); // P1 被删除
}

每一步都正确持有了 storageResourceLock,文件层面也没有撕裂 —— 问题在于陈旧快照被当成了权威。这类丢失靠文件锁防不住,只能靠写入前的版本比对。

会话历史(conversationHistoryStore / conversationTimelineStore)已有「锁内重读 + generation 校验」,不存在此问题,本 PR 未改动。

方案

commit 1 — 乐观并发校验(安全底线)

  • 复用文件内已有的 savedAt 作为 revision,不新增持久化字段、不改 STORAGE_VERSION
  • writeGlobalSettingsFile / saveRecordStore 增加可选 expectedRevision,在已持有的资源锁内比对;recordStore 的比对搭在已有的 previousIndex 读取上,零额外 IO
  • 不传 expectedRevision完全保持旧行为,内部初始化与迁移路径不受影响
  • provider / compression 配置改用 pruneMissing 一次原子提交,取代「先 remove 再 write」,消除冲突时记录已被删的破坏性窗口
  • 冲突时先推回最新快照、再发错误信封(顺序不可颠倒,否则 snapshot 会清掉刚设置的错误提示)

冲突时的行为是拒绝保存并载入最新值,宁可让用户重做一次修改,也不静默删除数据。

commit 2 — 跨窗口自动刷新(体验)

  • 新增 GlobalSettingsWatcher,180ms 防抖后按 section 收敛刷新
  • 监听范围使用精确白名单settings/ 下绝大多数文件是每会话一份的 conversation-*-llm.json(实测 92 个文件中 86 个属此类,多 agent 任务单小时批量写入 14 个),用通配会把刷新事件刷爆
  • 刷新动作是「重新读盘再广播」的幂等纯读,因此无需自写抑制
  • data root 切换后通过 onDidChangeStorageRoot 事件重建监听器,不使用轮询
  • 面板存在未落盘修改时,外部快照暂存而不覆盖表单,改为顶部提示条由用户选择载入,避免正在输入的内容被冲掉
  • lastSyncedRevisions 区分「真外部变更」与「本窗口自身写入的回送」,避免误报

兼容性

  • 数据格式零变更(STORAGE_VERSION 仍为 1,无新增持久化字段),可安全降级回旧版本读写
  • 协议新增字段 revision? / expectedRevision? 均为可选
  • 无新增依赖

测试

npm run check 全绿:201 pass / 0 fail(原基线 195 + 新增 6)。

新增 tests/settingsRevisionConflict.test.cjs

  1. revision 匹配 → 保存成功且 revision 递进
  2. revision 过期 → 抛冲突,且断言目录内所有文件逐字节不变
  3. 原缺陷复现:A 加 P1 → B 持旧 revision 加 P2 → B 被拒 → P1 存活
  4. 不传 expectedRevision → 旧行为完全兼容
  5. 文件型 section 的 CAS 与不覆盖行为
  6. watcher 白名单断言:限定 settings/ 前缀、不含 conversation-、顶层文件必须枚举且禁用 *

已知取舍

  • revision 为 ISO 毫秒串,两窗口在同一毫秒内写入理论上可漏检;为不改变文件格式未引入计数器
  • llm / common 两个 section 后端未接 CAS(前者仅存单个 activeProviderConfigId,后者存于 globalState)。前端仍会携带 expectedRevision,后端忽略,无副作用
  • 保存请求发出后本地 revision 即作废(consumeExpectedRevision),直到收到新快照才恢复校验,否则同一窗口连发多次保存会产生假冲突
  • webview store 中现已存在四个语义不同的 "revision"(本 PR 新增两个,另两个是原有的编辑/请求序号),后续如有重构建议统一命名
  • 真实双窗口 GUI 联调未在 CI 中覆盖,逻辑由上述单测保证

多窗口下 B 窗口设置面板持有的列表可能早于 A 窗口刚保存的结果,而存储层是全量保存:saveLlmProviderConfigsSettings 会把不在提交列表里的记录判定为「用户删除」并真删。每一步都正常持有文件锁,但陈旧快照被当成权威,这类丢失靠锁防不住。

- 复用文件内已有的 savedAt 作为 revision,不新增持久化字段、不改 STORAGE_VERSION,可安全降级

- writeGlobalSettingsFile / saveRecordStore 增加可选 expectedRevision,在已持有的资源锁内比对;recordStore 的比对搭在已有的 previousIndex 读取上,零额外 IO

- 不传 expectedRevision 时完全保持旧行为,内部初始化与迁移路径不受影响

- provider/compression 配置改用 pruneMissing 一次原子提交,取代「先 remove 再 write」,消除冲突时记录已被删的破坏性窗口

- 冲突时先推回最新快照再发错误信封,面板不会停留在陈旧数据

- 新增 6 个回归测试,含原缺陷复现:A 加 P1 -> B 持旧 revision 加 P2 -> B 被拒且 P1 存活
其他窗口修改全局设置后,本窗口面板会停留在陈旧值,需要手动 reload window 才能看到。

- 新增 GlobalSettingsWatcher 监听 data root 下的全局设置文件,180ms 防抖后按 section 收敛刷新

- 监听范围使用精确白名单:settings 目录下绝大多数文件是每会话一份的 conversation-*-llm.json,多 agent 任务会短时间批量写入,用通配会把刷新事件刷爆

- 刷新动作是重新读盘再广播的幂等纯读,因此无需自写抑制

- data root 被切换后通过 onDidChangeStorageRoot 事件重建监听器,不使用轮询

- 面板存在未落盘修改时,外部快照暂存而不覆盖表单,改为顶部提示条由用户选择载入

- 以 lastSyncedRevisions 区分真外部变更与本窗口自身写入的回送,避免误报
@Lianues
Lianues merged commit 50d7a61 into Lianues:main Jul 29, 2026
2 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.

2 participants