Commit b0b2b62f67663936791b11e7ae838b4e086ab440

Authored by yibo
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>
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 +});
... ...