1978 字
10 分钟
为 CodeGraph 修复 inotify 耗尽问题:从 fs.watch 到 chokidar

前几天我在使用 CodeGraph 时发现了一个问题:改完代码后 watcher 不总是能自动更新索引。深入排查后发现这与一个已知的 Linux inotify 内核限制有关,于是动手修了并 提交了 PR

colbymchenry
/
codegraph
Waiting for api.github.com...
00K
0K
0K
Waiting...

背景:CodeGraph 是什么#

CodeGraph 是一个用 tree-sitter 解析代码、构建语义知识图谱的工具,18 天冲到 17k Star。它为 Claude Code、Cursor、Codex CLI、OpenCode 等 AI Agent 提供毫秒级的代码符号搜索——谁调用了这个函数、这个变量定义在哪、改了这个类会影响什么——都是预建的索引直接返回。

Agent 用 CodeGraph 能 减少 50% 以上的 token 消耗和 80% 以上的工具调用。它自带文件监听器,代码改了就自动增量同步索引。

问题:改代码后索引不自动刷新#

在用 OpenCode 对话时,我发现了一个现象:Agent 改了代码,然后用 CodeGraph 去查新写的函数,查不到。只有手动跑 codegraph sync 后才能搜到。

我开始以为是 watcher 压根没有自动同步功能,但翻源码后发现,watcher 是有的——src/sync/watcher.ts 里用 fs.watch(root, {recursive: true}) 监听文件变更,检测到 .ts / .py 等源文件改动后就触发增量 sync。

那为什么我的项目里不生效?

根因:inotify 被 node_modules 吃光了#

翻 GitHub Issues,找到了 #276

fs.watch(root, {recursive: true}) 在 Linux 上为每个目录注册一个 inotify watch —— 包括 node_modules/.git/.next/dist/ 等目录。过滤逻辑写在回调里,此时 watch 早已注册完成。

Linux 内核默认允许一个用户最多持有 65536 个 inotify watch。而 node_modules/ 下面动辄几千个小包目录,每个包里又有自己的 node_modules/——一个中等规模的 monorepo 很容易就跑出几万甚至十几万个目录。

CodeGraph 的递归 watcher 会为每个目录注册 watch,哪怕回调里会把这些目录的事件扔掉。结果是:

  • 单个 codegraph serve --mcp 实例占掉大半的 inotify 预算
  • 同一仓库开两个 agent 会话 → 直接触顶
  • 其他工具报错ENOSPC: System limit for number of file watchers reached
  • 更糟的是,PR #286 发现 agent 会话结束时子进程可能变成孤儿继续跑,积压的僵尸进程带着全部 watch 赖着不走

根本原因是 “先注册,再过滤”。正确的做法应该是 “先过滤,再注册”

方案:用 chokidar 替代 fs.watch#

Issue 里提出了两个修复方向:

  1. 遍历树,手动过滤目录,对通过过滤的目录注册非递归 fs.watch —— 等于自己写一个 mini chokidar
  2. 直接用 chokidar,它的 ignored 回调在注册 watch 之前过滤

我选了方案 2。chokidar 是一个周下载 4000 万+ 的纯 JS 库,早把目录遍历、过滤、watch 管理、跨平台兼容这些 edge case 踩平了。而且它是纯 JavaScript,零 native 编译——不破坏 CodeGraph “self-contained,零编译” 的卖点。

核心改动不到 100 行:

// 之前:先注册所有 watch,回调里再过滤(太晚)
this.watcher = fs.watch(this.projectRoot, { recursive: true }, (_eventType, filename) => {
    if (!isSourceFile(filename)) return;  // 过滤在注册之后
});

// 之后:用 chokidar 的 ignored 回调,先过滤再注册
this.watcher = chokidar.watch(this.projectRoot, {
    ignored: (testPath) => {
        // .gitignore 规则 + .codegraph/ + .git/
        // 返回 true 的路径不会被注册 inotify watch
    },
});

this.watcher.on('all', (_event, filePath) => {
    if (!isSourceFile(filePath)) return; // 防御性过滤仍然保留
    // ... 触发 debounced sync
});

过滤逻辑:从项目根目录向上遍历,加载所有 .gitignore 文件(跟 git 的行为一致),用已有的 ignore npm 包做匹配,同时硬编码排除 .codegraph/.git/

改动量:

文件改动
package.jsonchokidar 依赖(v4,CommonJS 兼容)
src/sync/watcher.ts+90 行:.gitignore 加载 + ignored 回调 + 事件处理重构

对现有功能无影响:WSL2 /mnt/* 自动禁用、CODEGRAPH_NO_WATCH 环境变量、git hook 替代方案全部保持不变。

测试#

单元测试#

vitest __tests__/watcher.test.ts      → 9/9 ✅
vitest __tests__/watch-policy.test.ts → 8/8 ✅
tsc --noEmit                          → clean ✅

所有 watcher 单元测试原封不动通过——chokidar 的事件模型与 fs.watch 兼容,debounce 逻辑、过滤逻辑、lifecycle 全部保持一致。sync 测试因测试环境缺少 node:sqlite 而失败(已有问题)。

inotify watch 实测#

策略#

不能靠目录遍历估算——事后对比发现,fs.watch({recursive: true}) 注册的 watch 数远超肉眼可见的目录数(博客项目目录树 ~9,000 个,实测 watch 数 ~48,000,偏差 5 倍)。必须从内核的真实数据源读取。

测试方法:在同一进程内先后跑旧方案和新方案,各自从 /proc/self/fdinfo/ 读取内核分配的 inotify watch 计数:

1. fs.watch(root, {recursive: true})
2. 等待 3 秒 → 内核完成所有目录的 inotify 注册
3. 遍历 /proc/self/fd/* → 找到类型为 anon_inode:inotify 的 fd
4. 读对应 /proc/self/fdinfo/{fd} → 统计 inotify wd: 行数
5. → OLD 值

6. 关闭旧 watcher
7. 加载 .gitignore 规则链
8. chokidar.watch(root, { ignored: gitignoreFilter })
9. 等待 'ready' 事件 → 确保 watch 全部注册完毕
10. 再次读 /proc/self/fdinfo/*
11. → NEW 值

为什么不用 lsofpgrep、spawn 子进程?

方法问题
lsof -p PID不显示单个 inotify 实例内的 watch 条目数
spawn 子进程 + 父进程读 /proc/{child}stdout 管道捕获不可靠,子进程可能先于测量退出
目录遍历计数严重低估——fs.watch 的内核行为比”每个目录一个 watch”复杂得多

结果#

中等项目(博客,pnpm + Astro + Tailwind,~9,000 个目录):

修复前修复后
inotify watch 数48,368175
节省48,193(99.6%)

小型项目(codegraph 仓库本身,~250 个目录):

修复前修复后
inotify watch 数2,045209
节省1,836(90%)

测试环境内核限额:65,536。旧方案下,博客项目一个 agent 会话就吃掉限额的 74%——开两个会话直接触顶。issue #276 reporter 的 monorepo(~20 万文件)实测每实例 ~44 万个 watch,单项就把预算撑爆 6 次以上。

局限性#

  1. 只测了两次,没算方差。 单次运行。如果 fs.watch 的异步注册在 3 秒内没走完,OLD 值可能偏低。
  2. 只有两个项目,规模跨度有限。 最大的也就 ~9,000 个目录、48k watch,远未达到 issue #276 reporter 的 44 万级别。更大规模项目上 OLD/NEW 的绝对差距会更大,但比例不一定线性。
  3. 每个项目只跑了一轮对比。 没有测多次取均值排除抖动。
  4. 没测并发。 单实例测试,没有验证两个 watcher 同时运行时是否有额外的资源竞争(虽然理论上不会,但没有实测)。
  5. 没测 macOS / Windows。 只测了 Linux。chokidar 在其他平台上使用 FSEvents / ReadDirectoryChangesW,行为可能不同——但对于这个 PR 来说,这两个平台不是问题域。
  6. 修复效果完全依赖 .gitignore 质量。 如果项目没写 node_modules/.gitignore,修复后 watch 数几乎不变。这不是 bug,但需要明确。

对维护者的建议#

如果上游合并这个 PR,建议在 release notes 或 README 中补充一句提醒:

On Linux, the file watcher now respects .gitignore to avoid consuming inotify watches on excluded directories (e.g. node_modules/, dist/, .git/). Make sure your .gitignore covers these directories, or explicitly add CODEGRAPH_NO_WATCH=1 if the watcher still causes issues on very large repos.

同时,如果合并后有人报告 watch 数依然很高,第一反应应该是检查他们的 .gitignore 是否包含了 node_modules/dist/build/.next/ 等常见构建输出目录。

提 PR#

改动确认无误后,fork 了上游仓库,推送分支,提交了 PR:

colbymchenry
/
codegraph
Waiting for api.github.com...
00K
0K
0K
Waiting...

PR #346 已链接到 issue #276,合并后自动关闭。CodeGraph 仓库目前有 60+ 个 open PR,维护者应该比较忙,等待 review 中。


从发现到修复的全过程:用户报 issue → 看代码怀疑 watcher 不工作 → 查 issues 发现是 inotify 预算问题 → 评估修复方案 → 选 chokidar → 改代码 → 跑测试 → 提 PR。整个过程下来,对 Linux inotify 机制、chokidar 的工作原理、以及 CodeGraph 的 watcher 架构有了更深的理解。

开源就是这样——你用一个工具,发现它的 bug,然后顺手把它修了。17k Star 的项目也不例外。

为 CodeGraph 修复 inotify 耗尽问题:从 fs.watch 到 chokidar
https://lorenzofeng.top/posts/codegraph-inotify-fix/
作者
Lorenzo Feng
发布于
2026-05-23
许可协议
CC BY-NC-SA 4.0