/** * SessionPool 空闲回收守卫 * * 背景:这个池子存的是**常驻**的 claude CLI 子进程,而 getKey 里带了 taskId * —— 每个图节点一个新 key,跑完之后再没人查得到那个会话,也再没人杀得掉它。 * 修之前池子只有三条回收路径(换新会话 / reset / killActive),「正常跑完」和 * 「不可重试的报错」都不在其中,实测 23 小时漏出 7 个进程(约 2GB RSS)。 * * 症状是**静默的**:不报错、不写日志、git status 干净,只有内存一路涨。所以这里盯三条: * 1. 上弦之后到点必须真的杀子进程 + 摘出池子 * 2. 会话被复用时必须停表(否则长任务跑到一半被 TTL 打断,比泄漏更难查) * 3. reset 之后计时器不许还活着(否则回收完还会再摸一次已经不存在的会话) * * 不起真 CLI:直接把假会话塞进 pool.sessions,只验回收机制本身。 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createRequire } from 'node:module'; const require_ = createRequire(import.meta.url); const { SessionPool } = require_('../../src/core/session-pool'); const TTL = 30; // ms —— idleMs() 每次读 env,所以能在测试里调小 const AGENT = 'graph-node-agent'; const CONFIG = { client: 'claude', deploy: 'local', companyId: 'e2e-demo', taskId: 'run_dead_beef' }; const WORKDIR = '/tmp/does-not-need-to-exist'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 一个只记录 disconnect 有没有被调过的假 protocol */ function fakeSession(pool, { connected = true } = {}) { const key = pool.getKey(AGENT, CONFIG, WORKDIR); const session = { key, agentName: AGENT, protocolName: 'claude-cli', protocol: { isConnected: connected, sessionId: 'sess-1', disconnected: false, disconnect() { this.disconnected = true; this.isConnected = false; } }, launcher: null, sessionId: 'sess-1', callQueue: Promise.resolve(), idleTimer: null, }; pool.sessions.set(key, session); return { key, session }; } describe('SessionPool 空闲回收', () => { let prevEnv; let pool; beforeEach(() => { prevEnv = process.env.AGENT_SESSION_IDLE_MS; process.env.AGENT_SESSION_IDLE_MS = String(TTL); pool = new SessionPool(); }); afterEach(() => { if (prevEnv === undefined) delete process.env.AGENT_SESSION_IDLE_MS; else process.env.AGENT_SESSION_IDLE_MS = prevEnv; }); it('上弦后到点:子进程被杀,会话从池子里摘掉', async () => { const { key, session } = fakeSession(pool); pool.armIdle(key); expect(session.protocol.disconnected).toBe(false); // 还没到点,不许提前动手 await sleep(TTL * 3); expect(session.protocol.disconnected).toBe(true); expect(pool.sessions.has(key)).toBe(false); }); it('会话被复用时停表 —— 在途请求不会被 TTL 打断', async () => { const { key, session } = fakeSession(pool); pool.armIdle(key); const reused = await pool.getOrCreate(AGENT, CONFIG, {}, 'claude-cli', WORKDIR); expect(reused).toBe(session); // 命中复用,没有新建 await sleep(TTL * 3); expect(session.protocol.disconnected).toBe(false); expect(pool.sessions.has(key)).toBe(true); }); it('reset 之后计时器不再触发', async () => { const { key, session } = fakeSession(pool); pool.armIdle(key); await pool.reset(key); expect(session.protocol.disconnected).toBe(true); expect(pool.sessions.has(key)).toBe(false); // 看 IdleTimer.fired 而不是「有没有再杀一次子进程」:会话已经不在池子里了, // 超时回调本来就空转,拿 disconnected 断言等于什么都没测(写第一版时就是这么假绿的)。 // fired 直接答的是「这只表还在不在走」—— 没取消的话它会留一个吊着的 setTimeout。 await sleep(TTL * 3); expect(session.idleTimer.fired).toBe(false); }); });