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,6 +438,9 @@ class AgentManager extends EventEmitter { | ||
| 438 | }); | 438 | }); |
| 439 | 439 | ||
| 440 | this.sessionPool.activeKey = null; | 440 | this.sessionPool.activeKey = null; |
| 441 | + // 调用结束 = 会话进入空闲,上弦。不上的话它就永远留在池子里: | ||
| 442 | + // getKey 带 taskId,下一个图节点是新 key,这个会话再没人查得到,也再没人杀得掉。 | ||
| 443 | + this.sessionPool.armIdle(session.key); | ||
| 441 | 444 | ||
| 442 | if (logSession) await logSession.logAgentEnd(agentName, stdout, 'success'); | 445 | if (logSession) await logSession.logAgentEnd(agentName, stdout, 'success'); |
| 443 | return { agent: agentName, output: stdout, status: 'success' }; | 446 | return { agent: agentName, output: stdout, status: 'success' }; |
| @@ -463,6 +466,9 @@ class AgentManager extends EventEmitter { | @@ -463,6 +466,9 @@ class AgentManager extends EventEmitter { | ||
| 463 | } | 466 | } |
| 464 | 467 | ||
| 465 | if (logSession) await logSession.logAgentError(agentName, err); | 468 | if (logSession) await logSession.logAgentError(agentName, err); |
| 469 | + // 报错退出同样要上弦 —— 「跑挂的会话」正是 agent-chat 侧漏得最凶的那一类: | ||
| 470 | + // 子进程活得好好的,错的是 API 调用,没人回收就是纯泄漏。 | ||
| 471 | + this.sessionPool.armIdle(session.key); | ||
| 466 | throw new Error(`Agent ${agentName} (${protocolName}) failed: ${err.message}`); | 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,6 +15,22 @@ | ||
| 15 | const { EventEmitter } = require('events'); | 15 | const { EventEmitter } = require('events'); |
| 16 | const { randomUUID } = require('crypto'); | 16 | const { randomUUID } = require('crypto'); |
| 17 | const { createProtocol } = require('./protocol-factory'); | 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 | class SessionPool extends EventEmitter { | 35 | class SessionPool extends EventEmitter { |
| 20 | constructor() { | 36 | constructor() { |
| @@ -101,6 +117,7 @@ class SessionPool extends EventEmitter { | @@ -101,6 +117,7 @@ class SessionPool extends EventEmitter { | ||
| 101 | launcher, | 117 | launcher, |
| 102 | sessionId, | 118 | sessionId, |
| 103 | callQueue: Promise.resolve(), | 119 | callQueue: Promise.resolve(), |
| 120 | + idleTimer: null, // 由 armIdle 上弦;新建会话在首次调用期间故意不计时 | ||
| 104 | }; | 121 | }; |
| 105 | 122 | ||
| 106 | this.sessions.set(sessionKey, session); | 123 | this.sessions.set(sessionKey, session); |
| @@ -115,6 +132,7 @@ class SessionPool extends EventEmitter { | @@ -115,6 +132,7 @@ class SessionPool extends EventEmitter { | ||
| 115 | const existing = this.sessions.get(key); | 132 | const existing = this.sessions.get(key); |
| 116 | 133 | ||
| 117 | if (existing?.protocol?.isConnected) { | 134 | if (existing?.protocol?.isConnected) { |
| 135 | + existing.idleTimer?.cancel(); // 请求在途期间不计空闲 | ||
| 118 | this._emit(agentName, protocolName, 'reused', existing.sessionId, key); | 136 | this._emit(agentName, protocolName, 'reused', existing.sessionId, key); |
| 119 | return existing; | 137 | return existing; |
| 120 | } | 138 | } |
| @@ -132,10 +150,37 @@ class SessionPool extends EventEmitter { | @@ -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 | async reset(sessionKey) { | 181 | async reset(sessionKey) { |
| 138 | const session = this.sessions.get(sessionKey); | 182 | const session = this.sessions.get(sessionKey); |
| 183 | + session?.idleTimer?.cancel(); | ||
| 139 | if (session?.protocol) { | 184 | if (session?.protocol) { |
| 140 | this._emit( | 185 | this._emit( |
| 141 | session.agentName, session.protocolName, 'closed', | 186 | session.agentName, session.protocolName, 'closed', |
| @@ -197,6 +242,7 @@ class SessionPool extends EventEmitter { | @@ -197,6 +242,7 @@ class SessionPool extends EventEmitter { | ||
| 197 | if (!this.activeKey) return null; | 242 | if (!this.activeKey) return null; |
| 198 | const session = this.sessions.get(this.activeKey); | 243 | const session = this.sessions.get(this.activeKey); |
| 199 | if (!session?.protocol) return null; | 244 | if (!session?.protocol) return null; |
| 245 | + session.idleTimer?.cancel(); | ||
| 200 | 246 | ||
| 201 | this._emit( | 247 | this._emit( |
| 202 | session.agentName, session.protocolName, 'closed', | 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