fix(test): take staging ports from the kernel instead of a fixed 43100 block
Quality gates / quality (pull_request) Waiting to run

The harness derived every server port from a fixed `nextPort = 43100`, which
lives inside this host's ephemeral range (32768-60999). The kernel can already
have handed one of those numbers to an unrelated outbound socket — a Caddy ->
Gitea connection held 43102 — so the spawn died with EADDRINUSE before a single
assertion ran, and the suite reported a failure for a reason that is not the
code under test. Reproduced 3/3 on main; 6/6 with the block moved out of range.

Ports now come from a bind on :0, readiness is the child's own "Timmy is
listening on http://" line rather than a poll deadline, and a port stolen in the
probe-then-spawn gap is retried (4 attempts) instead of failing the release.

Hold-out: with the leading-slash branch of resolveBasePath disabled, the gate
still fails in ~336 ms with "the server announced it was listening" — the
retry cannot make a broken validator pass.
This commit is contained in:
timmy committed 2026-11-05 12:07:01 +00:00
1 parent f334fe9d20
commit fd347d5159
1 file changed
+62 -23
+62 -23
View File
@@ -2,38 +2,77 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chmod, mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
const hermesFixture = fileURLToPath(new URL('./fixtures/fake-hermes.mjs', import.meta.url));
let nextPort = 43100;
// A hard-coded base port is not ours to take: 43100 sits inside this host's ephemeral range
// (32768-60999), so the kernel can already have handed it to an unrelated outbound socket
// (a Caddy -> Gitea connection held 43102) and the spawn dies with EADDRINUSE before any
// assertion runs. Ask the kernel for a free port, then retry if one is stolen in the gap.
function freePort() {
return new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
}
function waitForListening(child, readStdout, timeoutMs = 20_000) {
return new Promise(resolve => {
let settled = false;
const finish = value => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const timer = setTimeout(() => finish({ state: 'timeout' }), timeoutMs);
child.stdout.on('data', () => {
if (/^Timmy is listening on http:\/\//m.test(readStdout())) finish({ state: 'listening' });
});
child.once('exit', code => finish({ state: 'exit', code }));
});
}
async function startServer(t, env = {}) {
const port = nextPort++;
const origin = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: String(port), ...env, ...(env.TIMMY_PUBLIC_ORIGIN === '__ORIGIN__' ? { TIMMY_PUBLIC_ORIGIN: origin } : {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let stdout = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
t.after(() => child.kill('SIGTERM'));
const deadline = Date.now() + 10_000;
const basePath = (env.TIMMY_BASE_PATH || '').replace(/\/$/, '');
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}: ${stderr}`);
try {
const response = await fetch(`${origin}${basePath}/api/healthz`);
if (response.status) return { origin, child, getStdout: () => stdout };
} catch {}
await new Promise(resolve => setTimeout(resolve, 40));
for (let attempt = 1; attempt <= 4; attempt++) {
const port = await freePort();
const origin = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: { ...process.env, PORT: String(port), ...env, ...(env.TIMMY_PUBLIC_ORIGIN === '__ORIGIN__' ? { TIMMY_PUBLIC_ORIGIN: origin } : {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let stdout = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
const outcome = await waitForListening(child, () => stdout);
if (outcome.state === 'listening') {
t.after(() => child.kill('SIGTERM'));
return { origin, child, getStdout: () => stdout };
}
const closed = new Promise(resolve => child.once('close', resolve));
if (child.exitCode === null) child.kill('SIGKILL');
await closed;
const stolen = /EADDRINUSE/.test(stderr);
if (!stolen || attempt === 4) {
throw new Error(outcome.state === 'timeout'
? `server did not announce listening: ${stderr}`
: `server exited ${outcome.code}${stolen ? ' (port stolen again)' : ''}: ${stderr}`);
}
}
throw new Error(`server did not become ready: ${stderr}`);
}
test('staging host can bind loopback instead of every network interface', async t => {