报告很吓人:basemind 的缓存从 1.8 GB 直接膨胀到 4 GB,而 CPU 像被钉死了一样长时间 100% 占用。任何看到这种数据的人第一反应都是去翻扫描器的源码,看看哪个循环没关、哪个释放忘了做。但这位开发者没有。他先看的是活着的系统。

ps 和 lsof 甩出来的信息立刻改了侦查方向:有一个进程正嚼着大概 70% 的 CPU,手里还死死抓着索引不放手。关键是,这进程是 serve,而不是 scan。症状是指向扫描的,真正占资源的是服务进程,两件事八竿子打不着。这时候如果一头扎进扫描器代码,只会被错误线索带偏。

接下来就是一个灵魂拷问:这到底是 live bug 还是旧版本的残废?开发者没有迷信任何版本号,Homebrew 说的、PATH 里躺着的、编辑器插件报告的,全不一样。所以直接翻 git 历史才是正道。

git log 扫一遍 watcher 相关的提交,再用 git merge-base --is-ancestor 和提交日期一对,结论立刻明朗:一个标题写着“fix: stop watcher CPU runaway”的提交,是在当前运行的二进制文件被编译出来四天后才进入代码库的。也就是说,那个把 CPU 吃到满的 bug 其实早就修了。眼前这个吞资源的进程,不过是另一个编辑器会话里留下的一段僵尸代码,一个被遗忘的旧版本。

两条铁律就这么被掂了出来:第一,永远核查实际在跑的工件,别信任何中间人报给你的版本。第二,“正在发生的 bug 和历史遗留的旧构建”,永远是第一个该问的问题。把修复日期和运行版本对一下,很可能调查还没开始就已经结束了。

把那个幽灵进程杀掉之后,真正的扫描才显形。CPU 直接蹿到 488%,几乎吃满五核。这是 rayon 把所有核都榨干的典型表现,单从数值上根本分不清到底是正常的多核扫描,还是某个单线程跑进了死循环。所以这次不看单点数值,而是同时盯三个信号随时间的变化:CPU 利用率、缓存大小、blob 写入数量。

磁盘使用量先快速爬升,然后在一个水平线上放缓。blob 写入经历一次爆发后戛然而止。CPU 在上升之后又慢慢回落到只吃一个核。这三条曲线的走向拼在一起,答案就是:这是一次会收敛的正常重建,不是内存泄漏,也不是失控的写入循环。泄漏会一直申请新内存,死循环会永远产生磁盘写入,而真正的扫描终将抵达稳态——诊断的关键不是某个瞬间的数字,而是它的一阶导数。

为了不做人肉监控,开发者让 Claude 写了几个后台监测脚本。shell 循环每 30 秒采一次样,只在状态切换时才吐出信息。一个脚本负责识别“扫描完成并进入空闲”,另一个专门盯着“连续 4 分钟 CPU 为 0 且磁盘没任何增长”。这些告警本身就嵌入了先验假设:真正挂起时,CPU 和磁盘应该双双趴平;健康扫描要么 CPU 高,要么磁盘在长。脚本不是单纯记录数据,而是实时验证着最初的那个判断。

然后新的麻烦来了。一个多工作树测试突然陷入诡异的静止:扫描进程卡死在 0% CPU,连去探查它自己的 lsof 命令都一起冻住了整整两分钟。

lsof 卡住意味着被盯上的 socket 出了大问题。翻到最后一行日志,里面只有几个字:Registering VLM OCR backend。原来 OCR 这一层在启动时会发起一个没有超时设置的网络调用,而防火墙直接把这个连接吞了,整个线程就永远封在了那里。

这次没在原地和这个 hang 缠斗。OCR 层在整个项目里是通过一个 cargo feature 选择启用的,不启用就不会编译进来。所以接下来做的是立刻切回默认 feature 构建,生成一个完全不可能触发那条网络请求的二进制文件,然后把扫描器单独隔离出来观察。当两个故障纠缠在一起,第一时间要做的不是猜谁是谁,而是把一个嫌疑犯从现场里完全搬走。

整个调试会话从“现在装的是哪个版本?”开始,到最终拿到 7 倍的扫描提速、提交了两个上游 PR、并永久关闭了一个特性为止。整个过程不是靠逐行啃源码,而是一步一步用系统级信号和工具链的特性,把真凶从一堆似是而非的表象里剥离出来。