AI agent 把我的 Mac 撑到内核 panic:追到一行 join_all
两次 macOS 内核 panic,签名都是 launchd 收到 SIGBUS。顺着 stackshot 追到 pagefind 写索引时的无界并发把系统文件表撑满,修复已提交上游,并附自查办法和开源诊断工具 panicsight。
10 月 15 日下午到 16 日凌晨,我的 MacBook Pro 死机重启了两次,间隔 8 个半小时。重启后弹出的报告只有一行有用的信息:initproc exited -- exit reason namespace 2 subcode 0xa。查到最后,凶手是一个每月下载九百万次的开源工具里的一行并发代码;而让这行代码有机会出事的,是我让 AI agent 同时在这台机器上跑了太多活。这篇记录排查过程,也给同样在 Mac 上重度使用 coding agent 的人一个自查办法。

两次一模一样的死机
两次死机分别发生在 16:14 和 00:43,机器是 M5 Max、128 GB 内存,系统是 macOS 27.0.1。两份 panic 报告的签名完全相同:编号为 1 的进程收到信号 10 退出了。编号 1 的进程就是 launchd,它负责拉起和看管系统里所有其他服务;它一退出,内核没有任何办法继续,只能 panic。信号 10 是 SIGBUS。
排查先走了两段弯路。第一段是怀疑外置硬盘:家里一块 20 TB 的机械盘每晚会休眠,可第二次死机时,定时任务已经把这块盘的两个卷卸载了三个小时,launchd 也碰不到它。第二段是查系统日志,连续几次查到的都是空结果,我一度以为死机前日志断了;后来发现 zsh 有个内建命令也叫 log,它会盖掉系统的 /usr/bin/log,查询悄悄返回空。换成绝对路径,日志一切正常。
内存也排除了。两次 panic 的报告里,内存压力都是 false,swap 余量充足。
launchd 到底死在哪一步,答案写在 panic 报告附带的那份 stackshot 里。它是内核在死机瞬间给每个进程、每个线程拍的快照,包括用户态的调用栈地址。我用本机同版本的 dyld 和 launchd 二进制,把 launchd 崩溃线程的地址还原成函数名,两次 panic 逐帧一致:
- launchd 收到一个请求,要拉起某个按需启动的服务,拉起返回了一个不在预期范围内的错误码。
- launchd 对这个错误码做断言检查。断言本身不会让进程退出,它只是要写一条日志。
- 写日志时,它调用
dladdr去查调用方属于哪个二进制,好把名字写进日志。 dladdr在 dyld 里遍历已加载镜像,第一条读内存的指令ldr w9, [x0]收到了 SIGBUS。
SIGBUS 和 SIGSEGV 的区别是这一层的关键。在 macOS 上,访问一个没有映射的地址,得到的是 SIGSEGV;地址已经映射,但这一页拿不到,才是 SIGBUS。拿不到最常见的原因是 page-in 失败:这一页对应磁盘上的文件,很久没用过,内存里的副本早就回收掉了,这次要从磁盘读回来,读失败了。
我的推断是,launchd 这次读到的 Loader 对象就在这样一个冷页上。正常运行的系统里,page-in 几乎不会失败,所以真正的问题变成了:那一刻,是什么让内核连一个文件页都读不回来。
凶手藏在 stackshot 里
一份构建日志给了第一个直接证据:panic 发生前,agent 正给一个约 51.6 万页的静态网站做构建,日志的最后一行是 ENFILE: file table overflow,时间是 16:14:45。ENFILE 的意思是系统级的文件表满了,整台机器再也无法打开任何文件。4 秒之后,16:14:49,机器 panic。
这台机器的文件表上限是 491,520。我之前因为另一次死机,装过一个每两分钟采样一次的监控。它显示平时全系统打开的文件大约 1.1 万个;那天却有好几次在两分钟内冲到 20 万以上,空闲 vnode 跌到 12% 到 17%,然后又自己回落。采样太粗,panic 那一刻的峰值没有采到。
第二个证据就在 stackshot 里。我数了每个进程有多少线程处在不可中断的等待状态,并且内核栈里有 APFS 文件系统驱动。两次 panic 的结果几乎一样:全系统分别有 549 个和 531 个这样的线程,其中 512 个属于同一个进程 pagefind_extended。这 512 个线程里,两次都有 511 个在等同一个等待对象,也就是同一把锁;第二次排在这把锁后面的还有 Finder、node 和一个 Python 进程,各一个线程。剩下卡在 APFS 里的是 fseventsd、Finder、kernel_task 这些系统进程,它们跟着一起卡住了。
这只 pagefind 进程的两次存活时间分别是 473 秒和 72 秒。第二次的时间线是:网站渲染在 00:42:15 结束,pagefind 在 00:43:09 完成索引、开始往磁盘写结果,写了大约 18 秒,机器 panic。
一行 join_all 和 32 的上限
Pagefind 是给静态网站做站内全文搜索的工具,由 CloudCannon 开源,用 Rust 写成。它在网站构建完成后扫描所有页面,生成切好片的索引文件,让纯静态托管的网站也能搜索。npm 上的 pagefind 包最近 30 天下载约 909.6 万次,Astro 的文档框架 Starlight 默认就用它。
问题出在它把索引写进磁盘的那段代码里。源码读完,症结是三件事的叠加。
- 它给每个输出文件创建一个异步任务,然后用
join_all一次性全部等待。一个几十万页的网站,就是几十万个同时开始的写文件任务。 - 每个任务先创建文件,再做最高级别的 gzip 压缩,写完才关闭。文件描述符从创建到写完一直占着。
- 创建文件失败时,它每隔 100 毫秒重试一次,永远不报错,文件表满了也只是继续重试。
Rust 的异步运行时 tokio 用一个线程池执行这类文件操作,默认上限正好是 512 个线程。stackshot 里那 512 个卡在 APFS 的线程,就是这个线程池占满之后的样子。截至我提交修复时,上游最新的代码仍然是这样写的,也没有人报告过这个问题。
修复改了三点。同一时刻最多写 32 个文件;先压缩好内容,再打开文件,写完立刻关闭;只对文件表满这类临时错误重试,最多 50 次,之后带着文件路径报错。
我在另一台 16 GB 内存的 Mac mini 上做了对比:
| 场景 | 原版线程峰值 | 原版打开文件峰值 | 补丁版线程峰值 | 补丁版打开文件峰值 |
|---|---|---|---|---|
| 合成网站 3 万页 | 529 | 29,138 | 89 | 38 |
同样 3 万页,补丁版从 45 秒降到 29 秒,输出的 3 万个索引片段与原版逐字节一致。合成的 50 万页网站,补丁版全程最多只开 38 个文件,109 秒跑完,输出 50 万个文件。出事的那个网站这时已经涨到约 51.8 万页,也用补丁版在 mini 上跑完了索引。修复已经提交给上游:pagefind PR #1380。
同样的签名,别人的报告里也有
查资料时我发现,同样的 panic 签名今年已经出现过好几次。起因各不相同,机制是同一个。
- Claude Code 的 issue #101116:launchd 同样在
dladdr里收到 SIGBUS,起因是一个遍历目录的工具同时持有大量目录描述符。 - Claude Code 的 issue #35556:空闲 vnode 只剩 0.82%,把
kern.maxvnodes加倍后不再 panic。 - mcp-language-server 的 issue #155:文件监听器持有约 20 万个描述符,日志里先出现
FS pagein error: 23 Too many open files in system,随后 launchd 崩溃。 - 一篇 日文文章:Spotlight 索引 node_modules 把 vnode 顶满,同样是这个 panic。
我的理解是,AI coding agent 改变了一台开发机的负载形态。以前一个人同时只跑一个构建;如今几个 agent 会同时构建、索引、监听文件、拉起子进程,机器可能几天不重启。某个工具里一个平时无害的无界并发,在这种负载下就会把桌面系统从来碰不到的内核表推到上限。撞上上限之后,最先死的不一定是那个工具,可能是任何一个刚好需要读回一页代码的进程;如果这个进程是 launchd,整台机器就没了。
如果你的 Mac 也出现了 initproc exited -- exit reason namespace 2 subcode 0xa,可以先做三件事:
- 打开
/Library/Logs/DiagnosticReports/下最新的panic-full-*.panic,确认签名一致。 - 在报告的 stackshot 里数每个进程卡在文件系统驱动里的线程数,排在第一的那个很可能就是凶手。
- 平时用
sysctl kern.num_files kern.maxfiles看文件表余量,跑大型构建或索引时盯一下。
手工做第二步费时费力,我把这次用过的脚本整理成了一个开源工具 panicsight,用它分析我这两份 panic 报告,第一行就指向了 pagefind_extended。v0.1 已经能读入 panic 报告,给出死机类型和最可疑的进程;panicsight check 能看当前文件表和 vnode 表还剩多少余量。持续监控模式还在开发,欢迎来提 issue。
