Files
stack/packages/webui/tests/teardown.test.mjs
jason.woltje f9f34bd249 Fix testhang review findings F1-F4 (P19 #865)
serve.mjs keeps the bound address as well as the port, so the settings,
queue and business routes answer a request that arrives after close().
browser.mjs keeps an exited leader's group tracked until it is killed at
exit, and close() signals the group only while the leader is alive. The
late-request test is event-based with one owned timeout. New test: the
chromium group dies when the main process dies and close() never runs.

Webui browser and server files (92, pinned-copy excluded): 741 pass, 0 fail.
2026-11-08 15:22:48 -06:00

175 lines
8.8 KiB
JavaScript

// P19 #865: test teardown must finish, or fail with a clear message, within a bounded time.
// t.after hooks run in the order they are registered, so the org browser files tore down
// the server (and sometimes the data directory) while the page was still live. Full-suite
// runs then hung for many minutes in two ways:
// - org-quick: a page's GET /api/config reached the server on a kept-alive connection after
// close(server) had stopped the listener; the handler threw on server.address().port (null
// once closed), the request was never answered, and server.close() waited for it forever.
// - org-delete: removing the data directory while the server still wrote there failed with
// ENOTEMPTY; node:test then skipped the later hooks, and the open server and chromium kept
// the file running.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const within = (promise, ms, what) => {
let timer;
const late = new Promise(resolve => { timer = setTimeout(resolve, ms, `${what} still pending after ${ms} ms`); });
return Promise.race([promise.then(() => 'done'), late]).finally(() => clearTimeout(timer));
};
test('close(server) finishes when a request arrives on an open connection after close starts', { timeout: 20000 }, async () => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
const { port } = server.address();
const socket = connect(port, '127.0.0.1');
socket.on('error', () => {});
await new Promise(resolve => socket.once('connect', resolve));
// Half a request: the connection is busy, so closing idle connections leaves it open.
socket.write(`GET /api/config HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n`);
await new Promise(resolve => setTimeout(resolve, 300));
const closing = close(server);
socket.write('\r\n');
const outcome = await within(closing, 8000, 'close(server)');
socket.destroy();
assert.equal(outcome, 'done');
});
// Sends half a request, closes the server, then finishes the request on the open connection.
// Returns the status line, or null when no reply came within 3 s. Cleanup always runs.
async function lateRequest(path) {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
const { port } = server.address();
const socket = connect(port, '127.0.0.1');
socket.on('error', () => {});
let timer;
try {
await new Promise(resolve => socket.once('connect', resolve));
socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n`);
await new Promise(resolve => setTimeout(resolve, 300));
let reply = '';
const status = new Promise(resolve => socket.on('data', chunk => {
reply += chunk;
const line = /^HTTP\/1\.1 \d{3}/.exec(reply);
if (line) resolve(line[0]);
}));
server.close();
socket.write('\r\n');
return await Promise.race([status, new Promise(resolve => { timer = setTimeout(resolve, 3000, null); })]);
} finally {
clearTimeout(timer);
socket.destroy();
server.closeAllConnections();
}
}
test('the server answers a request that arrives on an open connection after close starts', { timeout: 20000 }, async () => {
assert.equal(await lateRequest('/api/config'), 'HTTP/1.1 200');
// These routes build the Console address from the listener (the server has no reads here).
assert.equal(await lateRequest('/api/settings'), 'HTTP/1.1 200');
assert.equal(await lateRequest('/api/queue'), 'HTTP/1.1 503');
});
test('b.close() ends a browser that does not answer Browser.close', { timeout: 30000 }, async () => {
const b = await browser({ machine: false });
process.kill(b.pid, 'SIGSTOP');
const started = Date.now();
const outcome = await within(b.close(), 15000, 'b.close()');
if (outcome !== 'done') process.kill(b.pid, 'SIGKILL');
assert.equal(outcome, 'done');
assert.throws(() => process.kill(b.pid, 0), { code: 'ESRCH' });
assert.ok(Date.now() - started < 15000);
});
test('a browser left open by a failed after hook does not keep its test file running', { timeout: 60000 }, async t => {
// An earlier failing hook skips the later ones (node:test), so b.close() never runs. The org
// delete file hung this way for 23 minutes with one chromium child.
const dir = await mkdtemp(join(tmpdir(), 'teardown-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const file = join(dir, 'skipped-close.test.mjs');
await writeFile(file, `import { test } from 'node:test';
import { browser } from ${JSON.stringify(new URL('./browser.mjs', import.meta.url).href)};
test('skipped close', async t => {
t.after(() => { throw new Error('earlier hook failed'); });
const b = await browser({ machine: false });
t.after(() => b.close());
console.log('chromium-pid=' + b.pid);
});
`);
// Without NODE_TEST_CONTEXT the child is its own test run, not a file of this one.
const { NODE_TEST_CONTEXT, ...env } = process.env;
const child = spawn(process.execPath, ['--test', '--test-reporter=tap', file], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', chunk => { out += chunk; });
child.stderr.on('data', chunk => { out += chunk; });
const code = await within(new Promise(resolve => child.once('exit', resolve)).then(code => { out += `\nexit=${code}`; }), 40000, 'test file')
.then(outcome => outcome === 'done' ? Number(/exit=(\d+)/.exec(out)[1]) : 'still running');
if (code === 'still running') child.kill('SIGKILL');
const pid = Number(/chromium-pid=(\d+)/.exec(out)?.[1]);
if (pid && code === 'still running') try { process.kill(pid, 'SIGKILL'); } catch {}
assert.notEqual(code, 'still running', 'the test file must exit');
assert.notEqual(code, 0, 'the failed hook still fails the file');
assert.match(out, /earlier hook failed/);
assert.ok(pid > 0, out);
await new Promise(resolve => setTimeout(resolve, 200));
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, 'chromium must not outlive the file');
});
test('a CDP call fails at once when the browser has exited', { timeout: 20000 }, async () => {
const b = await browser({ machine: false });
process.kill(b.pid, 'SIGKILL');
const started = Date.now();
await assert.rejects(b.evaluate('1'), /browser exited/);
assert.ok(Date.now() - started < 5000, 'a call to a dead browser must not wait for the 25 s CDP timeout');
await b.close();
});
// Members of process group pgid, from /proc (field 5 of stat is the group).
const groupMembers = async pgid => {
const { readdir, readFile } = await import('node:fs/promises');
const out = [];
for (const name of await readdir('/proc')) {
if (!/^\d+$/.test(name)) continue;
try {
const stat = await readFile(`/proc/${name}/stat`, 'utf8');
if (Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[2]) === pgid) out.push(Number(name));
} catch {}
}
return out;
};
test('chromium children die when the main process dies, even if close() never runs', { timeout: 60000 }, async t => {
// Review F2: the whole group is stopped, the main process is killed, and close() is skipped.
// Chromium cannot reap its own children then, so only browser.mjs can end them.
const dir = await mkdtemp(join(tmpdir(), 'teardown-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const file = join(dir, 'leader-exit.mjs');
await writeFile(file, `import { browser } from ${JSON.stringify(new URL('./browser.mjs', import.meta.url).href)};
const b = await browser({ machine: false });
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('pgid=' + b.pid);
process.kill(-b.pid, 'SIGSTOP');
process.kill(b.pid, 'SIGKILL');
await new Promise(resolve => setTimeout(resolve, 1000));
`);
const { NODE_TEST_CONTEXT, ...env } = process.env;
const child = spawn(process.execPath, [file], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', chunk => { out += chunk; });
child.stderr.on('data', chunk => { out += chunk; });
await within(new Promise(resolve => child.once('exit', resolve)), 30000, 'leader-exit script');
const pgid = Number(/pgid=(\d+)/.exec(out)?.[1]);
assert.ok(pgid > 0, out);
const left = await groupMembers(pgid);
// Clean up our own group if the check fails, so a red run leaves nothing behind.
if (left.length) { try { process.kill(-pgid, 'SIGKILL'); } catch {} }
assert.deepEqual(left, [], `chromium group ${pgid} still has members after the script exited`);
});