# 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 下沉到运行态目录,但第三层不一样: ```bash lsof -p -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` 的保留缺省值,两个池子都会用到, > **不能靠它区分**。区分点是第三层的命名形态。 一次性把全部孤儿归类: ```bash for p in $(pgrep -P ); 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 分支只摘不杀 ### 代码 ```js } 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`) `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`: ```js const runtime = `${agentConfig.companyId || '_global'}/${agentConfig.taskId || '_no-task'}`; ``` 每个图节点一个新 taskId = 一个新 key = 一个新 CLI 进程,跑完之后**再没人查得到那个会话** (下一个节点用的是别的 key),也就再没人杀得掉它。一节点一进程,永不释放。 > `taskId` 进 key 是必须的(28 §6 批次 3-12:两家公司调同一个本体,key 相同会串上下文)。 > 问题不在 key 的设计,在于加了这一维之后没有配套的回收。 ### 修法(`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///`,运行态目录落在 本体目录里属既定设计。问题在于心跳每 5 分钟建一个新的(`cto/_global/` 已 263 个子项且在涨), 而扫描 `system/agents/` 的守卫测试(`no-dead-path-references` 那一类)会被瞬时竞态打红: **列到了目录名,进去时那一层还没写完**。症状是偶发假失败、重跑就好,最难查。 ```bash DISABLE_BACKGROUND_AGENTS=1 bash start.sh ``` 一条早退关掉全部「会自己起 agent 的东西」(`server/index.js` 的启动 setTimeout 块): 两处中断恢复 + 任务队列扫描 + 两路心跳。实测 127 文件 / 1226 用例全绿。 **验证它真的生效,别只看日志里的 `⏸`** —— 那只证明代码走到了早退。硬判据是目录数: ```bash 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`。 --- ## 八、下次再遇到,三步定位 ```bash # 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//_global/conversations//meta.json ``` **一条经验**:先确认跑着的服务器进程**是什么时候启动的**,再去看代码。 本次排查中,正在跑的实例比修复 commit 早 18 秒启动,代码里有的修复它并没有。 ```bash ps -o lstart= -p $SRV # 服务器启动时间 git log --format='%h %ad %s' --date=format:'%m-%d %H:%M' -5 -- <相关文件> ```