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 });