Skip to content

fix(cli): avoid stale git branch watcher setup#5271

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
tt-a1i:fix/next-local-bug-scan
Jun 18, 2026
Merged

fix(cli): avoid stale git branch watcher setup#5271
wenshao merged 3 commits into
QwenLM:mainfrom
tt-a1i:fix/next-local-bug-scan

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • prevent async git branch watcher setup from creating fs.watch after unmount
  • unskip/replace watcher update and cleanup coverage, and add unmount race coverage

Validation

  • npx vitest run src/ui/hooks/useGitBranchName.test.ts
  • npx prettier --check packages/cli/src/ui/hooks/useGitBranchName.ts packages/cli/src/ui/hooks/useGitBranchName.test.ts
  • npx eslint packages/cli/src/ui/hooks/useGitBranchName.ts packages/cli/src/ui/hooks/useGitBranchName.test.ts
  • npm run build --workspace=packages/core
  • npm run build --workspace=packages/acp-bridge
  • npm run generate
  • npm run typecheck --workspace=packages/cli
  • git diff --check

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.


const gitLogsHeadPath = path.join(cwd, '.git', 'logs', 'HEAD');
let watcher: fs.FSWatcher | undefined;
let disposed = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The flag is named disposed, but every other hook in this directory that uses the same async-cancellation pattern names it cancelled:

  • useWorktreeSession.ts:58let cancelled = false;
  • useDiffData.ts:49let cancelled = false;
  • useAtCompletion.ts:160let cancelled = false;
  • useTurnDiffs.ts:69let cancelled = false;

Renaming to cancelled would keep the convention consistent across the hooks directory.

Suggested change
let disposed = false;
let cancelled = false;

— qwen3.7-max via Qwen Code /review

const CWD = '/test/project';
const GIT_LOGS_HEAD_PATH = `${CWD}/.git/logs/HEAD`;

async function flushAsyncEffects() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This helper hardcodes exactly two await Promise.resolve() ticks after vi.runAllTimers(), which works today but is brittle — if setupWatcher ever gains an additional await, the flush becomes silently insufficient. Other test files in this directory (e.g. useToolScheduler.test.ts, ideCommand.test.ts) use await vi.runAllTimersAsync() instead, which natively drains the full microtask + timer queue:

// Before
async function flushAsyncEffects() {
  vi.runAllTimers();
  await Promise.resolve();
  await Promise.resolve();
}

// After — replace all flushAsyncEffects() calls with:
await vi.runAllTimersAsync();

— qwen3.7-max via Qwen Code /review

@tt-a1i
tt-a1i force-pushed the fix/next-local-bug-scan branch from ac01b71 to 6bde99c Compare June 18, 2026 01:20
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification — fix confirmed

I verified this PR locally with real test runs under tmux, including a real-filesystem demonstration of the watcher leak (beyond the mocked unit test). Verdict: the fix is correct and the new regression test genuinely catches the bug — safe to merge.

The bug

useGitBranchName's effect calls an async setupWatcher() that awaits fsPromises.access() before fs.watch(). If the component unmounts during that await, the cleanup runs while watcher is still undefined (so watcher?.close() is a no-op), and then — when access() resolves — fs.watch() is created after unmount. That watcher is never closed: a leaked OS file handle that keeps invoking fetchBranchName() (a git subprocess) on every .git/logs/HEAD change, for a component that no longer exists.

The fix adds a disposed flag set in cleanup and checked right after the await, bailing out before fs.watch() (plus a defensive watcher = undefined).

How I verified

All runs are the real vitest suite executed under tmux; the source file is swapped between base (c5935826c) and PR (6bde99ced) for each A/B.

1) The new test is a genuine regression test (base ✗ / PR ✓)

Running the PR's test file against each source:

Source should not create watcher if setup completes after unmount
base (c5935826c) FAILexpected "bound watch" to not be called at all, but actually been called 1 times → arg ["/test/project/.git/logs/HEAD", [Function]] (the leaked post-unmount watcher)
PR (6bde99ced) PASS

PR shipped state (PR source + PR test): 8/8 tests pass.

2) Real-filesystem demonstration (not mocked)

I rendered the real hook with a real fs.watch on a real temp .git/logs/HEAD, forced the unmount-during-access race, then appended to the real reflog and waited for a real inotify event. Only @qwen-code/qwen-code-core is mocked (to count fetchBranchNameexecCommand calls instead of shelling real git):

real fs.watch calls after unmount git lookups after setup after a real reflog append leaked watcher fired?
base 1 (leaked) 1 2 yes — the leaked watcher re-ran git for an unmounted component
PR 0 1 1 no

This shows the real-world consequence: on base, a live OS watcher survives unmount and keeps spawning git; the PR eliminates it.

3) PR's stated checks (on the PR state)prettier --check ✓, eslint ✓, tsc --noEmit (cli typecheck) ✓, all rc=0.

Notes for the maintainer (non-blocking)

  • Which test guards the race: only the new should not create watcher if setup completes after unmount fails on base. The two un-skipped tests (should update branch name when .git/logs/HEAD changes, should cleanup watcher on unmount) pass on both base and PR — they are de-flaked, un-skipped general coverage (replacing the flaky waitFor with a deterministic flushAsyncEffects + a real watch-callback spy), not the regression guard for this specific race. Good cleanup, just worth knowing which one is load-bearing.
  • Fix is complete: each effect invocation gets its own disposed closure (correct across cwd changes), and the if (disposed) return;fs.watch() path is synchronous, so there's no residual window after the check.

Recommendation: minimal, correct, well-covered. 👍 to merge.

Verified by running the real vitest suite under tmux with base/PR source A/B, plus a real-fs.watch leak demo on a real temp reflog. Hook source diff is exactly the 6-line disposed-flag change.

中文版(点击展开)

✅ 本地真实运行验证 —— 修复已确认

我在本地用 tmux 跑了真实测试进行验证,并额外做了一个真实文件系统的 watcher 泄漏演示(不止是 mock 单测)。结论:修复正确,且新增的回归测试确实能捕获该 bug —— 可以合并。

Bug 是什么

useGitBranchName 的 effect 里调用了异步setupWatcher(),它在 fs.watch() 之前会 await fsPromises.access()。如果组件在这个 await 期间 unmount,cleanup 会在 watcher 仍为 undefined 时运行(watcher?.close() 成为空操作);随后 access() resolve,fs.watch()unmount 之后被创建。这个 watcher 永远不会被关闭:一个泄漏的 OS 文件句柄,会在每次 .git/logs/HEAD 变化时为一个已经不存在的组件持续调用 fetchBranchName()(即 git 子进程)。

修复方案:在 cleanup 中置一个 disposed 标志,并在 await 之后立即检查,在 fs.watch() 之前提前 return(外加防御性的 watcher = undefined)。

验证方法

所有运行都是在 tmux 下执行的真实 vitest;每次 A/B 都把源文件在 base(c5935826c)与 PR(6bde99ced)之间切换。

1) 新测试是真正的回归测试(base 失败 / PR 通过)

用 PR 的测试文件分别跑两个源码版本:

源码 should not create watcher if setup completes after unmount
basec5935826c 失败 —— expected "bound watch" to not be called at all, but actually been called 1 times → 参数 ["/test/project/.git/logs/HEAD", [Function]](即 unmount 后泄漏的 watcher)
PR6bde99ced 通过

PR 完整状态(PR 源码 + PR 测试):8/8 测试通过。

2) 真实文件系统演示(非 mock)

我用真实 hook + 真实 fs.watch 监听真实临时 .git/logs/HEAD,强制制造 unmount-期间-access 的竞态,然后向真实 reflog 追加内容并等待真实 inotify 事件。仅 mock 了 @qwen-code/qwen-code-core(用于统计 fetchBranchNameexecCommand 调用次数,避免真的 shell 出 git):

unmount 后真实 fs.watch 调用数 setup 后 git 查询次数 真实 reflog 追加后 泄漏 watcher 是否触发
base 1(泄漏) 1 2 —— 泄漏的 watcher 为已 unmount 的组件再次跑了 git
PR 0 1 1

这展示了真实世界的后果:在 base 上,一个活的 OS watcher 在 unmount 后仍然存活并持续触发 git;PR 消除了它。

3) PR 声明的检查(在 PR 状态下) —— prettier --check ✓、eslint ✓、tsc --noEmit(cli typecheck)✓,rc 均为 0。

给维护者的提示(不阻塞)

  • 哪个测试守护该竞态: 只有新增的 should not create watcher if setup completes after unmount 在 base 上失败。两个被 un-skip 的测试(should update branch name when .git/logs/HEAD changesshould cleanup watcher on unmount)在 base 与 PR 上都通过 —— 它们是被去 flaky、解除 skip 的通用覆盖(把不稳定的 waitFor 换成确定性的 flushAsyncEffects + 真实 watch 回调 spy),并非针对本竞态的回归守护。清理得很好,只是需要知道哪个测试才是关键。
  • 修复是完整的: 每次 effect 调用都拥有自己的 disposed 闭包(对 cwd 变化也正确),且 if (disposed) return;fs.watch() 这段是同步的,因此检查之后没有残留的时间窗口。

建议: 改动最小、正确、覆盖充分。👍 可以合并。

通过在 tmux 下跑真实 vitest(base/PR 源码 A/B)+ 真实 fs.watch 在真实临时 reflog 上的泄漏演示完成验证。Hook 源码 diff 恰好就是那 6 行 disposed 标志改动。

wenshao
wenshao previously approved these changes Jun 18, 2026
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Nice cleanup, and the test additions here (un-skipping the two watcher tests + the new unmount-race test) are genuinely useful. The production change has been overtaken by main, though.

useGitBranchName.ts on main already guards this exact race — there's an if (cancelled) return; between await fsPromises.access(gitLogsHeadPath, …) and fs.watch(…), so the watcher can't be created after unmount. This branch was opened before that landed, so it now conflicts (CONFLICTING / DIRTY) and the new disposed flag duplicates the existing cancelled guard.

Could you rebase onto main, drop the production hunk (it's redundant now), and keep just the test changes? That resolves the conflict and leaves a clean, valuable test-only PR. Thanks!

中文说明

清理得不错,这里的测试新增(解封两个 watcher 测试 + 新的卸载竞态测试)确实有价值。不过生产代码那部分已经被 main 抢先解决了。

main 上的 useGitBranchName.ts 已经守住了这个完全相同的竞态 —— 在 await fsPromises.access(gitLogsHeadPath, …)fs.watch(…) 之间有一句 if (cancelled) return;,所以 watcher 不可能在卸载之后再被创建。你这个分支是在那之前开的,所以现在冲突了(CONFLICTING / DIRTY),而且新加的 disposed flag 和已有的 cancelled 守卫重复了。

能否 rebase 到 main删掉生产代码那段 hunk(现在它是冗余的),只保留测试改动?这样既解决冲突,又留下一个干净、有价值的纯测试 PR。谢谢!

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 17:01
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

1 similar comment
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Resolved the merge conflict by merging main into this branch (non-destructive — your commit history is preserved).

A note on how it resolved: main has since refactored useGitBranchName to guard the effect with a single cancelled flag, which already covers the watcher-teardown race this PR's disposed flag addressed — and additionally guards the async setState. So the hook is taken from main as-is (the disposed flag is now redundant), and this PR's net contribution is its regression test: it un-skips and implements the previously skip()-ed .git/logs/HEAD watcher test, which main still carries as a // TODO: fix. That test passes against main's implementation (8/8 locally, prettier-clean). The incidental formatting churn the 3-way merge introduced on unrelated files was reset back to main, so the net diff is just the test.

中文说明

通过把 main merge 进本分支解决了冲突(非破坏性,保留了你的提交历史)。

关于解决方式的说明:main 之后已把 useGitBranchName 重构为用单个 cancelled 标志来守卫该副作用,这已经覆盖了本 PR 用 disposed 标志修复的 watcher 拆除竞态——并且额外守护了异步 setState。因此 hook 直接采用 main 的版本(disposed 标志现在是冗余的),本 PR 的净贡献是它的回归测试:把之前被 skip() 掉的 .git/logs/HEAD watcher 测试实现并启用了,而 main 那边目前还是 // TODO: fix。该测试在 main 的实现下通过(本地 8/8,prettier 干净)。3-way merge 在无关文件上引入的格式化噪音已重置回 main,所以最终 diff 只剩这个测试。

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @tt-a1i!

Template: The PR body doesn't follow the PR template — it's missing "What this PR does", "Why it's needed", "Reviewer Test Plan", "Risk & Scope", "Linked Issues", and the 中文说明 section. However, a collaborator has already reviewed and resolved merge conflicts on this PR, so I'm treating this as non-blocking. For future PRs, please follow the template — it helps reviewers move faster.

Direction: This addresses a real bug — an async race in useGitBranchName where fs.watch() can be created after component unmount, leaking an OS file handle that keeps spawning git subprocesses. The production fix has since been landed on main independently (via the cancelled flag), so after the conflict resolution this PR is now test-only: un-skipping two previously broken watcher tests and adding a regression test for the unmount race. That's a valuable contribution — main still carries those tests as skip() // TODO: fix.

Approach: The scope is tight — one test file, focused changes. The flushAsyncEffects helper replaces the flaky waitFor with a deterministic pattern. The new regression test (should not create watcher if setup completes after unmount) is the load-bearing one. No concerns here.

Moving on to code review. 🔍

中文说明

感谢贡献, @tt-a1i!

模板: PR 正文未遵循 PR 模板 —— 缺少 "What this PR does"、"Why it's needed"、"Reviewer Test Plan"、"Risk & Scope"、"Linked Issues" 和中文说明部分。不过协作者已经审查并解决了合并冲突,因此视为非阻塞项。后续 PR 请遵循模板,有助于加快审查速度。

方向: 修复了一个真实 bug —— useGitBranchName 中的异步竞态条件,fs.watch() 可能在组件卸载后被创建,导致 OS 文件句柄泄漏并持续启动 git 子进程。生产代码修复已被 main 独立解决(通过 cancelled 标志),因此冲突解决后本 PR 现在是纯测试:解封两个之前 broken 的 watcher 测试并新增一个卸载竞态的回归测试。这是有价值的贡献 —— main 上这些测试仍然是 skip() // TODO: fix

方案: 范围紧凑 —— 只改一个测试文件,变更聚焦。flushAsyncEffects 辅助函数用确定性模式替代了不稳定的 waitFor。新增的回归测试(should not create watcher if setup completes after unmount)是关键守护。没有问题。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: For a race condition where fs.watch() can be created after unmount in an async effect, I'd expect the tests to: (1) un-skip the two broken watcher tests with a more deterministic async flushing approach, (2) add a regression test that holds the fsPromises.access() promise, unmounts during the await, then verifies no watcher is created, and (3) spy on fs.watch directly rather than relying on memfs to fire events (memfs doesn't actually trigger inotify).

Comparison with the diff: The PR matches this proposal almost exactly. The flushAsyncEffects helper (vi.runAllTimers() + two microtask ticks) replaces the flaky waitFor. The watcher update test now captures the fs.watch callback via a spy and invokes it directly — much more reliable than hoping memfs fires events. The unmount race test holds access() via a deferred promise, unmounts, resolves, and asserts fs.watch was never called. Clean implementation, no over-engineering.

No critical issues found. The code follows project conventions and the test patterns are sound.

Testing

Before (main — 7 tests, 2 skipped)

 ✓ src/ui/hooks/useGitBranchName.test.ts (7 tests | 2 skipped) 38ms

 Test Files  1 passed (1)
      Tests  5 passed | 2 skipped (7)

After (this PR — 8 tests, 0 skipped)

 ✓ src/ui/hooks/useGitBranchName.test.ts (8 tests) 50ms

 Test Files  1 passed (1)
      Tests  8 passed (8)

Regression verification (removed cancelled guard from production code)

Confirmed the new test genuinely catches the bug — with if (cancelled) return; stripped from the hook:

 ❯ src/ui/hooks/useGitBranchName.test.ts (8 tests | 1 failed) 53ms
   ✓ useGitBranchName > should return branch name
   ✓ useGitBranchName > should return undefined if git command fails
   ✓ useGitBranchName > should return short commit hash if branch is HEAD (detached state)
   ✓ useGitBranchName > should return undefined if branch is HEAD and getting commit hash fails
   ✓ useGitBranchName > should update branch name when .git/logs/HEAD changes
   ✓ useGitBranchName > should handle watcher setup error silently
   ✓ useGitBranchName > should cleanup watcher on unmount
   × useGitBranchName > should not create watcher if setup completes after unmount
     → expected "bound watch" to not be called at all, but actually been called 1 times

 Test Files  1 failed (1)
      Tests  1 failed | 7 passed (8)

Only the new regression test fails — the other 7 pass regardless of the guard. This confirms the new test is the load-bearing one for this specific race condition.

中文说明

代码审查

独立方案: 对于异步 effect 中 fs.watch() 可能在卸载后被创建的竞态条件,我期望测试能:(1) 用更确定性的异步刷新方式解封两个 broken 的 watcher 测试,(2) 新增回归测试:在 fsPromises.access() 的 await 期间卸载组件,然后验证不会创建 watcher,(3) 直接 spy fs.watch 而不是依赖 memfs 触发事件(memfs 不会真正触发 inotify)。

与 diff 对比: PR 几乎完全匹配这个方案。flushAsyncEffects 辅助函数(vi.runAllTimers() + 两次微任务 tick)替代了不稳定的 waitFor。watcher 更新测试现在通过 spy 捕获 fs.watch 的回调并直接调用 —— 比期望 memfs 触发事件可靠得多。卸载竞态测试通过延迟 promise、在 await 期间卸载、然后 resolve 并断言 fs.watch 从未被调用。实现干净,没有过度工程。

未发现关键问题。代码遵循项目规范,测试模式合理。

测试

  • 之前 (main): 7 个测试,2 个跳过,5 个通过
  • 之后 (本 PR): 8 个测试,0 个跳过,8 个通过
  • 回归验证: 移除生产代码中的 cancelled 守卫后,只有新增的回归测试失败 —— 证实它是该竞态条件的关键守护

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a clean, minimal test-only PR that does exactly what it says. The production fix (the cancelled flag race guard) was independently landed on main before this PR could merge, so after conflict resolution the net contribution is the test coverage improvement — and it's genuinely useful.

My independent proposal for these tests matched what the PR delivers: deterministic async flushing instead of flaky waitFor, direct fs.watch callback spying instead of relying on memfs inotify, and a regression test that holds the async gap open to verify the guard works. The PR nails all three.

The regression verification is the clincher: stripping the cancelled guard causes exactly one test to fail — the new should not create watcher if setup completes after unmount. The other 7 tests pass regardless. That's a well-targeted regression test.

The only note is the PR template non-compliance, which I've flagged as non-blocking for future PRs.

Approving. ✅

中文说明

退后一步看:这是一个干净、最小化的纯测试 PR,完全兑现了它的承诺。生产代码修复(cancelled 标志竞态守卫)在合并前已被 main 独立解决,因此冲突解决后的净贡献是测试覆盖改进 —— 这确实有价值。

我对这些测试的独立方案与 PR 交付的内容一致:用确定性异步刷新替代不稳定的 waitFor,直接 spy fs.watch 回调而不是依赖 memfs inotify,以及一个撑开异步间隙来验证守卫有效的回归测试。PR 三点全部命中。

回归验证是关键:移除 cancelled 守卫后恰好只有一个测试失败 —— 新增的 should not create watcher if setup completes after unmount。其他 7 个测试无论有没有守卫都通过。这是一个精准定位的回归测试。

唯一的备注是 PR 模板不合规,已作为非阻塞项标注,供后续 PR 参考。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao merged commit 9e16b4c into QwenLM:main Jun 18, 2026
24 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.

3 participants