All checks were successful
Quality gates / quality (pull_request) Successful in 1m58s
- server.mjs tracks in-flight chats: browser disconnects abort the queued turn (releasing its slot), and SIGTERM/SIGINT cancels every in-flight turn so no Hermes subprocess is orphaned. - vision-service forwards a per-request AbortSignal combined with the provider deadline via AbortSignal.any. - acceptance suites cover overload fallback, urgent bypass under saturation, disconnect cleanup, recovery, and shutdown orphan checks. - zero-call invariant tests prove urgent text never reaches Hermes or the vision provider under load.
94 lines
3.9 KiB
JavaScript
94 lines
3.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmod, mkdtemp, rm, readFile } from 'node:fs/promises';
|
|
import { spawn } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { constants as fsConstants } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
const fixture = fileURLToPath(new URL('./fixtures/slow-hermes.mjs', import.meta.url));
|
|
|
|
test('server shutdown cancels in-flight turns and leaves no orphan Hermes subprocesses', async () => {
|
|
const workdir = await mkdtemp(join(tmpdir(), 'timmy-shutdown-'));
|
|
await chmod(fixture, fsConstants.S_IRWXU);
|
|
const child = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
HOST: '127.0.0.1',
|
|
PORT: '4188',
|
|
TIMMY_AGENT_ENABLED: 'true',
|
|
TIMMY_AGENT_ACCESS_TOKEN: 'shutdown-accept-code-2026',
|
|
TIMMY_PUBLIC_ORIGIN: 'http://127.0.0.1:4188',
|
|
TIMMY_AGENT_WORKDIR: workdir,
|
|
TIMMY_HERMES_COMMAND: fixture,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let reportedOrigin = '';
|
|
child.stdout.on('data', chunk => {
|
|
const match = String(chunk).match(/listening on http:\/\/([^:]+):(\d+)/);
|
|
if (match && !reportedOrigin) reportedOrigin = `http://${match[1]}:${match[2]}`;
|
|
});
|
|
const deadline = Date.now() + 10_000;
|
|
while (!reportedOrigin && Date.now() < deadline && child.exitCode === null) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
assert.ok(reportedOrigin, 'server reported its port');
|
|
|
|
const unlockResponse = await fetch(`${reportedOrigin}/api/agent/unlock`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin' },
|
|
body: JSON.stringify({ accessCode: 'shutdown-accept-code-2026' }),
|
|
});
|
|
assert.equal(unlockResponse.status, 200);
|
|
const browserCookie = unlockResponse.headers.get('set-cookie').split(';', 1)[0];
|
|
|
|
// Start a turn wedged on the fixture's HOLD gate.
|
|
fetch(`${reportedOrigin}/api/agent/chat`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', origin: reportedOrigin, 'sec-fetch-site': 'same-origin', cookie: browserCookie },
|
|
body: JSON.stringify({ message: 'HOLD shutdown probe', ledger: [] }),
|
|
}).catch(() => {});
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
|
|
const pidLog = join(workdir, 'fixture-pids.log');
|
|
// Wait until the fixture for this run's HOLD turn is actually alive.
|
|
let lastPid = null;
|
|
const spawnDeadline = Date.now() + 8_000;
|
|
while (Date.now() < spawnDeadline) {
|
|
const recorded = ((await readFile(pidLog, 'utf8').catch(() => '')) || '')
|
|
.split('\n').map(Number).filter(Boolean);
|
|
const candidate = recorded.at(-1);
|
|
if (candidate) {
|
|
let alive = true;
|
|
try { process.kill(candidate, 0); } catch { alive = false; }
|
|
if (alive) {
|
|
lastPid = candidate;
|
|
break;
|
|
}
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
assert.ok(lastPid, 'fixture subprocess was spawned for the in-flight turn');
|
|
let aliveBefore = true;
|
|
try { process.kill(lastPid, 0); } catch { aliveBefore = false; }
|
|
assert.ok(aliveBefore, `fixture ${lastPid} is running before shutdown`);
|
|
|
|
// Shut the server down while the turn is still running.
|
|
child.kill('SIGTERM');
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error('server did not exit after SIGTERM')), 5_000);
|
|
child.on('exit', code => { clearTimeout(timeout); resolve(code); });
|
|
});
|
|
|
|
// The in-flight fixture must be gone — no orphan subprocess may survive.
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
let aliveAfter = true;
|
|
try { process.kill(lastPid, 0); } catch { aliveAfter = false; }
|
|
assert.equal(aliveAfter, false, `fixture process ${lastPid} survived server shutdown`);
|
|
await rm(workdir, { recursive: true, force: true }).catch(() => {});
|
|
}, { timeout: 30_000 });
|