session-pool-idle-reclaim.test.js 3.94 KB
/**
 * 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);
  });
});