Skip to content

fix(active-worktree): stop serving a cached root whose directory is g… - #976

Open
m4ku1337 wants to merge 1 commit into
omdsh-dev:devfrom
m4ku1337:fix/active-worktree-deleted-root
Open

m4ku1337 wants to merge 1 commit into
omdsh-dev:devfrom
m4ku1337:fix/active-worktree-deleted-root

Conversation

@m4ku1337

Copy link
Copy Markdown

Closes #960

问题

侧栏跟随一个 linked worktree 之后,若该 worktree 被外部删除(git worktree remove,或直接在编辑器/资源管理器里删目录),侧栏根目录会永久钉在已删除的路径上:文件树报"文件夹不存在"、git 面板的所有命令都在不存在的 cwd 里执行报"不是 git 仓库",主仓库明明完好,却只能重启 DSH 进程才能恢复。

根因与 #960 一致,是三个进程级缓存只存路径、从不校验存在性:

const activeWorktreeRootCache = new Map<string, { root, base, expires }>()  // 15s TTL
const pathTopCache = new Map<string, string | undefined>()                   // 永不过期
const linkedWorktreeCache = new Map<string, boolean>()                       // 永不过期

外层 TTL 到期后会重新扫描,但候选路径仍从 pathTopCache 里拿回同一个已删除的顶层目录,linkedWorktreeCache 又照样回答"这是 linked worktree",于是 TTL 形同虚设 —— 这正是"永远不会自行恢复"的机制。

改动(每次缓存命中多一次 stat())

  • 新增 isLiveDirectory();
  • topOfActivePath():命中缓存且缓存的是路径时先校验它仍是目录;已消失 → 删除该条目并重新探测(对不存在的目录,探测返回 undefined,候选自然被丢弃)。缓存 undefined("不在任何仓库里")的负答案不受影响,非仓库会话依旧免探测 —— 那是这层缓存存在的理由;
  • activeWorktreeRootOf():命中 TTL 缓存时同样校验 root;root === base 时跳过(这种情况没有更好的回退对象)。

一次 stat() 相比这层缓存要避免的 ~7ms git rev-parse 进程开销可以忽略(#960 里也算过这笔账)。

测试(tests/active-worktree.spec.ts)

新增一条真实仓库 + 真实 linked worktree 的用例:会话跟随 worktree → 删除该目录 → ① 同一会话再次询问必须回落到仓库顶层(覆盖 TTL 缓存层);② 新会话询问也不允许把它从内层缓存里复活(覆盖 pathTopCache)。放在文件末尾,以保持既有 LRU 用例对进程级缓存的假设(它就是靠"第 257 个会话把最老的挤出去"来观测上界的)。

把 src/active-worktree.ts 还原成修复前,这条用例立即红在:

Expected: "…\\Temp\\dsh-active-root-9lExN1\\repo"
Received: "…\\Temp\\dsh-active-root-9lExN1\\worktree"   ← 已删除的目录

即 #960 里"钉死在已删除目录"的现象本身。

文档

CHANGELOG.md / CHANGELOG_EN.md 各加一条 ### Unreleased 条目(AGENTS §6 把用户可见改动导向 CHANGELOG)。

验证(本机 Windows / Node 22)

检查 结果
pnpm exec vitest run tests/active-worktree.spec.ts 9/9
还原 src/active-worktree.ts 新用例红(上面的输出)
pnpm typecheck / pnpm lint 通过
pnpm test(全量) 见下

关于全量对照的诚实说明:本机第一次跑全量时(当时另有并行负载)出现 7 条失败,其中 5 条(git-remote-actions ×2、git-graph ×2、git ×1)在单独复跑时全部通过(那 5 个文件 53/54 通过,唯一失败是 plugin-meta),整轮耗时 128s vs 平时 62s,属负载导致的 30s 超时。另两条与本次无关且在干净 dev 上同样失败:plugin-meta 的 packs the icon and locales into the tarball(Windows tar 输出 CRLF、用例按换行切分 —— 确定性失败,不是超时),以及 git-worktree 的 rename-heavy 用例(负载下超时)。

未做:真机侧栏复现(本机没有装该插件的 DSH profile);pnpm test:mount。

AI 辅助说明:定位、补丁与验证由 AI 助手按用户指令完成,输出都是本机原始结果;独立子代理的盲复现与对抗性复核结论随后贴在评论里。

…one (omdsh-dev#960)

active-worktree.ts 的三个进程级缓存只存路径、从不校验存在性:

- pathTopCache(path → git 顶层)与 linkedWorktreeCache(top → 是否 linked worktree)永不过期;
- activeWorktreeRootCache 虽有 15s TTL,但每次过期重扫时,候选路径仍会从上面两个缓存里拿回同一个
  已删除的顶层目录,TTL 形同虚设。

结果是:侧栏跟随某个 linked worktree 之后,如果该 worktree 被外部删除(git worktree remove,或直接
在编辑器里删目录),侧栏根目录会永久钉在已删除的路径上 —— 文件树报「文件夹不存在」、git 面板报
「不是 git 仓库」,主仓库明明还健在,却只能重启 DSH 进程才能恢复(omdsh-dev#960)。

改动(每次缓存命中多一次 stat(),相比它要避免的 ~7ms git 进程开销可以忽略;不新增任何 git 探测):

- 新增 directoryPresence():**三态**判断,只有 ENOENT / ENOTDIR 才算「已消失」;EACCES / EPERM /
  EBUSY / EIO / EMFILE(被占用的句柄、杀软扫描、卡住的网络路径)一律按「无法判定」处理 —— 把这类
  瞬时错误当成「已消失」会因为一次抖动就把侧栏改到仓库顶层,比它要修的那个陈旧答案更糟;
- topOfActivePath():命中缓存且缓存的是路径时校验它,已消失才失效;失效后的**重新探测结果(负答案)
  deliberately 不写回缓存** —— pathTopCache 没有 TTL,一个由「消失」产生的负答案会把会话永久钉在
  仓库顶层,连「worktree 删掉后又建回来」都再也跟不上了;
- activeWorktreeRootOf():命中 TTL 缓存时同样校验 root(root === base 时跳过)。

测试(tests/active-worktree.spec.ts,共 11 例):

- 原有 omdsh-dev#960 用例:跟随 worktree → 删掉 → 同一会话回落到仓库(TTL 层)+ 新会话不被内层缓存污染;
- 复核补的两例:① 目录还在、但 stat 连续报 EPERM/EBUSY/EACCES/EIO(整趟 ask 都失败,模拟被占用的
  句柄/杀软/卡死的网络路径)必须继续跟随 worktree,不许改根;② 删掉后**原地重建**的 worktree 必须被
  重新跟随(用新会话验证,因为旧会话按文档语义要等 15s TTL 过期)。
  两例都按「先破坏实现再跑」验证过:把三态退化成「任何错误=消失」→ ①红;把失效产生的负答案写回缓存
  → ②红。

变异实测:还原 src/active-worktree.ts → omdsh-dev#960 用例红在 Received "…\\worktree"(已删除目录);
三态退化 → 瞬时错误用例红;负答案回写 → 重建用例红。

验证(本机 Windows / Node 22):
- pnpm exec vitest run tests/active-worktree.spec.ts → 11/11;pnpm typecheck / pnpm lint → 通过
- pnpm test(全量)对照干净 dev:失败集合为子集,无新增失败(详见 PR 描述;两条既有失败在各树上
  单独复跑时只剩 plugin-meta —— 那是 Windows tar 输出 CRLF 导致的确定性失败,不是超时)
未做:真机侧栏复现(本机没有装该插件的 DSH profile)。
@m4ku1337
m4ku1337 force-pushed the fix/active-worktree-deleted-root branch from c4cabd6 to d5853da Compare October 16, 2026 21:27
@m4ku1337

Copy link
Copy Markdown
Author

独立复核摘要(两个子代理各自 clone + 实跑)—— 盲复现 PASS,对抗性复核 FAIL:它在本版第一稿里抓到了两个真回归,已在 d5853da 修正

上面的描述写的是第一稿(c4cabd6);下面这条评论与提交信息是当前版本,请以它们为准。

① 盲复现 + 端到端核对 — PASS(针对 c4cabd6)

  • 用自己的探针(真实仓库 + 真实 git worktree add + 统计 runGit 次数)复现 侧栏根目录被永久钉死在已删除的 linked worktree 上(文件树报“文件夹不存在”,git 面板报“不是 git 仓库”) #960:dev 上删除 worktree 后两次询问(TTL 内 / 新会话)都继续返回已删除的路径,且 0 次 git 调用;这正是"永久钉死"的机制。
  • 本版修复后两条路径都回落到仓库顶层,且没有新增任何 git 探测(存活缓存命中仍是 0 次 git 调用,只多一次 stat)。
  • 测试有区分度:把 src/active-worktree.ts 单独还原成 dev → 用例红在 Expected "…\repo" / Received "…\worktree"。
  • 全量对照(高负载机器):分支 5 failed / 2640 passed / 2651,干净 dev 6 failed / 2638 passed / 2650 —— 分支失败集是 dev 的严格子集,四个非 plugin-meta 失败单独复跑即通过(负载相关),plugin-meta 是确定性的 Windows tar CRLF 现象(split('\n') 后比对 package/icon.svg 撞上 \r),在未改动的 dev 上同样失败 —— 我先前把它归因为"超时"是错的,已更正。

② 对抗性复核 — FAIL(针对 c4cabd6)

它用注入 stat 失败的方式证明第一稿把 #960 换成了两个同类但更窄的 bug,其中一条比 dev 更糟:

场景 第一稿行为 dev 行为
目录仍在,但 stat 报 EPERM/EACCES/EBUSY(被占用的句柄、杀软、卡住的网络路径) 静默改根到仓库顶层,而且这条负答案被写进没有 TTL 的 pathTopCache → 粘住直到进程重启 保持跟随 worktree
worktree 删掉后原地重建 再也跟不上(同会话、新会话都不行) 能重新跟上

两条都是真的 —— 我按它的建议改了:

  1. directoryPresence() 改成三态:只有 ENOENT / ENOTDIR 算"已消失",EACCES / EPERM / EBUSY / EIO / EMFILE 一律按"无法判定"处理(继续用缓存里的答案)。一次抖动就把侧栏改根,比它要修的那个陈旧答案更糟。
  2. 失效后的重新探测结果(负答案)不再写回缓存:pathTopCache 没有 TTL,一个由"消失"产生的负答案会把会话永久钉在仓库顶层。"删掉"与"重建"现在都是自愈的。
  3. 补了它要求的两条用例(共 11 例):瞬时 stat 失败不许改根(EPERM/EBUSY/EACCES/EIO 在整趟 ask 里持续失败)、删掉后重建必须重新跟上(用新会话验证;旧会话按文档语义要等 15s TTL 过期 —— 这一条我在测试注释里写明了,它是 TTL 的既定行为,不是 bug)。两例都做了"先破坏实现再跑"的验证:三态退化成"任何错误=消失" → ①红;负答案写回缓存 → ②红。

它另外的建议(模块头部注释补一句"每次命中多一次 stat 的文件系统成本")也已采纳。

本版验证:pnpm exec vitest run tests/active-worktree.spec.ts 11/11;pnpm typecheck / pnpm lint 通过;变异实测三条(还原源码 → #960 用例红;三态退化 → 瞬时错误用例红;负答案回写 → 重建用例红)。

仍未做:真机侧栏复现(本机没有装该插件的 DSH profile);pnpm test:mount。两个子代理也都没能跑真实宿主。

已知残留(复核者记录,本 PR 不动):linkedWorktreeCache(top → boolean)仍然永不过期;pathTopCache 里"本来就不是仓库"的负答案也仍然无限期缓存(dev 亦然)—— 与本次修的"由消失产生的负答案"不是同一条路径,但属于同一类,值得维护者知道。

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.

1 participant