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 路径:
getOrCreate发现旧会话已断开 → 换新的reset(key)显式重置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(它答的是「这只表还在不在走」)之后才真的会红。
七、跑全量测试前先关后台驱动
这不是泄漏,是既定设计带来的测试干扰 —— 但它是本次排查的起点(「全量测试挂的那一条」), 所以记在这里。
locator.taskDir() 的落点就是 system/agents/<agent>/<companyId>/<taskId>,运行态目录落在
本体目录里属既定设计。问题在于心跳每 5 分钟建一个新的(cto/_global/ 已 263 个子项且在涨),
而扫描 system/agents/ 的守卫测试(no-dead-path-references 那一类)会被瞬时竞态打红:
列到了目录名,进去时那一层还没写完。症状是偶发假失败、重跑就好,最难查。
DISABLE_BACKGROUND_AGENTS=1 bash start.sh
一条早退关掉全部「会自己起 agent 的东西」(server/index.js 的启动 setTimeout 块):
两处中断恢复 + 任务队列扫描 + 两路心跳。实测 127 文件 / 1226 用例全绿。
验证它真的生效,别只看日志里的 ⏸ —— 那只证明代码走到了早退。硬判据是目录数:
ls system/agents/cto/_global | wc -l # 跑前
npm run test:unit
ls system/agents/cto/_global | wc -l # 跑后:必须完全相等
两个坑:
.env里不要给这个键赋空值。start.sh是set -a; source .env加载的,.env里只要存在同名键(哪怕空的)就会盖掉命令行传进来的=1,而且不报错。 所以.env.example里它只以注释形式出现。[TaskQueue] 🚀 已启动仍会打印,那是另一个模块。services/task-queue.js(ArenaSK 时代/api/claude-chat的队列,routes/claude-chat.js模块加载时起) 和批 3 的services/task-scheduler.js同名不同物。它的心跳只扫内存里的this.tasks, 不碰system/agents/,不是干扰源。名字撞车,容易误判成开关没生效。
七之二、仍未解决的(不在本次范围)
机器级闸门不存在。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 -- <相关文件>