前几天我在使用 CodeGraph 时发现了一个问题:改完代码后 watcher 不总是能自动更新索引。深入排查后发现这与一个已知的 Linux inotify 内核限制有关,于是动手修了并 提交了 PR。
背景: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 里提出了两个修复方向:
- 遍历树,手动过滤目录,对通过过滤的目录注册非递归
fs.watch—— 等于自己写一个 mini chokidar - 直接用 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.json | 加 chokidar 依赖(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 值为什么不用 lsof、pgrep、spawn 子进程?
| 方法 | 问题 |
|---|---|
lsof -p PID | 不显示单个 inotify 实例内的 watch 条目数 |
spawn 子进程 + 父进程读 /proc/{child} | stdout 管道捕获不可靠,子进程可能先于测量退出 |
| 目录遍历计数 | 严重低估——fs.watch 的内核行为比”每个目录一个 watch”复杂得多 |
结果
中等项目(博客,pnpm + Astro + Tailwind,~9,000 个目录):
| 修复前 | 修复后 | |
|---|---|---|
| inotify watch 数 | 48,368 | 175 |
| 节省 | — | 48,193(99.6%) |
小型项目(codegraph 仓库本身,~250 个目录):
| 修复前 | 修复后 | |
|---|---|---|
| inotify watch 数 | 2,045 | 209 |
| 节省 | — | 1,836(90%) |
测试环境内核限额:65,536。旧方案下,博客项目一个 agent 会话就吃掉限额的 74%——开两个会话直接触顶。issue #276 reporter 的 monorepo(~20 万文件)实测每实例 ~44 万个 watch,单项就把预算撑爆 6 次以上。
局限性
- 只测了两次,没算方差。 单次运行。如果
fs.watch的异步注册在 3 秒内没走完,OLD 值可能偏低。 - 只有两个项目,规模跨度有限。 最大的也就 ~9,000 个目录、48k watch,远未达到 issue #276 reporter 的 44 万级别。更大规模项目上 OLD/NEW 的绝对差距会更大,但比例不一定线性。
- 每个项目只跑了一轮对比。 没有测多次取均值排除抖动。
- 没测并发。 单实例测试,没有验证两个 watcher 同时运行时是否有额外的资源竞争(虽然理论上不会,但没有实测)。
- 没测 macOS / Windows。 只测了 Linux。chokidar 在其他平台上使用 FSEvents / ReadDirectoryChangesW,行为可能不同——但对于这个 PR 来说,这两个平台不是问题域。
- 修复效果完全依赖
.gitignore质量。 如果项目没写node_modules/到.gitignore,修复后 watch 数几乎不变。这不是 bug,但需要明确。
对维护者的建议
如果上游合并这个 PR,建议在 release notes 或 README 中补充一句提醒:
On Linux, the file watcher now respects
.gitignoreto avoid consuming inotify watches on excluded directories (e.g.node_modules/,dist/,.git/). Make sure your.gitignorecovers these directories, or explicitly addCODEGRAPH_NO_WATCH=1if the watcher still causes issues on very large repos.
同时,如果合并后有人报告 watch 数依然很高,第一反应应该是检查他们的 .gitignore 是否包含了 node_modules/、dist/、build/、.next/ 等常见构建输出目录。
提 PR
改动确认无误后,fork 了上游仓库,推送分支,提交了 PR:
PR #346 已链接到 issue #276,合并后自动关闭。CodeGraph 仓库目前有 60+ 个 open PR,维护者应该比较忙,等待 review 中。
从发现到修复的全过程:用户报 issue → 看代码怀疑 watcher 不工作 → 查 issues 发现是 inotify 预算问题 → 评估修复方案 → 选 chokidar → 改代码 → 跑测试 → 提 PR。整个过程下来,对 Linux inotify 机制、chokidar 的工作原理、以及 CodeGraph 的 watcher 架构有了更深的理解。
开源就是这样——你用一个工具,发现它的 bug,然后顺手把它修了。17k Star 的项目也不例外。