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