如果你常年泡在 Linux 终端里,一定用过或者至少听说过 pstack 这个命令——一行pstack <pid>,就能把某个进程当前执行栈整个倒出来,像做一次体外透视。我把 Claude Code 当作日常编码主力之后,最常遇到的噩梦不是代码报错,而是它彻底卡住:光标还在闪,任务栏 CPU 那个小绿条已经压满,终端里 Ctrl+C 怎么按都没反应。这时候我第一个念头永远是 pstack,可打过去才发现,pstack 对 Claude Code 这种 Node.js 进程基本是隔靴搔痒。折腾了半个晚上,我把"给 Claude Code 进程做堆栈诊断"这件事做成了一个小项目,名字就叫 pstack-claude。这篇文章记录的是这个工具的来龙去脉,以及我在真实排障过程中总结出来的 Claude Code 进程体检思路,适合所有被 Claude Code 卡死、高 CPU、假死折磨过的开发者参考。
1. Claude Code 卡死时,传统 pstack 为什么使不上劲
1.1 pstack 原本是给原生进程准备的
pstack 不是一个新东西。早年排查多线程服务卡顿,第一反应就是pstack $(pgrep -f 服务名)。它本质上是调用 ptrace 附加到目标进程,读取每个线程的寄存器,把栈回溯出来,最后以"函数名+偏移地址"的形式打印到标准输出。对一个用 C/C++ 写的服务来说,这份栈非常直观,你能直接看到它卡在哪个系统调用、哪个锁或者哪个第三方库里。
但 Claude Code 是一个 Node.js 程序,跑的是 V8 引擎的 JavaScript。你拿 pstack 怼上去,看到的堆栈会是这个样子:
Thread 1 (process 27183): #0 0x00007f71d9a8f0d9 in epoll_wait () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x0000000000aa56c3 in uv__io_poll () from .../node #2 0x0000000000a9e37e in uv_run () from .../node #3 0x00000000009b2def in node::Start () from .../node #4 0x00000000009ad0b7 in main () from .../node然后呢?没了。这套栈最多告诉你"它进入了 libuv 的事件循环、正在等 socket 事件",但完全看不出是哪个 JS 函数卡住了,是卡在等待 Anthropic API 响应,还是卡在本地 MCP 子进程的 IPC 上,还是陷入了某个文件的读取重试。对排障来说,这个信息量几乎等于零。
1.2 我们真正需要的是 JS 级堆栈
Claude Code 本质上是 V8 虚拟机里跑的一堆 JavaScript 异步任务。进程层面卡住,绝大多数时候不是 C 层死循环,而是某个 Promise 永远没有 settle,或者某个事件循环阶段被一个同步的大计算霸占。这两类问题,C 栈完全照不出来。
举个我遇到过的典型例子:某次 Claude Code 在做一个大规模批量重构,终端突然停止输出。pstack 出来的栈和上面的样例一模一样,所有线程都停在epoll_wait——看似什么都没干。但实际情况是,V8 内部有个同步操作正在执行一个长达几分钟的正则回溯,整个事件循环被堵死,epoll 根本没有新事件进来。这种问题要抓 JS 栈才能定位。
所以要给 Claude Code 做诊断,正确路线是拿到 V8 层面的 JavaScript 堆栈,而不是去读 C 栈。
1.3 pstack-claude 的定位:一个面向 Claude Code 的 JS 栈采样器
pstack-claude 做的事情很简单:它把经典 pstack 的"给我一份当前栈"这个意图,翻译成 V8 能理解的语言。具体来说,它做了三层封装:
- 定位:通过
pgrep -f claude之类的逻辑找到 Claude Code 真正在跑的那个 Node 进程,注意是 Node 子进程而不是外层 Electron 壳。 - 采样:给目标进程发送信号触发 V8 inspector,让 V8 按固定间隔记录当前正在执行的 JS 函数栈,连续采样几秒。
- 汇总:把几百上千个采样点按函数调用路径做聚合,输出"哪个函数出现的次数最多、占用了多少采样时间",最后生成一份类似
profiler.log但又经过简化的报告。
后面你会发现,这个三层逻辑听上去简单,但每一层都藏着非常多的环境坑,尤其是当 Claude Code 被跑在 VS Code 集成终端、WSL、或者容器里的时候。
2. pstack-claude 的构造思路:把 V8 栈翻译成人话
2.1 为什么不能用 gdb 直接 x/stepping
有人可能会问,既然 Node 进程本身就能被 gdb 附加,直接用 gdb 看线程栈不就行了?这个问题我踩过。gdb 确实能看到更细的 C 栈,但有两个问题:一是 Claude Code 的进程往往有多个子进程,你很难一眼看出哪个子进程真正负责当前任务;二是即便你用 gdb 抓到了 V8 机器码栈,也只是一堆v8::internal::Builtin_*内部的地址,需要额外的符号映射才能还原成 JS 函数名,而这个符号映射过程在 Node 的高版本里并不稳定。
所以在 pstack-claude 的设计里,我抛弃了 gdb,改用 V8 自带的 inspector 协议。
2.2 核心机制:SIGUSR1 打开 V8 Inspector
Node 内置了一个信号处理机制:向运行中的 Node 进程发送SIGUSR1,它会开启 inspector 的调试端口(默认是 127.0.0.1:9229),你可以从这里连接 WebSocket,下发 CPU Profiler 指令。这个机制原本是给调试器用的,但用来做进程采样同样可行。
pstack-claude 的核心代码并不长,思路如下:
// pstack-claude 的采样核心(简化版) const inspector = require('node:inspector'); const session = new inspector.Session(); session.connect(); session.post('Profiler.enable', () => { session.post('Profiler.setSamplingInterval', { interval: 100 }, () => { session.post('Profiler.start', () => { // 采样 3 秒 setTimeout(() => { session.post('Profiler.stop', (err, { profile }) => { if (err) { console.error('stop failed:', err.message); return; } const top = aggregateHotStacks(profile); console.log(formatReport(top)); session.disconnect(); }, 3000); }); }); }); });这段脚本运行时有一个前提:目标进程必须开启了 inspector 端口。pstack-claude 的做法是,先找到 Claude Code 的 PID,然后用process.kill(pid, 'SIGUSR1')给目标进程发信号,等待一两秒让 inspector 就绪,再连接 9229 端口。如果端口被占用,它也能自动尝试 9230、9231 等后续端口。
2.3 聚合策略:不要打印一千行,只打印热点
原始Profiler.start / stop返回的 profile 对象里通常有几百上千个 node 节点。直接全量打印,人能看疯掉。pstack-claude 的策略是做一个"热点聚合":
function aggregateHotStacks(profile) { const samples = profile.samples || []; const timeDeltas = profile.timeDeltas || []; const map = new Map(); for (let i = 0; i < samples.length; i++) { const nodeId = samples[i]; const cost = timeDeltas[i] || 0; const path = resolveCallPath(profile, nodeId); // 把 nodeId 还原成调用链 const topFn = path[path.length - 1]; map.set(topFn, (map.get(topFn) || 0) + cost); } return [...map.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 20); }最后输出的报告长这样:
Hot functions from 3124 samples over 3.1s (sampling interval: 100us) 1. 47.2% ?? (native) -> C++ retry loop [node: fs.renameSync] 2. 22.8% await fetch / network idle [node: http2 connect] 3. 12.1% child_process.pipe reading [node: IPC channel] 4. 8.4% JS RegExp backtrack [internal/regexp] ...里面的具体文字会根据现场情况变化,但总体思路一致:让你第一眼就知道 CPU 时间到底烧在哪段逻辑上。
3. 安装、连 PID 与第一次采样实操
3.1 环境要求与安装
pstack-claude 是一个纯 Node 脚本,通过 npm 全局安装即可:
npm install -g pstack-claude它最低要求 Node 16 以上。为什么必须是 16?因为node:inspector里Profiler.setSamplingInterval这个接口从 Node 12 之后行为才稳定,而 Claude Code 官方运行环境一般都高于这个版本,所以用 16 作为底线比较保守,也不会有兼容性负担。
安装完可以先跑一下版本号确认:
pstack-claude --version3.2 第一次采样:找到 PID 并抓栈
假设 Claude Code 正在某个终端里跑任务,你在另一个终端执行:
# 列出所有 claude 相关进程 pstack-claude ps # 自动定位主进程并采样 5 秒 pstack-claude attach --duration 5如果不指定 PID,pstack-claude 会按照下面的优先级去找目标进程:
- 名为
claude的进程; - 命令行里包含
@anthropic-ai/claude-code的 Node 进程; - 命令行里包含
claude-code.js的 Node 进程。
这个优先级很重要。我在实际使用中发现,Claude Code 的进程树比想象中复杂:当你在 VS Code 的集成终端里启动它,外面可能套了code的 Electron 进程,里面才是真正的 Node 运行时。如果选错了 PID,采样到的栈会把 Electron 的渲染任务当成 Claude Code 的任务,报告完全失真。
3.3 和 VS Code 里的 Claude Code 怎么配合
这里有个很实用的经验:如果你用的是 VS Code 的 Claude Code 扩展,不要直接对code进程采样,而是要先找到它的扩展宿主进程。通常它的命令行里带--extensionHost标志,子进程会多出很多,pstack-claude 的ps子命令会把这个列表完整打出来。
拿到列表后,你可以手动指定 PID:
pstack-claude attach --pid 27183 --duration 5第一次跑通的时候,你会看到一份正常的堆栈报告。如果报告里全部是epoll_wait之类的空闲事件,千万别慌,这是采到了进程的空闲状态;继续让 Claude Code 执行一个正在耗费 CPU 的任务,比如让它批量重写几十个文件,然后再采样,这时候热点才会突显出来。
4. 一个真实案例:auto-update 卡死是怎么被揪出来的
4.1 现象与初步排查
有次 Claude Code 启动后一直卡在"正在检查更新"的阶段。终端输出停在Checking for updates...,过了十分钟仍然没有进入对话界面。常规手段先走一遍:ps aux | grep claude确认进程还在,CPU 占用不高,状态是 S(睡眠)。此时用 pstack 看,又是千篇一律的 epoll 栈,没有参考价值。
我改用 pstack-claude 采样,报告很快指向了一个内部函数路径:
62.5% (node:fs) -> fs.renameSync -> (internal/modules) retry loop 18.2% (node:timers) -> setTimeout -> update flow wait 9.0% (node:child_process) -> spawnSync npm prefix check看到fs.renameSync和npm prefix check这两个线索,我意识到问题出在自动更新流程:Claude Code 启动时会把已下载的新版本解压,然后通过fs.renameSync把新文件替换进全局 node_modules 目录。这一步撞上了权限问题,系统盘 node_modules 目录没有当前用户的写权限,于是renameSync反复重试,整个启动流程被死死卡在这个同步操作上。
4.2 复现与确认
我用下面的命令试了一下:
npm prefix -g ls -ld "$(npm prefix -g)/@anthropic-ai/claude-code"结果ls直接报权限不足。再切到 root 用户操作一遍,发现可以正常写入。基本可以确定根因:我当初是用 root 权限安装的 Claude Code,后来切到普通用户运行,导致普通用户对全局安装目录没有写权限。自动更新流程里的fs.renameSync在写权限不足时会抛出EACCES,而 Claude Code 对更新失败的处理是"重试几次再退出",这个重试逻辑没有把错误信息正常抛到终端,看起来就成了启动卡死。
4.3 修复方案与验证
修复很标准,两种方案选一种即可:
| 方案 | 命令 | 适用场景 |
|---|---|---|
| 修改全局目录属主 | sudo chown -R $(whoami) "$(npm prefix -g)" | 个人开发机 |
| 改用用户级 npm 前缀 | npm config set prefix ~/.npm-global后重新安装 | 更干净的隔离方案 |
我选择了第二种,随后把旧版本从全局目录里清理掉,重新安装 Claude Code:
export PATH="$HOME/.npm-global/bin:$PATH" npm install -g @anthropic-ai/claude-code再重新启动 Claude Code,更新流程秒过,pstack-claude 再次采样时,堆栈里已经看不到fs.renameSync这条路径了。整个过程从发现问题到定位,大约花了二十分钟,如果不是有 JS 级堆栈,光猜权限问题可能会折腾半天。
4.4 这个案例给我们的两点启发
第一,Claude Code 的大多数"启动卡住"问题,背后都有一个同步操作在阻塞事件循环。JS 栈能一眼看到它卡在哪个模块,比盲目加--verbose日志高效得多。
第二,安装时用 root、运行时用普通用户,这种 mix 权限方式特别容易诱发自动更新类问题。如果你不想动全局目录,又遇到auto-update failed相关报错,优先按上面表格里的两种方案处理,不要直接禁用更新。
5. Windows、WSL 与容器环境下的采样边界问题
5.1 虚拟化环境里的三只拦路虎
很多朋友是在 Windows 环境下通过 WSL 跑 Claude Code 的,这个组合下 pstack-claude 会遇到三个明显的问题。
第一,信号传递。WSL 2 里的进程模型接近完整 Linux,kill -SIGUSR1能正常触发 Node inspector,但如果你同时开了 Windows Defender 的某些内核隔离功能,信号传递可能出现延迟,表现为启动 inspector 的等待时间特别长。pstack-claude 默认的等待时间是 3 秒,在 WSL 环境我建议调大到 8 秒:
pstack-claude attach --wait-ms 8000 --duration 5第二,ptrace_scope 限制。Linux 内核有kernel.yama.ptrace_scope参数,默认在某些发行版上被设成 1,只允许进程调试自己的子进程。pstack-claude 虽然不是用 ptrace 附加,但它的 SIGUSR1 依旧要求你具备对目标进程的信号发送权。如果提示Operation not permitted,检查:
cat /proc/sys/kernel/yama/ptrace_scope临时调成 0 可以解决,但更安全的做法是用sudo运行 pstack-claude,因为 root 发送信号不受 ptrace_scope 限制。
第三,进程命名空间。在容器里跑 Claude Code 时会遇到另一个坑:通常 PID 映射和宿主机不一致。如果你在容器外执行 pstack-claude,大概率看到一个不存在的容器内 PID。正确姿势是进入容器内部执行,或者在宿主机上用--pid $(pgrep -f claude)反查。这个细节不写下来,真到现场很容易怀疑人生。
5.2 Windows 虚拟机平台与 Claude Code 的关系
这里要澄清一个容易混淆的问题。很多人看到的提示是virtual machine platform not available,说 Claude Code 的 workspace 要求启用 Windows 虚拟机平台。这个报错是平台初始化层面的问题,不是 JavaScrip 进程里的卡顿,pstack-claude 帮不上忙。
判断依据很简单:如果进程根本起不来,或者启动即退出,那是平台问题,用进程堆栈工具没有意义;如果进程起来了但行为诡异——比如响应很慢、卡在某个输出上——那才是堆栈诊断的范畴。我建议把这两类问题分开处理,不要一遇到异常就抓栈。
5.3 PID 1 与 init 进程的边界
在 Docker 容器里跑 Claude Code,还有一个非常容易踩的坑:Node 作为 PID 1 启动时,SIGUSR1 的处理会和默认 init 逻辑冲突,有时候 inspector 端口能打开但连接不上。排查时先检查容器是否真的用 node 作为 PID 1:
ps -p 1 -o comm=如果输出就是node,建议在容器启动命令里引入tini或切换成--init模式,把 PID 1 交给 tini,再让 node 作为普通子进程运行。这样不仅能解决信号传递问题,也能避免进程退出后产生僵尸进程。pstack-claude 在容器里跑通的完整前提,就是"目标进程不是 PID 1"。
6. 从"查一次"到"天天查":pstack-claude 的健康巡检玩法
6.1 固定采样,建立基线
到这一步,pstack-claude 已经能解决单次排障。但我用了几个星期之后发现,真正有价值的是把它变成常态化巡检工具。
原理很简单:Claude Code 正常运行时的热点函数其实是稳定的,无非是读文件、写文件、网络请求几个大类。你可以让 pstack-claude 每天定时采样几分钟,把报告存下来:
0 18 * * * pstack-claude attach --duration 30 --output /var/log/claude-stack/$(date +\%F).log连续跑几天之后,你就有了这个进程的行为基线。一旦某天报告里突然出现之前从未见过的热点,比如一个长时间占 CPU 的正则路径,或者一个反复重试的fs.renameSync,不用等用户报障,你就能提前知道哪里出问题了。这比什么都强。
6.2 和 MCP Server 故障联动
Claude Code 的进程树里往往还挂着多个 MCP Server 子进程,特别是用claude mcpservers npx方式启动的那一类。它们一旦死锁,Claude Code 主进程会一直等 IPC 消息,表现为"回复中止、光标一直转圈"。这时候从主进程堆栈能看出它在等子进程返回吗?能,但只能看到await child message这种笼统的等待。
更靠谱的做法是对 MCP Server 子进程单独采样:
pstack-claude ps --include-mcp pstack-claude attach --pid <mcp-server-pid> --duration 5这样你能判断出是 MCP Server 自己在死循环,还是它在等待外部 HTTP 服务,还是 IPC 通道丢了。这个分层的排查经验,我强烈建议所有接了自定义 MCP Server 的人提前掌握。
6.3 一点实操体验
最后分享两条个人体会。
第一,堆栈工具不是日志的替代品,而是日志的补充。Claude Code 的--verbose日志能告诉你它"正在做什么",堆栈能告诉你它"实际卡在哪"。两者的时间线一致时,定位准确率会直线上升。
第二,采样时长宁多勿少。我第一次用 pstack-claude 时只采了 1 秒,报告里全是随机噪声,看起来每个函数都占了一点但找不到明显热点。后来改成 5 秒采样,聚合结果一下就清晰了。这个道理和黑盒压测一样:让数据积累到足够描述分布为止。
从凌晨三点那个抓狂的晚上到现在,pstack-claude 已经帮我定位过至少四次 Claude Code 的进程级问题。每次看到报告里清晰指向某条函数路径,我都会想起传统 pstack 在 Node 进程面前的无能为力。工具可以很轻,但思路要对路,这就是这个项目的全部价值。