184 lines
6.9 KiB
JavaScript
184 lines
6.9 KiB
JavaScript
/**
|
||
* 引擎进程管理用例(任务 A3.10)。
|
||
*
|
||
* 五条验收:可启停、超时中断、崩溃按退避重启且上限可配、日志轮转、平台退出时子进程回收。
|
||
* 超时中断与真进程回收在 pi-runtime / 关停用例里覆盖,这里覆盖调度、重建与退避逻辑。
|
||
*/
|
||
import assert from 'node:assert/strict';
|
||
import { test } from 'node:test';
|
||
|
||
import { RuntimeSupervisor } from '@aps/server/agent/process-manager';
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
/** 造一个可观测的假运行时会话 */
|
||
function fakeSession(label, log) {
|
||
return {
|
||
id: label,
|
||
toolExecution: 'platform',
|
||
async *send() {
|
||
yield { type: 'turn_end', reason: 'stop' };
|
||
},
|
||
resolveToolResult() {},
|
||
abort() {
|
||
log.push(`abort:${label}`);
|
||
},
|
||
async dispose() {
|
||
log.push(`dispose:${label}`);
|
||
},
|
||
stderrTail: () => log.filter((item) => item.startsWith('stderr')).join(''),
|
||
};
|
||
}
|
||
|
||
function countingFactory(log) {
|
||
let created = 0;
|
||
return async () => {
|
||
created += 1;
|
||
const label = `s${created}`;
|
||
log.push(`create:${label}`);
|
||
return fakeSession(label, log);
|
||
};
|
||
}
|
||
|
||
test('空闲回收:到点回收,下一次请求重建', async () => {
|
||
const log = [];
|
||
const supervisor = new RuntimeSupervisor({ idleTimeoutMs: 30, sweepIntervalMs: 10, maxConcurrent: 4 });
|
||
const create = countingFactory(log);
|
||
|
||
await supervisor.acquire('k1', 'fp', create);
|
||
assert.equal(supervisor.size, 1);
|
||
await sleep(80);
|
||
await supervisor.sweepIdle();
|
||
assert.equal(supervisor.size, 0, '空闲会话应被回收');
|
||
assert.equal(supervisor.stats().evictedIdle, 1);
|
||
assert.ok(log.includes('dispose:s1'), '回收要真的释放引擎资源');
|
||
|
||
await supervisor.acquire('k1', 'fp', create);
|
||
assert.equal(supervisor.stats().active, 1);
|
||
assert.ok(log.includes('create:s2'), '下一次请求应重建');
|
||
});
|
||
|
||
test('并发上限:满了明确拒绝(rate_limited 429),回收后可再用', async () => {
|
||
const log = [];
|
||
const supervisor = new RuntimeSupervisor({ maxConcurrent: 1, idleTimeoutMs: 60_000 });
|
||
const create = countingFactory(log);
|
||
|
||
await supervisor.acquire('a', 'fp', create);
|
||
await assert.rejects(
|
||
() => supervisor.acquire('b', 'fp', create),
|
||
(error) => {
|
||
assert.equal(error.code, 'rate_limited');
|
||
assert.equal(error.status, 429);
|
||
assert.equal(error.retryable, true);
|
||
return true;
|
||
},
|
||
);
|
||
assert.equal(supervisor.stats().rejectedBusy, 1);
|
||
|
||
await supervisor.evict('a', 'idle');
|
||
await supervisor.acquire('b', 'fp', create);
|
||
assert.equal(supervisor.stats().active, 1);
|
||
});
|
||
|
||
test('崩溃退避重启:按指数退避等待,超过上限停止重试,正常回合清零', async () => {
|
||
const log = [];
|
||
const supervisor = new RuntimeSupervisor({ maxConcurrent: 4, idleTimeoutMs: 60_000, maxRestarts: 2, baseBackoffMs: 20 });
|
||
const create = countingFactory(log);
|
||
|
||
await supervisor.acquire('k', 'fp', create);
|
||
await supervisor.evict('k', 'failed', '进程退出');
|
||
// 第一次退避:立即请求被拒
|
||
await assert.rejects(() => supervisor.acquire('k', 'fp', create), /退避重启/);
|
||
await sleep(40);
|
||
await supervisor.acquire('k', 'fp', create);
|
||
assert.equal(supervisor.stats().active, 1, '退避到点后应重建');
|
||
|
||
// 正常回合清零连续失败计数
|
||
supervisor.markHealthy('k');
|
||
await supervisor.evict('k', 'failed');
|
||
await sleep(40);
|
||
await supervisor.acquire('k', 'fp', create);
|
||
assert.equal(supervisor.stats().active, 1);
|
||
|
||
// 连续失败到上限:不再重试
|
||
supervisor.touch('k');
|
||
await supervisor.evict('k', 'failed');
|
||
await sleep(90);
|
||
await supervisor.acquire('k', 'fp', create);
|
||
await supervisor.evict('k', 'failed');
|
||
await assert.rejects(
|
||
() => supervisor.acquire('k', 'fp', create),
|
||
(error) => {
|
||
assert.equal(error.code, 'unavailable');
|
||
assert.equal(error.retryable, false);
|
||
return true;
|
||
},
|
||
'超过重启上限后应停止重试',
|
||
);
|
||
assert.ok(supervisor.stats().rejectedBackoff >= 1);
|
||
});
|
||
|
||
test('工具集/身份变化:旧会话作废并立即重建,不进退避也不计崩溃', async () => {
|
||
const log = [];
|
||
const supervisor = new RuntimeSupervisor({ maxConcurrent: 4, idleTimeoutMs: 60_000, maxRestarts: 1, baseBackoffMs: 5_000 });
|
||
const create = countingFactory(log);
|
||
|
||
await supervisor.acquire('k', 'fp-a', create);
|
||
// 指纹变化(例如新注册了 Provider):立刻换新,不能像崩溃那样把用户的请求挡在退避里
|
||
await supervisor.acquire('k', 'fp-b', create);
|
||
assert.ok(log.includes('create:s2'), '指纹变化应立刻重建运行时会话');
|
||
assert.equal(supervisor.stats().active, 1);
|
||
assert.equal(supervisor.stats().evictedStale, 1);
|
||
assert.equal(supervisor.stats().evictedFailed, 0, '更换工具集不是故障,不该计入崩溃回收');
|
||
assert.ok(log.includes('dispose:s1'), '旧会话要真的释放');
|
||
});
|
||
test('平台关停:全部回收并记录引擎日志尾部', async () => {
|
||
const log = [];
|
||
const supervisor = new RuntimeSupervisor({ maxConcurrent: 4, idleTimeoutMs: 60_000, onLog: (message) => log.push(message) });
|
||
const create = countingFactory(log);
|
||
await supervisor.acquire('a', 'fp', create);
|
||
await supervisor.acquire('b', 'fp', create);
|
||
|
||
await supervisor.disposeAll();
|
||
assert.equal(supervisor.stats().active, 0);
|
||
assert.equal(supervisor.stats().evictedClosed, 2);
|
||
assert.ok(log.includes('dispose:s1') && log.includes('dispose:s2'), '关停要把所有引擎资源释放');
|
||
});
|
||
|
||
test('服务层:关停平台时引擎会话被释放(无残留进程)', async () => {
|
||
const log = [];
|
||
let created = 0;
|
||
const runtime = {
|
||
name: 'counting',
|
||
toolExecution: 'platform',
|
||
async createSession() {
|
||
created += 1;
|
||
return fakeSession(`svc${created}`, log);
|
||
},
|
||
};
|
||
|
||
process.env.APS_DEV_PROVIDER = '1';
|
||
const { startServer } = await import('../server/dist/index.js');
|
||
const server = await startServer({ host: '127.0.0.1', port: 0, version: 'test', databasePath: ':memory:', engine: 'fake', runtime });
|
||
try {
|
||
const created1 = await (
|
||
await fetch(`${server.url}/v1/sessions`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: '关停用例' }),
|
||
})
|
||
).json();
|
||
const sessionId = created1.session.sessionId;
|
||
await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ text: '你好', idempotencyKey: 'idem-proc-0001' }),
|
||
});
|
||
await sleep(200);
|
||
assert.equal(server.service.processStats().active, 1, '发过消息后应有一个活跃引擎会话');
|
||
} finally {
|
||
await server.close();
|
||
delete process.env.APS_DEV_PROVIDER;
|
||
}
|
||
assert.ok(log.includes('dispose:svc1'), `关停平台应释放引擎会话,实际日志:${log.join(',')}`);
|
||
}); |