DUNGEONS-33: clean shutdown locks and add optional Telegram 2048

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 09:14:08 +05:00
parent eb135ac6ef
commit 5e7b24cbaf
9 changed files with 240 additions and 22 deletions

95
test/shutdown.test.js Normal file
View 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);
});
}