fix(test): take staging ports from the kernel instead of a fixed 43100 block
Quality gates / quality (pull_request) Waiting to run
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:
1 parent
f334fe9d20
commit
fd347d5159
1 file changed
+62
-23
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user