Skip to content

fix(repo-scan): 仓库扫描加目录数+时长预算护栏(纵深防御,缓解扫 ~ 拖垮 daemon) - #797

Merged
deepcoldy merged 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/repo-scan-guard
Aug 10, 2026
Merged

fix(repo-scan): 仓库扫描加目录数+时长预算护栏(纵深防御,缓解扫 ~ 拖垮 daemon)#797
deepcoldy merged 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/repo-scan-guard

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

一句话结论

/repo 切换仓库时,若某个 bot 的工作目录配成 ~(家目录),同步的仓库扫描可能钉死整个 daemon 进程——不只当前群的选仓库卡片发不出,同一 daemon 管理的所有其它群也一起"已读不回"。本 PR 加**协作式预算护栏(目录数 + 时长)**作为纵深防御,并在命中预算时给群里回卡片提示收窄根目录。

⚠️ 本 PR 不是对"扫 ~ 卡死"的确定性修复。 事故的直接死因是 readdirSync('~/Downloads') 单次调用陷入内核不返回;预算护栏只在文件系统调用之间检查,打不断已经阻塞在内核态的那一次 readdir。若遍历在预算耗尽前先撞上这种目录,daemon 仍会挂。护栏能挡的是"目录海量 / git 子进程风暴"这类可协作中断的慢扫描——是概率性缓解 + 体验改善,不是根治。彻底修复需把扫描移出 daemon、由父进程施硬超时(能 kill 内核挂死的 syscall),列为 follow-up。

  • 状态:head fix/repo-scan-guard,已明确能力边界、修复护栏回归测试并新增预算命中提示,当前未合并。
  • 已验证:project-scanner 单测(含护栏截断、正向对照、回调触发共 47 条)+ command-handler 单测(含预算命中的两条行为测试);tsc --noEmit 通过。
  • 未验证:真实 daemon 端到端在 ~ 场景下的恢复(需重启目标 bot 后现场复核,见文末)。

如何发现

用户在飞书「Botmux-本地」群连发两次 /repo 想切仓库,第二次只弹出"⚠️ 当前会话已在运行中…请在下方卡片中选择新仓库"的警告,但选仓库的卡片始终不来。随后用户在另一个群(「Devbox Town」)@ 同一个 bot,也"已读不回"。

定位路径:

  1. 查 daemon 日志,锁定那次操作 13:20:48 [a4017e9e] Command: /repo
  2. 对比正常流程:正常应有 Sent repo card with N project(s);而这次警告之后 daemon 再无任何输出,连"扫描完成"日志都没有——说明卡在发卡片之前。实测该次静默持续约 23 分钟(13:20:48 → 13:43:45 期间零日志),13:43:45 才复活并对所有 session 喷 tmux pane probe ETIMEDOUT——同步扫 ~ 钉死事件循环的现场。
  3. 该 bot 的 defaultWorkingDir = '~',第二次 /repo 走到 scanMultipleProjects(['/Users/bytedance'], depth=3)
  4. 用真实扫描逻辑实测家目录:2 分钟未扫完;进一步定位到 readdirSync('/Users/bytedance/Downloads') 单次调用稳定挂起不返回(多次复现,连系统 ls -f 也挂)。
  5. 交叉核对「Devbox Town」群:它与「Botmux-本地」同属一个 local bot(cli_a97771991eb8dccb)、同一个 daemon 进程(pm2 id 0);进程被扫 ~ 占死期间,该 bot 名下所有群都无法处理消息——这解释了"另一个群也不理我"。

用户看到什么

触发方式 旧表现 本 PR 后表现
会话运行中再发 /repo(bot workingDir=~慢扫描但可中断 只弹警告,选仓库卡片永不出现 命中预算即返回:有部分结果则发卡片 + 追加"列表可能不全,请收窄根目录"提示;无结果则回"根目录过大/请指定路径"提示,不再误报"未找到仓库"
会话运行中再发 /repo单目录内核挂死,如 Downloads 选仓库卡片永不出现,daemon 静默钉死 ⚠️ 仍会挂死(护栏打不断内核态 readdir);根治见 follow-up,当前须收窄根目录规避
同一 daemon 下其它群 @ 该 bot 一起"已读不回" 仅"可中断慢扫描"场景下恢复正常;"内核挂死"场景仍受牵连
daemon 日志 卡片前无任何输出,进程静默钉死 命中预算时 logger.warn 明确提示收窄 workingDirs

根因

scanProjectssrc/services/project-scanner.ts)是完全同步的递归遍历:readdirSync + 每个 git 仓库 fork 一次 git worktree list 子进程。当扫描根是家目录 ~ 时有两重杀伤:

  1. 量级(可协作中断):家目录下目录海量、git 仓库众多,同步遍历 + 子进程风暴本身就极慢——但每次 fs 调用会返回,护栏能在调用间截断。
  2. 挂起(不可协作中断):家目录里存在会让 readdirSync 永久阻塞的目录(本机是 ~/Downloads,单次 readdir 稳定不返回)——这次事故的直接死因。护栏无法中断已陷入内核的单次 syscall。

因为扫描同步执行且无任何上限,一旦卡住就钉死 daemon 事件循环。而一个 daemon 进程服务多个群,于是"一次扫 ~ 卡死 → 拖垮该进程下所有会话"。

改了什么

1. src/services/project-scanner.ts — 协作式预算护栏

scanProjects 增加两道预算,在文件系统调用之间检查:

  • maxScanDirs(默认 DEFAULT_MAX_SCAN_DIRS = 4000):访问目录数上限,挡"海量目录 + git 子进程风暴"。
  • maxScanMs(默认 DEFAULT_MAX_SCAN_MS = 4000ms):墙钟时长上限,挡目录数不高但每仓库 git 子进程慢的"仓库风暴"。

命中任一预算即停止遍历,返回已找到的部分结果,logger.warn 提示收窄,并回调 onBudgetExceeded 把"结果可能不全"的信号交给调用方。均可通过 ProjectScanOptions 覆盖;默认值对正常 projects 根目录(毫秒级扫完)无影响。

关于时长承诺的诚实修正:护栏不保证 "4s 后 daemon 必然恢复"——每发现一个 repo 会跑 ≥2 个各 5s 超时的同步 git 子进程(getGitCommonDir + worktree list),期间无预算检查;且 scanMultipleProjects 对每个 root 重置预算,K 个 root 累计约 K×4s。净收益仍在(把"无限"降为"有界且可中断"),但不是确定性时限。

2. src/core/command-handler.ts + i18n — 提示卡片

/repo 扫描命中预算时,通过 onBudgetExceeded 感知,给群里回明确提示而非静默/误报:

  • 命中预算且无结果cmd.repo.scan_budget_no_repos:"根目录过大或读取过慢,已中止;请用 /repo <路径> 指定或收窄 workingDirs",并注明"若某目录系统级读取卡死,本护栏无法中断"。
  • 命中预算但有部分结果 → 先发 cmd.repo.scan_budget_partial:"列表可能不完整",再照常发卡片。

诚实边界:此提示卡片只在"可中断慢扫描"场景有用;真·内核挂死时事件循环已停摆,卡片同样发不出去——它也是纵深防御,不是根治。

观察到的修复结果

  • test/project-scanner.test.ts47 passed。其中修复了评审指出的目录护栏假绿:旧断言 toBeLessThanOrEqual(1) 即使整段删掉目录护栏也仍绿;改用顺序无关单链 fixturemkRepo('a/b/c/d/e/repo') + maxScanDirs:5 → toEqual([]),再 maxScanDirs:999999 → toHaveLength(1) 正向对照)。变异验证:把目录护栏分支改成永不触发后该用例立即 FAIL。新增 onBudgetExceeded 的 dirs/time 触发 + 预算内不触发共 3 条。
  • test/command-handler.test.ts244 passed,含 /repo 相关路径回归,并新增两条预算命中的行为测试:mock 的 scanMultipleProjects 主动触发 onBudgetExceeded 回调,① 回 [] → 断言发 scan_budget_no_repos 提示、不误报「未找到仓库」、且不发选仓库卡;② 回一个 repo → 断言先发 scan_budget_partial 再发卡。变异验证:把回调体清空(() => {})后这两条同时 FAIL——补上了「回调在但没干活」这类只测 wiring 抓不到的假绿。
  • tsc --noEmit 退出 0。
  • 全量套件:本 PR 相关的两个文件(scanner + command-handler)共 291 passed;仓库既有的 PTY/tmux/CoCo-snapshot 等 e2e 用例存在与本改动无关的环境性超时失败multi-bot-session.e2e.ts 等,改动前即失败),见下方"仍未验证"。

仍未验证什么

剩余检查 需要的环境 / 原因 影响范围
真实 daemon 在 ~ 场景下重启后恢复响应 需重启目标 bot 的 daemon(影响在线会话,需用户授权) 阻塞发布验收,不阻塞代码合并
单次 syscall 挂起的彻底兜底(出口 A 护栏只在调用间检查,无法中断已阻塞在内核态的 readdir;根治需把扫描移到带硬超时的 worker/父进程 已知边界,列为 follow-up;不阻塞本 PR
全量测试套件 既有 PTY/tmux/CoCo e2e 用例在本机环境超时失败,改动前即存在,与本 PR 无关 不阻塞合并;需 CI 环境确认这些 e2e 的基线状态

治本建议(配置层 + 出口 A)

  • 配置层(立即可做):不要把 bot 的 workingDir/defaultWorkingDir 配成 ~,改成具体 projects 根目录(如 /Users/bytedance/Documents),从源头避免扫描家目录。
  • 出口 A(follow-up):把仓库扫描移出 daemon 主进程,由父进程对子进程施硬超时(可 kill 掉陷在内核态的 syscall),才能对"单目录挂死"确定性兜底。

本 PR 定位为纵深防御 + 体验改善,不声称已修复"扫 ~ 挂死"。

🤖 Generated with Claude Code

@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner August 9, 2026 06:02
@xiaoxueSunn xiaoxueSunn changed the title fix(repo-scan): 给仓库扫描加目录数+时长双护栏,防止扫 ~ 卡死 daemon fix(repo-scan): 仓库扫描加目录数+时长预算护栏(纵深防御,缓解扫 ~ 拖垮 daemon) Aug 10, 2026
/repo 切换仓库时同步扫描仓库;若 bot workingDir 配成 ~(家目录),
海量目录 + git 子进程风暴会长时间钉死 daemon 事件循环,拖垮该
daemon 下所有群。本改动是纵深防御,非确定性根治:护栏只在 fs 调用
之间检查预算,无法中断已阻塞在内核态的单次 readdir(如 ~/Downloads);
确定性兜底需把扫描移出 daemon、由父进程施硬超时,列为 follow-up。

- project-scanner: 增 maxScanDirs(默认4000)/maxScanMs(默认4000ms)
  两道预算,命中即返回部分结果并 logger.warn;新增 onBudgetExceeded
  回调把「结果可能不全」交给调用方。
- command-handler /repo: 经回调感知预算命中,无结果时回「根目录过大
  /请指定路径」提示、有部分结果时追加「列表可能不全」提示,替代原来
  的静默/误报「未找到仓库」。
- i18n: 新增 cmd.repo.scan_budget_no_repos / scan_budget_partial (zh/en)。
- 测试: 修复目录护栏假绿(改顺序无关单链 fixture + 正向对照,变异验证
  过);新增两条 command-handler 预算命中行为测试(mock 主动回调,断言
  no_repos 提示不发卡 / partial 提示先于卡),清空回调体变异时两条同时
  FAIL;新增 onBudgetExceeded dirs/time/预算内不触发用例。
  scanner 47 + command-handler 244 全绿,tsc 通过。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

合并协调说明(与 #756 的关系)

本 PR(#797)与 #756 修的是同一个根问题——扫描仓库时同步遍历可能拖垮 daemon——但处在不同层次,互补而非二选一

合并顺序与冲突:经本地实测,两者会在 src/core/command-handler.ts/repo 处理块(约 2032–2043 行)+ i18n 键上文本冲突——两边都重写了同一段(#797 改为「同步 scan + onBudgetExceeded」,#756 改为「await scanMultipleProjectsAsync(...)」)。

但语义不矛盾#756 的子进程内部仍然调用同一个同步 scanProjects#756 并未修改 project-scanner.ts),因此本 PR 的预算护栏会照样在 #756 的子进程里生效——两层叠加最稳(预算让单次扫描本身有界,watchdog 兜底内核挂死)。

按维护者决定:先合本 PR(#797)快速止血#756 后续合并时,把该处 /repo 冲突解成「使用异步 scan,但保留 onBudgetExceeded 的部分结果提示语义」即可干净合流。

@github-actions

Copy link
Copy Markdown

🚀 Released in v3.12.1

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