diff --git a/docs/README.md b/docs/README.md index ebd3185..82b6fdc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ | [LEMONBOSS-SYSTEM-DESIGN.md](./done/LEMONBOSS-SYSTEM-DESIGN.md) | 系统设计主索引 v2.0,含 27 章 `system-design/` 的总目录 | | [architecture-v5.md](./done/architecture-v5.md) | v5.3 架构方案,Phase 1-6 已完成(Phase 7 认知架构条目单独见 todo/) | | [claude-code-cron.md](./done/claude-code-cron.md) | Claude Code 原生 Cron 调度功能参考手册 | +| [claude-cli-session-leak.md](./done/claude-cli-session-leak.md) | **常驻 CLI 子进程泄漏排查手册** — ✅ 已修复(`b0b2b62` + `2092a2e`):系统里有**两个互不相干的会话池**(`routes/agent-chat.js` 的 `sessions` / `src/core/session-pool.js`),各漏各的。含按 cwd 判断孤儿归属的方法、变异检验为什么必要、三步定位脚本 | | [open-api-manual.md](./done/open-api-manual.md) | ArenaSK Open API 外部调用手册 | | [qa_judge_req.md](./done/qa_judge_req.md) | QA Judge Agent 需求文档 — 已在 `system/agents/qa-judge/` 落地 | | [task_timeline_design.md](./done/task_timeline_design.md) | Task Timeline View 设计文档 — 已实现(`server/routes/tasks.js`、`server/services/task-service.js`、`web/src/views/TaskTimeline.vue`) | diff --git a/docs/done/claude-cli-session-leak.md b/docs/done/claude-cli-session-leak.md new file mode 100644 index 0000000..79caa1e --- /dev/null +++ b/docs/done/claude-cli-session-leak.md @@ -0,0 +1,230 @@ +# 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`(它答的是「这只表还在不在走」)之后才真的会红。 + +--- + +## 七、仍未解决的(不在本次范围) + +1. **心跳持续往 `system/agents/` 写运行态目录**。`locator.taskDir()` 的落点设计就是 + `system/agents///`,属既定设计,不是 bug;但心跳每 5 分钟建一个新目录 + (`cto/_global/` 已 260+ 子项且在涨),扫描 `system/agents/` 的守卫测试会被瞬时竞态打红。 + 要跑干净的全量测试,得让测试隔离掉 `system/agents/`,或跑之前先停心跳。 +2. **机器级闸门不存在**。`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 -- <相关文件> +```