Skip to content

fix: bare clone/fetch 异步化,git 网络操作不再阻塞事件循环#267

Merged
lishuceo merged 2 commits into
mainfrom
fix/async-bare-clone
Jun 23, 2026
Merged

fix: bare clone/fetch 异步化,git 网络操作不再阻塞事件循环#267
lishuceo merged 2 commits into
mainfrom
fix/async-bare-clone

Conversation

@lishuceo

Copy link
Copy Markdown
Owner

变更概述

  • 根因:cloneBareAtomic / fetchIfStaleexecFileSync 同步执行远程 git,clone 卡到 timeout(默认 5 分钟)期间整个单进程 bridge 的事件循环被冻死 —— 飞书事件无法处理/ACK,被飞书定时重投后又超过 5 分钟 staleness 阈值被丢弃,导致期间所有会话"毫无响应"。一次连不上远程的 clone 即可拖垮全服务(线上 06-23 10:39–10:44 实测冻结 5 分钟)。
  • 改为 promisify(execFile) + await:cache.ts 的 clone/fetch/ensureBareCache 全部异步化;manager.ts setupWorkspace 改 async 并将本地 clone/set-url/checkout 一并异步化;isolation.ts/tool.ts/registry.ts 调用处补 await
  • execFile 不支持 stdio,改用 maxBuffer: 64MB 防输出溢出;env(GIT_LFS_SKIP_SMUDGE)保留。
  • 与已合入的 fix: 修复 bare cache 分支永久冻结(fetch 空转) #266(bare cache refspec 修复)正交,无冲突。

测试计划

  • npm run typecheck 通过
  • eslint(改动文件)无错
  • 新增回归守卫:bare clone 在途时事件循环仍存活,并发宏任务能推进(锁死同步阻塞根因)
  • 更新 cache/manager/event-handler 测试为 async;修复 identity.test.ts 的 child_process mock 缺 execFile 致模块加载崩溃
  • 全量 vitest:1723 passed(唯一 1 个 failure 为 memory/quality.test.ts,依赖 DASHSCOPE_API_KEY 的既有环境失败,与本改动无关)

🤖 Generated with Claude Code

cloneBareAtomic / fetchIfStale 此前用 execFileSync 同步执行远程 git,
clone 卡到 timeout(默认 5 分钟)期间整个单进程 bridge 的事件循环被冻死:
飞书事件无法处理与 ACK → 被飞书定时重投 → 重投又超过 5 分钟 staleness
阈值被丢弃,导致期间所有会话"毫无响应"。一次连不上远程的 clone 即可拖垮全服务。

改为 promisify(execFile) + await,让 git 在子进程执行、await 让出事件循环:
- cache.ts: cloneBareAtomic / fetchIfStale / ensureBareCache 改 async,
  execFileSync → execFileAsync(去掉 stdio,改 maxBuffer 防输出溢出)
- manager.ts: setupWorkspace 改 async(await ensureBareCache),
  本地 clone / set-url / checkout 三处 git 调用一并异步化
- isolation.ts / tool.ts / registry.ts: 调用处补 await

新增回归守卫(cache.test.ts):断言 bare clone 在途时事件循环仍存活,
并发宏任务能照常推进 —— 锁死本次事故的根因(同步阻塞)。
顺带修复 identity.test.ts 的 child_process mock 缺 execFile 导致模块加载即崩。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 2m 57s —— View job


Code Review — PR #267

Verdict: ✅ Approve with one minor note

The async refactor is correct and well-scoped. The root cause (synchronous execFileSync freezing the single-process event loop during the entire git network timeout window) is real and the fix is the right one. Every call site of the now-async functions was updated:

Function Callers updated
ensureBareCache manager.ts:126, registry.ts:423
setupWorkspace isolation.ts:154, tool.ts:116
ensureIsolatedWorkspace (no production callers; tests await it) ✅

Other things verified:

  • execFile error rejections carry .message; try/catch + await correctly replaces the old sync throw paths. ✅
  • maxBuffer: 64MB is a sound replacement for stdio: pipe — bare clone/fetch produce little output in non-TTY. ✅
  • GIT_LFS_SKIP_SMUDGE env preserved on the workspace clone; bare clones don't smudge anyway. ✅
  • The event-loop-liveness regression test (cache.test.ts:272) genuinely guards the root cause. ✅
  • Concurrent stale fetchIfStale collisions degrade gracefully (failure → return true → use stale, non-fatal). ✅

🟡 One issue (confidence 80) — left as inline comment

Concurrent cold-clone race in ensureBareCache / cloneBareAtomic. The previous sync version implicitly serialized first-time clones on the event loop; awaiting now lets two concurrent callers for the same uncached repo (different chats aren't serialized by the per-chat queue) both pass the existsSync check and both clone — the loser's renameSync hits ENOTEMPTY and fails that request. Self-healing on retry, but a new regression. Suggested fix: an in-flight Map<cachePath, Promise> dedup, or tolerate ENOTEMPTY at the rename. (See inline comment for details + a one-click fix link.)

Testing note

The CLAUDE.md testing policy is satisfied — async migration is covered by updated cache/manager/event-handler tests plus the new event-loop regression guard. If the race above is addressed, add a concurrent-ensureBareCache regression test alongside it.


  • Gather context (read changed source files)
  • Review cache.ts async changes
  • Review manager.ts / isolation.ts / callers
  • Check for unhandled rejections, races, behavior changes
  • Post review summary

Comment thread src/workspace/cache.ts Outdated
PR review 反馈:ensureBareCache 异步化后,existsSync 检查与 clone 不再原子。
跨会话(不同 chat 不共享 FIFO 队列)并发请求同一未缓存仓库时,两者都会通过
existsSync(false) 后各自 clone,落败方的 renameSync 撞到已存在目录报 ENOTEMPTY,
导致该请求失败(可重试自愈,但属同步版本没有的回归)。

用 in-flight clone Promise 去重(Map<cachePath, Promise>):get→set 之间无 await,
对单线程事件循环原子,并发调用共享同一次 clone,绝不会两次 clone。
新增并发回归测试:同一未缓存仓库的两个并发 ensureBareCache 只触发一次 clone+rename。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @lishuceo's task in 1m 43s —— View job


Code Review — PR #267 (re-review after fix)

Verdict: ✅ Approve — 1 previous issue fixed.

The concurrent cold-clone race flagged in the prior review (github-actions[bot], confidence 80) is correctly resolved in commit d79d706, and the thread is already resolved.

Fix verification — in-flight clone dedup (cache.ts:38,149-156)

let clonePromise = inFlightClones.get(cachePath);
if (!clonePromise) {
  clonePromise = cloneBareAtomic(repoUrl, cachePath).finally(() => {
    inFlightClones.delete(cachePath);
  });
  inFlightClones.set(cachePath, clonePromise);
}
await clonePromise;

Traced and confirmed correct:

  • No await between get() and set() — the whole prefix of ensureBareCache up to await clonePromise is synchronous. Invoking cloneBareAtomic() runs synchronously up to its own first await execFileAsync and returns a pending promise, which is registered before the first suspension point. A second concurrent caller for the same uncached repo is therefore guaranteed to observe the registered promise and reuse it → only one clone + one renameSync → no ENOTEMPTY. ✅
  • .finally(() => delete) clears the entry on both resolve and reject, so a failed clone doesn't poison the cache key — the next request retries cleanly. ✅
  • Keyed by the resolved absolute cachePath (post-normalization), so URL variants of the same repo collapse to one entry. ✅

Regression test (cache.test.ts:303-332)

Genuinely guards the root cause: two concurrent ensureBareCache calls for the same uncached repo with a suspended clone, asserting cloneCalls === 1 and renameSync called once. Pairs well with the existing event-loop-liveness guard (cache.test.ts:272). ✅

Notes

  • Couldn't run typecheck/vitest in this environment (command approval not granted), but the PR documents both passing, and the changes are straightforward to verify by inspection.
  • The CLAUDE.md testing policy is satisfied: async migration + the new concurrency regression test.

No new issues found. Nice tight fix.

@lishuceo lishuceo merged commit 1bfd967 into main Jun 23, 2026
6 checks passed
@lishuceo lishuceo deleted the fix/async-bare-clone branch June 23, 2026 04:15
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