DUNGEONS-33: clean shutdown locks and add optional Telegram 2048
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
95
test/shutdown.test.js
Normal file
95
test/shutdown.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AccessStore } from '../src/store.js';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
function setup(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dng-shutdown-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
return join(directory, 'access.json');
|
||||
}
|
||||
function launch(t, args, env) {
|
||||
const child = spawn(process.execPath, args, { cwd: root, env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const exited = once(child, 'exit');
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => { output += chunk; });
|
||||
child.stderr.on('data', chunk => { output += chunk; });
|
||||
t.after(async () => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
await exited;
|
||||
});
|
||||
const ready = async text => {
|
||||
while (!output.includes(text)) {
|
||||
const result = await Promise.race([once(child.stdout, 'data').then(() => true), exited.then(() => false)]);
|
||||
assert.ok(result, output);
|
||||
}
|
||||
};
|
||||
return { child, exited, ready, output: () => output };
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
test(`gateway releases its lock on ${signal} and can restart`, { timeout: 10000 }, async t => {
|
||||
const dataFile = setup(t);
|
||||
const reservation = createServer().listen(0, '127.0.0.1');
|
||||
await once(reservation, 'listening');
|
||||
const port = reservation.address().port;
|
||||
await new Promise(resolve => reservation.close(resolve));
|
||||
const env = { SESSION_SECRET: 'test-secret-with-at-least-32-characters', DATA_FILE: dataFile, HOST: '127.0.0.1', PORT: String(port), DOTENV_CONFIG_PATH: join(dataFile, 'no-env') };
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const run = launch(t, ['src/index.js'], env);
|
||||
await run.ready('gateway listening');
|
||||
assert.ok(existsSync(`${dataFile}.lock`));
|
||||
run.child.kill(signal);
|
||||
assert.deepEqual(await run.exited, [0, null], run.output());
|
||||
assert.equal(existsSync(`${dataFile}.lock`), false);
|
||||
assert.equal(JSON.parse(readFileSync(dataFile)).version, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('startup failure releases the acquired lock', { timeout: 10000 }, async t => {
|
||||
const dataFile = setup(t);
|
||||
const occupied = createServer().listen(0, '127.0.0.1');
|
||||
await once(occupied, 'listening');
|
||||
t.after(() => new Promise(resolve => occupied.close(resolve)));
|
||||
const run = launch(t, ['src/index.js'], { SESSION_SECRET: 'test-secret-with-at-least-32-characters', DATA_FILE: dataFile, HOST: '127.0.0.1', PORT: String(occupied.address().port), DOTENV_CONFIG_PATH: join(dataFile, 'no-env') });
|
||||
assert.deepEqual(await run.exited, [1, null]);
|
||||
assert.match(run.output(), /EADDRINUSE/);
|
||||
assert.equal(existsSync(`${dataFile}.lock`), false);
|
||||
});
|
||||
|
||||
for (const mode of ['drain', 'hang', 'error']) {
|
||||
test(`shutdown ${mode}: repeated interrupts keep the lock until writers stop`, { timeout: 10000 }, async t => {
|
||||
const dataFile = setup(t);
|
||||
const run = launch(t, ['--input-type=module', '-e', `
|
||||
import { AccessStore } from './src/store.js';
|
||||
import { installShutdown } from './src/shutdown.js';
|
||||
const store = new AccessStore({ dataFile: process.env.DATA_FILE });
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
installShutdown(store, async () => {
|
||||
console.log('draining');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
if (process.env.MODE === 'error') throw new Error('failed drain');
|
||||
if (process.env.MODE === 'hang') await new Promise(() => {});
|
||||
store.change(state => { state.telegramOffset = 42; });
|
||||
clearInterval(keepAlive);
|
||||
}, { timeout: 500 });
|
||||
console.log('ready');
|
||||
`], { DATA_FILE: dataFile, MODE: mode });
|
||||
await run.ready('ready');
|
||||
run.child.kill('SIGINT');
|
||||
await run.ready('draining');
|
||||
assert.throws(() => new AccessStore({ dataFile }), /locked/);
|
||||
run.child.kill('SIGINT');
|
||||
assert.deepEqual(await run.exited, [mode === 'drain' ? 0 : 1, null], run.output());
|
||||
assert.equal(existsSync(`${dataFile}.lock`), false);
|
||||
assert.equal(JSON.parse(readFileSync(dataFile)).telegramOffset, mode === 'drain' ? 42 : 0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user