claude-cli-session-leak.md 9.47 KB

Claude CLI 子进程泄漏 —— 两个会话池,两个洞

状态:✅ 已修复(b0b2b62 + 2092a2e,2026-08-19) 适用范围:任何往会话池里塞常驻 CLI 子进程的代码路径


一、这份文档要解决的问题

泄漏被修过一次(310a2b2 / 3e718aa,2026-08-18),修完之后照样漏。 第二次排查发现原因不是修错了,而是:

系统里有两个互相独立的会话池,上一次只修了其中一个;而被修的那个,还漏了一条分支。

这个结构性事实没有任何一处文档记着,所以第二个人查到同一个症状时会重走一遍全程。


二、症状:它是静默的

一个连续跑了 23 小时的 server/index.js 实例(PID 40255,8-18 17:29 启动):

遗留 claude CLI 子进程 45 个
这些子进程占用 RSS 9.6 GB
全机 claude 进程总 RSS 12.3 GB → 清理后 2.3 GB
CPU 全部 0%(早已跑完,只是不死)

不报错、不写日志、git status 干净、接口全部正常。唯一的外部表现是内存一路涨, 以及跑全量测试时被这些进程写出来的运行态目录打出偶发假失败。

上一次的记录是「2h43m 攒到 82 个进程 / 17.5 GB」。量级对得上,说明这是同一类问题的复发, 不是新 bug。


三、先分清楚:两个会话池

这是排查这类问题的第一步,也是上次修复漏掉一半的根本原因。

池 A 池 B
位置 server/routes/agent-chat.js 的 sessions Map server/src/core/session-pool.js 的 SessionPool
谁在用 /api/agent/:id/chat(含两路心跳) AgentManager.callAgent → 图执行 / DevLoop
会话 key ${agentId}:${convId} getKey():agent + client + deploy + companyId/taskId + workDir
本次泄漏占比 38 个 7 个

两个池子没有任何共享代码,回收逻辑各写各的。上一次的修复只动了池 A。

怎么判断一个孤儿进程属于哪个池

看它的 cwd —— 两个池子都把 cwd 下沉到运行态目录,但第三层不一样:

lsof -p <pid> -a -d cwd | tail -1 | awk '{print $NF}'
cwd 形态 归属
system/agents/cto/_global/heartbeat-msywwyhf 池 A(convId 是 heartbeat- + Date.now().toString(36))
system/agents/brd-planning/e2e-demo/run_ee90c4e35f9f 池 B(第三层是图执行的 runId)

注意 _global / _no-task 是 agent-locator 的保留缺省值,两个池子都会用到, 不能靠它区分。区分点是第三层的命名形态。

一次性把全部孤儿归类:

for p in $(pgrep -P <server-pid>); do
  echo "$p $(ps -o lstart= -p $p | awk '{print $3,$4}') \
        $(lsof -p $p -a -d cwd 2>/dev/null | tail -1 | awk '{print $NF}')"
done | sed 's|.*/system/agents/||'

四、池 A 的洞:catch 分支只摘不杀

代码

} catch (err) {
    ...
    dropSession(`${agentId}:${convId}`);          // ← 只摘出池子,不杀子进程
} finally {
    const sessionKey = `${agentId}:${convId}`;
    if (kind?.[2]) closeSession(sessionKey, ...); // ← 池子里已经没了,返回 false,空转
    else armSessionIdle(sessionKey);
}

dropSession 的注释写着「子进程已经自己死了的场景」。但这条 catch 覆盖的是另一种失败: 错的是 API 调用,CLI 子进程好端端地活着。于是会话先被摘出池子,finally 再也查不到它 —— 一个既没有引用、也没有回收路径的孤儿。

证据

抽查泄漏的 convId,status 无一例外是 failed:

heartbeat-mszk16qj -> failed | UNKNOWN_CERTIFICATE_VERIFICATION_ERROR
heartbeat-msznzjlx -> failed | Can't reach the API server (ENOTFOUND)
heartbeat-msywwyhf -> failed | API Error: 529 Overloaded

对照 cto 最近 60 次心跳:55 completed / 5 failed。8% 的失败率、一次漏一个, 23 小时漏 38 个——数字对得上。成功的心跳一个都没漏,说明 310a2b2 本身是对的。

2092a2e">修法(2092a2e)

dropSession → closeSession。可观测行为完全不变(两者都把会话摘出池子、都让下一轮冷启动), 只是多杀一个本该死的进程。

为什么上一版守卫拦不住 —— 这条最值得记

agent-chat-session-reclaim.test.js 用 /chat/:convId/stop 当黑盒探针(查得到会话才返回 success:true)。这个探针对本 bug 是瞎的:

dropSession 和 closeSession 都会让会话从池子里消失,探针两种情况都返回 false。 两者的差别只在子进程死没死,而那是探针看不见的维度。

所以新增用例破例 spy 了 ClaudeCliProtocol.prototype.disconnect —— 它是「进程被杀」唯一可观测的落点。 用 vi.spyOn 让 sendPrompt reject 来复现「CLI 活着、API 挂了」。

教训:黑盒探针的覆盖面等于它能观测的维度。选探针时要问的不是「它能不能测出正确行为」, 而是「它能不能区分正确行为和这个具体的错误行为」。


五、池 B 的洞:压根没有回收路径

代码

SessionPool 修改前只有三条 disconnect 路径:

  1. getOrCreate 发现旧会话已断开 → 换新的
  2. reset(key) 显式重置
  3. killActive() 手动终止

「正常跑完」和「不可重试的报错」都不在其中。 而 getKey() 里带了 taskId:

const runtime = `${agentConfig.companyId || '_global'}/${agentConfig.taskId || '_no-task'}`;

每个图节点一个新 taskId = 一个新 key = 一个新 CLI 进程,跑完之后再没人查得到那个会话 (下一个节点用的是别的 key),也就再没人杀得掉它。一节点一进程,永不释放。

taskId 进 key 是必须的(28 §6 批次 3-12:两家公司调同一个本体,key 相同会串上下文)。 问题不在 key 的设计,在于加了这一维之后没有配套的回收。

b0b2b62">修法(b0b2b62)

按池 A 已经定下的口径补空闲 TTL,复用 src/engine/idle-timer 和同一个旋钮 AGENT_SESSION_IDLE_MS(默认 30 分钟):

  • session-pool.js 新增 armIdle(key);getOrCreate 命中复用时停表;reset / killActive 取消计时器
  • agent-manager.js 的 _runViaProtocol 在两个出口各上一次弦:正常跑完、放弃重试

计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断。

idleMs() 故意做成每次读 env 而不是模块加载时读一次 —— 否则单测只能干等 30 分钟。

ponytail: 已知天花板

TTL 是兜底不是精算。一次性 taskId(图节点跑完就不会再用)也要白占一个 TTL 周期的内存, 天花板是「并发会话数 × 单进程 RSS(实测约 300MB)× TTL」。

升级路径:调用方在明知 taskId 一次性时直接 reset(key) 立刻释放。这里不替它判断 —— DevLoop 的 retry-with-reflection 复用同一个 taskId,一刀切关掉会砍掉复用能力。


六、验证

线上观察(PID 63099,17:03 启动,心跳 coo/cto 各 5 分钟、qa-judge 10 分钟):

17:08:01  子进程=0
17:08:31  子进程=2     ← cto + coo 心跳同时触发
17:09:01  子进程=0     ← 跑完即回收

守卫测试:

文件 用例
server/tests/unit/agent-chat-session-reclaim.test.js 13 条(本次 +1:跑挂的心跳同样要杀子进程)
server/tests/unit/session-pool-idle-reclaim.test.js 3 条(新增,不起真 CLI)

四条断言都做过变异检验(把修复逐条改回去,确认对应用例转红)。其中一条第一版是假绿的: 「reset 后不再杀一次子进程」—— 会话那时已经不在池子里,超时回调本来就空转, 那条断言什么都没测。改成看 IdleTimer.fired(它答的是「这只表还在不在走」)之后才真的会红。


七、仍未解决的(不在本次范围)

  1. 心跳持续往 system/agents/ 写运行态目录。locator.taskDir() 的落点设计就是 system/agents/<agent>/<companyId>/<taskId>,属既定设计,不是 bug;但心跳每 5 分钟建一个新目录 (cto/_global/ 已 260+ 子项且在涨),扫描 system/agents/ 的守卫测试会被瞬时竞态打红。 要跑干净的全量测试,得让测试隔离掉 system/agents/,或跑之前先停心跳。
  2. 机器级闸门不存在。SessionPool 没有 maxSessions,全靠 TTL 兜底。登记在 28-followups。

八、下次再遇到,三步定位

# 1. 谁的孩子,多少个,占多少
SRV=$(lsof -nP -iTCP:3010 -sTCP:LISTEN -t | head -1)
pgrep -P $SRV | wc -l
ps -eo ppid,rss | awk -v s=$SRV '$1==s {n+=1; m+=$2} END {print n"个", m/1024"MB"}'

# 2. 按 cwd 归类,判断是哪个池(对照第三节的表)
for p in $(pgrep -P $SRV); do lsof -p $p -a -d cwd 2>/dev/null | tail -1 | awk '{print $NF}'; done \
  | sed 's|.*/system/agents/||' | sort | uniq -c

# 3. 对池 A 的泄漏,查对应会话的 status —— 全是 failed 就是错误路径没回收
cat system/agents/<agent>/_global/conversations/<convId>/meta.json

一条经验:先确认跑着的服务器进程是什么时候启动的,再去看代码。 本次排查中,正在跑的实例比修复 commit 早 18 秒启动,代码里有的修复它并没有。

ps -o lstart= -p $SRV            # 服务器启动时间
git log --format='%h %ad %s' --date=format:'%m-%d %H:%M' -5 -- <相关文件>