session-pool-idle-reclaim.test.js
3.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/**
* 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);
});
});