From 71d1305a3887d4880af34789926541fa76a3fbab Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 19 Aug 2026 23:33:35 +0800 Subject: [PATCH] fix(session-pool): 补上机器级会话上限 —— 池子里每个 session 是一个常驻 CLI 进程 --- server/src/core/session-pool.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- server/tests/unit/session-pool-max.test.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 server/tests/unit/session-pool-max.test.js diff --git a/server/src/core/session-pool.js b/server/src/core/session-pool.js index 86286e3..afbb7a8 100644 --- a/server/src/core/session-pool.js +++ b/server/src/core/session-pool.js @@ -32,6 +32,33 @@ function idleMs() { return Number(process.env.AGENT_SESSION_IDLE_MS) || 30 * 60 * 1000; } +/** + * 机器级会话上限(27-P3C §15.5 第 2 条)。 + * + * 池子里每个 session 就是一个**常驻** claude CLI 子进程(stream-json 模式),实测单进程 RSS + * 约 200-300MB。在此之前 `this.sessions.size` **没有任何人过问** —— 一次心跳泄漏就攒到过 + * 82 个进程 / 17.5 GB。批 4 又刚把公司级并行放开(单公司上限 20),机器上同时能起多少 + * 完全没有闸门。 + * + * **调用时读,不在模块加载时快照**(`company-paths.js` 顶上那条教训:快照一次就意味着 + * 「改了配置要重启才生效」,而且不报错)。 + */ +const DEFAULT_MAX_SESSIONS = 12; +function maxSessions() { + const raw = Number(process.env.AGENT_MAX_SESSIONS); + return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_MAX_SESSIONS; +} + +/** 池子满了、且**一个空闲的都没有**(全都在途)时抛这个 —— 调用方该重试或排队,不是崩 */ +class SessionPoolFullError extends Error { + constructor(size, max) { + super(`会话池已满(${size}/${max})且无空闲会话可回收:机器级并发上限,请稍后重试`); + this.name = 'SessionPoolFullError'; + this.size = size; + this.max = max; + } +} + class SessionPool extends EventEmitter { constructor() { super(); @@ -92,6 +119,9 @@ class SessionPool extends EventEmitter { } else { // 其他协议:组合 Launcher + Transport + JSON-RPC const { createConnection } = require('@mas/acp-core'); + // ⚠️ 起子进程**之前**先腾位置:起完再判等于该拦的那一个已经起来了 + this._evictForRoom(); + const { transport, launcher: lnch } = await createConnection( agentConfig, launcherConfig, workDir ); @@ -133,6 +163,7 @@ class SessionPool extends EventEmitter { if (existing?.protocol?.isConnected) { existing.idleTimer?.cancel(); // 请求在途期间不计空闲 + existing.idleSince = null; // 在途 = 不可被 _evictForRoom 挑走 this._emit(agentName, protocolName, 'reused', existing.sessionId, key); return existing; } @@ -164,6 +195,8 @@ class SessionPool extends EventEmitter { const session = this.sessions.get(sessionKey); if (!session) return; session.idleTimer?.cancel(); + // 空闲态用显式时间戳记,不去戳 IdleTimer 的私有 _timer —— `_evictForRoom` 靠它挑最久没用的那个 + session.idleSince = Date.now(); const ms = idleMs(); session.idleTimer = new IdleTimer( ms, @@ -176,6 +209,38 @@ class SessionPool extends EventEmitter { } /** + * 起新会话之前腾位置:池子满了就回收**最久没用**的空闲会话。 + * + * 为什么是「回收空闲」而不是「直接拒绝」:session 是可复用的池化资源,杀掉一个空闲的 + * 只是让它下次冷启动一次(几秒),而拒绝会让一个本来能跑的任务因为资源原因失败 —— + * 那是把可恢复的代价换成不可恢复的。 + * + * 只有**一个空闲的都没有**(全部在途)时才抛 `SessionPoolFullError`:那时机器是真满了, + * 再起就是超发。**刻意抛而不是静默放行** —— 静默放行正是这个仓库栽过的那一类 + * 「写不进去/超出去但不报错」(27-P3C §九.4quater)。 + * + * 注意 `reset()` 是 async(要 await launcher.stop()),这里**故意不 await**: + * 腾位置只需要把它从 Map 里摘掉(reset 第一件事就是),子进程的收尾可以在后台走完。 + * await 的话,一次腾位要串行等一次进程退出,而调用方还在等着起自己的会话。 + */ + _evictForRoom() { + const max = maxSessions(); + while (this.sessions.size >= max) { + let victim = null; + for (const [key, sess] of this.sessions) { + if (!sess.idleSince) continue; // 在途的不许动 + if (!victim || sess.idleSince < victim.sess.idleSince) victim = { key, sess }; + } + if (!victim) throw new SessionPoolFullError(this.sessions.size, max); + console.log(`[SessionPool] 🧹 池满(${this.sessions.size}/${max}),回收最久空闲的 ${victim.key}`); + this.reset(victim.key).catch(() => {}); + if (this.sessions.has(victim.key)) { // reset 没能摘掉 = 防死循环 + this.sessions.delete(victim.key); + } + } + } + + /** * 重置(断开 + 清理)单个会话 */ async reset(sessionKey) { @@ -262,4 +327,4 @@ class SessionPool extends EventEmitter { } } -module.exports = { SessionPool }; +module.exports = { SessionPool, SessionPoolFullError, DEFAULT_MAX_SESSIONS, maxSessions }; diff --git a/server/tests/unit/session-pool-max.test.js b/server/tests/unit/session-pool-max.test.js new file mode 100644 index 0000000..262e0e2 --- /dev/null +++ b/server/tests/unit/session-pool-max.test.js @@ -0,0 +1,71 @@ +/** + * 机器级会话上限(27-P3C §15.5 第 2 条)。 + * + * 为什么要这条守卫:池子里每个 session 是一个**常驻** claude CLI 子进程(约 200-300MB RSS), + * 而 `sessions.size` 在此之前没有任何人过问 —— 一次心跳泄漏攒到过 82 个进程 / 17.5 GB。 + * 批 4 又刚把公司级并行放开(单公司上限 20),机器上同时能起多少完全没闸门。 + * + * 这里**不起真进程**:直接往 `pool.sessions` 里塞假 session,只验腾位置的判据本身 + * (挑最久空闲的、在途的不许动、全在途就抛)。真起进程的路径由 agent-manager 的用例覆盖。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createRequire } from 'module'; +const require_ = createRequire(import.meta.url); +const { SessionPool, SessionPoolFullError, DEFAULT_MAX_SESSIONS } = require_('../../src/core/session-pool'); + +describe('SessionPool 机器级上限', () => { + let pool; + const OLD = process.env.AGENT_MAX_SESSIONS; + + beforeEach(() => { pool = new SessionPool(); }); + afterEach(() => { + if (OLD === undefined) delete process.env.AGENT_MAX_SESSIONS; + else process.env.AGENT_MAX_SESSIONS = OLD; + }); + + /** 塞一个假 session;idleSince 为 null = 在途 */ + function seed(key, idleSince) { + pool.sessions.set(key, { key, idleSince, protocol: null, launcher: null, idleTimer: null }); + } + + it('缺省上限是 12,且从 env 调用时读(不是模块加载时快照)', () => { + expect(DEFAULT_MAX_SESSIONS).toBe(12); + process.env.AGENT_MAX_SESSIONS = '3'; + seed('a', 100); seed('b', 200); seed('c', 300); + pool._evictForRoom(); // 3/3 满 → 该腾一个 + expect(pool.sessions.size).toBe(2); + expect(pool.sessions.has('a')).toBe(false); // 挑的是最久空闲的那个 + }); + + it('在途的会话不许被腾走', () => { + process.env.AGENT_MAX_SESSIONS = '2'; + seed('inflight', null); // 在途 + seed('idle', 100); + pool._evictForRoom(); + expect(pool.sessions.has('inflight')).toBe(true); + expect(pool.sessions.has('idle')).toBe(false); + }); + + it('全部在途时抛 SessionPoolFullError —— 不静默放行', () => { + process.env.AGENT_MAX_SESSIONS = '2'; + seed('x', null); seed('y', null); + expect(() => pool._evictForRoom()).toThrow(SessionPoolFullError); + // 关键:一个都没被杀(在途的动不得),也没有偷偷放行 + expect(pool.sessions.size).toBe(2); + }); + + it('没满时什么都不做', () => { + process.env.AGENT_MAX_SESSIONS = '5'; + seed('a', 100); seed('b', 200); + pool._evictForRoom(); + expect(pool.sessions.size).toBe(2); + }); + + it('非法配置回落缺省,不当成无限', () => { + for (const bad of ['0', '-1', 'abc', '']) { + process.env.AGENT_MAX_SESSIONS = bad; + const { maxSessions } = require_('../../src/core/session-pool'); + expect(maxSessions()).toBe(DEFAULT_MAX_SESSIONS); + } + }); +}); -- libgit2 0.21.4