Commit b0b2b62f67663936791b11e7ae838b4e086ab440
1 parent
87b776cb
fix(session-pool): 给图执行侧的常驻会话上空闲 TTL,堵住一节点一进程的泄漏
SessionPool 此前只有三条回收路径:换新会话 / reset() / killActive()。「正常跑完」和 「不可重试的报错」都不在其中,而 getKey 里带了 taskId —— 每个图节点一个新 key,于是 一节点一进程、永不释放,且**不报错**。实测 23 小时漏出 7 个图执行进程。 修法与 agent-chat 侧同一套:调用开始时停表(getOrCreate 里 cancel),调用结束时上弦 (agent-manager 两个出口调 armIdle),计时器只在没有在途请求的间隙走,所以再长的单次 生成也不会被 TTL 打断。旋钮共用 AGENT_SESSION_IDLE_MS(默认 30 分钟)。 守卫:server/tests/unit/session-pool-idle-reclaim.test.js Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
3 changed files
with
153 additions
and
0 deletions
server/src/core/agent-manager.js
| ... | ... | @@ -438,6 +438,9 @@ class AgentManager extends EventEmitter { |
| 438 | 438 | }); |
| 439 | 439 | |
| 440 | 440 | this.sessionPool.activeKey = null; |
| 441 | + // 调用结束 = 会话进入空闲,上弦。不上的话它就永远留在池子里: | |
| 442 | + // getKey 带 taskId,下一个图节点是新 key,这个会话再没人查得到,也再没人杀得掉。 | |
| 443 | + this.sessionPool.armIdle(session.key); | |
| 441 | 444 | |
| 442 | 445 | if (logSession) await logSession.logAgentEnd(agentName, stdout, 'success'); |
| 443 | 446 | return { agent: agentName, output: stdout, status: 'success' }; |
| ... | ... | @@ -463,6 +466,9 @@ class AgentManager extends EventEmitter { |
| 463 | 466 | } |
| 464 | 467 | |
| 465 | 468 | if (logSession) await logSession.logAgentError(agentName, err); |
| 469 | + // 报错退出同样要上弦 —— 「跑挂的会话」正是 agent-chat 侧漏得最凶的那一类: | |
| 470 | + // 子进程活得好好的,错的是 API 调用,没人回收就是纯泄漏。 | |
| 471 | + this.sessionPool.armIdle(session.key); | |
| 466 | 472 | throw new Error(`Agent ${agentName} (${protocolName}) failed: ${err.message}`); |
| 467 | 473 | } |
| 468 | 474 | } | ... | ... |
server/src/core/session-pool.js
| ... | ... | @@ -15,6 +15,22 @@ |
| 15 | 15 | const { EventEmitter } = require('events'); |
| 16 | 16 | const { randomUUID } = require('crypto'); |
| 17 | 17 | const { createProtocol } = require('./protocol-factory'); |
| 18 | +const { IdleTimer } = require('../engine/idle-timer'); | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * 会话空闲上限。与 routes/agent-chat.js 的 `sessions` 池共用同一个旋钮和同一个含义 | |
| 22 | + * —— 两边存的都是**常驻**的 claude CLI 子进程,回收判据也一样。 | |
| 23 | + * | |
| 24 | + * 在此之前这个池子只有三条回收路径:换新会话 / reset() / killActive()。 | |
| 25 | + * 「正常跑完」和「不可重试的报错」都不在其中,而 getKey 里带了 taskId | |
| 26 | + * —— 每个图节点一个新 key,于是一节点一进程、永不释放,且不报错。 | |
| 27 | + * 实测 23 小时漏出 7 个图执行进程(另有 38 个来自 agent-chat 侧的另一个洞)。 | |
| 28 | + * | |
| 29 | + * 每次读 env 而不是模块加载时读一次:单测要能改这个值,不然只能等 30 分钟。 | |
| 30 | + */ | |
| 31 | +function idleMs() { | |
| 32 | + return Number(process.env.AGENT_SESSION_IDLE_MS) || 30 * 60 * 1000; | |
| 33 | +} | |
| 18 | 34 | |
| 19 | 35 | class SessionPool extends EventEmitter { |
| 20 | 36 | constructor() { |
| ... | ... | @@ -101,6 +117,7 @@ class SessionPool extends EventEmitter { |
| 101 | 117 | launcher, |
| 102 | 118 | sessionId, |
| 103 | 119 | callQueue: Promise.resolve(), |
| 120 | + idleTimer: null, // 由 armIdle 上弦;新建会话在首次调用期间故意不计时 | |
| 104 | 121 | }; |
| 105 | 122 | |
| 106 | 123 | this.sessions.set(sessionKey, session); |
| ... | ... | @@ -115,6 +132,7 @@ class SessionPool extends EventEmitter { |
| 115 | 132 | const existing = this.sessions.get(key); |
| 116 | 133 | |
| 117 | 134 | if (existing?.protocol?.isConnected) { |
| 135 | + existing.idleTimer?.cancel(); // 请求在途期间不计空闲 | |
| 118 | 136 | this._emit(agentName, protocolName, 'reused', existing.sessionId, key); |
| 119 | 137 | return existing; |
| 120 | 138 | } |
| ... | ... | @@ -132,10 +150,37 @@ class SessionPool extends EventEmitter { |
| 132 | 150 | } |
| 133 | 151 | |
| 134 | 152 | /** |
| 153 | + * 调用结束后给会话上弦;调用开始时由 getOrCreate 停表。 | |
| 154 | + * | |
| 155 | + * 计时器只在「没有在途请求」的间隙走,所以再长的单次生成也不会被 TTL 打断 | |
| 156 | + * —— 与 agent-chat 侧同一套写法,比在流式回调里 touch() 少一处要维护的调用点。 | |
| 157 | + * | |
| 158 | + * ponytail: 这是**兜底**不是精算 —— 一次性 taskId(图节点跑完就不会再用)也要 | |
| 159 | + * 白占一个 TTL 周期的内存,天花板是「并发会话数 × 单进程 RSS(实测约 300MB)× TTL」。 | |
| 160 | + * 要更紧的话,调用方在明知 taskId 一次性时直接 reset(key) 立刻释放;这里不替它判断, | |
| 161 | + * 因为 devloop 的 retry-with-reflection 复用同一个 taskId,一刀切关掉会砍掉复用能力。 | |
| 162 | + */ | |
| 163 | + armIdle(sessionKey) { | |
| 164 | + const session = this.sessions.get(sessionKey); | |
| 165 | + if (!session) return; | |
| 166 | + session.idleTimer?.cancel(); | |
| 167 | + const ms = idleMs(); | |
| 168 | + session.idleTimer = new IdleTimer( | |
| 169 | + ms, | |
| 170 | + () => { | |
| 171 | + console.log(`[SessionPool] 🧹 会话空闲超过 ${Math.round(ms / 60000)} 分钟,回收 ${sessionKey}`); | |
| 172 | + this.reset(sessionKey).catch(() => {}); | |
| 173 | + }, | |
| 174 | + { label: `pool-session:${sessionKey}` } | |
| 175 | + ).start(); | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** | |
| 135 | 179 | * 重置(断开 + 清理)单个会话 |
| 136 | 180 | */ |
| 137 | 181 | async reset(sessionKey) { |
| 138 | 182 | const session = this.sessions.get(sessionKey); |
| 183 | + session?.idleTimer?.cancel(); | |
| 139 | 184 | if (session?.protocol) { |
| 140 | 185 | this._emit( |
| 141 | 186 | session.agentName, session.protocolName, 'closed', |
| ... | ... | @@ -197,6 +242,7 @@ class SessionPool extends EventEmitter { |
| 197 | 242 | if (!this.activeKey) return null; |
| 198 | 243 | const session = this.sessions.get(this.activeKey); |
| 199 | 244 | if (!session?.protocol) return null; |
| 245 | + session.idleTimer?.cancel(); | |
| 200 | 246 | |
| 201 | 247 | this._emit( |
| 202 | 248 | session.agentName, session.protocolName, 'closed', | ... | ... |
server/tests/unit/session-pool-idle-reclaim.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * SessionPool 空闲回收守卫 | |
| 3 | + * | |
| 4 | + * 背景:这个池子存的是**常驻**的 claude CLI 子进程,而 getKey 里带了 taskId | |
| 5 | + * —— 每个图节点一个新 key,跑完之后再没人查得到那个会话,也再没人杀得掉它。 | |
| 6 | + * 修之前池子只有三条回收路径(换新会话 / reset / killActive),「正常跑完」和 | |
| 7 | + * 「不可重试的报错」都不在其中,实测 23 小时漏出 7 个进程(约 2GB RSS)。 | |
| 8 | + * | |
| 9 | + * 症状是**静默的**:不报错、不写日志、git status 干净,只有内存一路涨。所以这里盯三条: | |
| 10 | + * 1. 上弦之后到点必须真的杀子进程 + 摘出池子 | |
| 11 | + * 2. 会话被复用时必须停表(否则长任务跑到一半被 TTL 打断,比泄漏更难查) | |
| 12 | + * 3. reset 之后计时器不许还活着(否则回收完还会再摸一次已经不存在的会话) | |
| 13 | + * | |
| 14 | + * 不起真 CLI:直接把假会话塞进 pool.sessions,只验回收机制本身。 | |
| 15 | + */ | |
| 16 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| 17 | +import { createRequire } from 'node:module'; | |
| 18 | + | |
| 19 | +const require_ = createRequire(import.meta.url); | |
| 20 | +const { SessionPool } = require_('../../src/core/session-pool'); | |
| 21 | + | |
| 22 | +const TTL = 30; // ms —— idleMs() 每次读 env,所以能在测试里调小 | |
| 23 | +const AGENT = 'graph-node-agent'; | |
| 24 | +const CONFIG = { client: 'claude', deploy: 'local', companyId: 'e2e-demo', taskId: 'run_dead_beef' }; | |
| 25 | +const WORKDIR = '/tmp/does-not-need-to-exist'; | |
| 26 | + | |
| 27 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 28 | + | |
| 29 | +/** 一个只记录 disconnect 有没有被调过的假 protocol */ | |
| 30 | +function fakeSession(pool, { connected = true } = {}) { | |
| 31 | + const key = pool.getKey(AGENT, CONFIG, WORKDIR); | |
| 32 | + const session = { | |
| 33 | + key, | |
| 34 | + agentName: AGENT, | |
| 35 | + protocolName: 'claude-cli', | |
| 36 | + protocol: { isConnected: connected, sessionId: 'sess-1', disconnected: false, | |
| 37 | + disconnect() { this.disconnected = true; this.isConnected = false; } }, | |
| 38 | + launcher: null, | |
| 39 | + sessionId: 'sess-1', | |
| 40 | + callQueue: Promise.resolve(), | |
| 41 | + idleTimer: null, | |
| 42 | + }; | |
| 43 | + pool.sessions.set(key, session); | |
| 44 | + return { key, session }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +describe('SessionPool 空闲回收', () => { | |
| 48 | + let prevEnv; | |
| 49 | + let pool; | |
| 50 | + | |
| 51 | + beforeEach(() => { | |
| 52 | + prevEnv = process.env.AGENT_SESSION_IDLE_MS; | |
| 53 | + process.env.AGENT_SESSION_IDLE_MS = String(TTL); | |
| 54 | + pool = new SessionPool(); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + afterEach(() => { | |
| 58 | + if (prevEnv === undefined) delete process.env.AGENT_SESSION_IDLE_MS; | |
| 59 | + else process.env.AGENT_SESSION_IDLE_MS = prevEnv; | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it('上弦后到点:子进程被杀,会话从池子里摘掉', async () => { | |
| 63 | + const { key, session } = fakeSession(pool); | |
| 64 | + | |
| 65 | + pool.armIdle(key); | |
| 66 | + expect(session.protocol.disconnected).toBe(false); // 还没到点,不许提前动手 | |
| 67 | + | |
| 68 | + await sleep(TTL * 3); | |
| 69 | + | |
| 70 | + expect(session.protocol.disconnected).toBe(true); | |
| 71 | + expect(pool.sessions.has(key)).toBe(false); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('会话被复用时停表 —— 在途请求不会被 TTL 打断', async () => { | |
| 75 | + const { key, session } = fakeSession(pool); | |
| 76 | + | |
| 77 | + pool.armIdle(key); | |
| 78 | + const reused = await pool.getOrCreate(AGENT, CONFIG, {}, 'claude-cli', WORKDIR); | |
| 79 | + expect(reused).toBe(session); // 命中复用,没有新建 | |
| 80 | + | |
| 81 | + await sleep(TTL * 3); | |
| 82 | + | |
| 83 | + expect(session.protocol.disconnected).toBe(false); | |
| 84 | + expect(pool.sessions.has(key)).toBe(true); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it('reset 之后计时器不再触发', async () => { | |
| 88 | + const { key, session } = fakeSession(pool); | |
| 89 | + | |
| 90 | + pool.armIdle(key); | |
| 91 | + await pool.reset(key); | |
| 92 | + expect(session.protocol.disconnected).toBe(true); | |
| 93 | + expect(pool.sessions.has(key)).toBe(false); | |
| 94 | + | |
| 95 | + // 看 IdleTimer.fired 而不是「有没有再杀一次子进程」:会话已经不在池子里了, | |
| 96 | + // 超时回调本来就空转,拿 disconnected 断言等于什么都没测(写第一版时就是这么假绿的)。 | |
| 97 | + // fired 直接答的是「这只表还在不在走」—— 没取消的话它会留一个吊着的 setTimeout。 | |
| 98 | + await sleep(TTL * 3); | |
| 99 | + expect(session.idleTimer.fired).toBe(false); | |
| 100 | + }); | |
| 101 | +}); | ... | ... |
-
mentioned in commit d8b4346d1b9a693b6325855e89eeb3aa871157cd