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