Repository navigation
Test battery: free loopback ports per run, so two batteries on one host never share a stub - #192
Conversation
…st never share a stub Every failed CI test job of the last hour overlapped another test job of this repository on the same self-hosted machine, and every job that ran alone passed. The battery bound fixed ports (stub 8900, dead upstream 8901, instances 8810 to 8813) and runner rows share the host's loopback, so two batteries shared one stub (one counted the other's requests) and the runner's port-freeing step killed the other job's processes. The runner now picks six free ports per run and hands them to the stub and the suites in CORDON_TEST_PORTS; by hand a suite still falls back to the fixed ports.
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The per-run port wiring reaches the stub and integration suites, but manually pinned duplicate ports and Windows teardown need correction before the battery can reliably run with its new configuration.
1. Blocking: _test_ports.mjs:18
if (given.length !== NAMES.length || given.some((p) => !Number.isInteger(p) || p < 1 || p > 65535))
throw new Error(CORDON_TEST_PORTS must be ${NAMES.length} ports (${NAMES.join(",")}), got "${value}");
The new environment format accepts the same valid port more than once. For example, CORDON_TEST_PORTS=10000,10001,10002,10002,10004,10005 passes this check, but the computed instances object has two entries keyed by 10002; the later one overwrites the earlier instance configuration. The runner then starts too few instances and suites address a service with the wrong configuration or fail to connect.
Suggested fix:
Reject duplicate values while parsing, for example by requiring `new Set(given).size === NAMES.length` along with the existing numeric validation.
2. Blocking: _run_tests.mjs:5
import { spawn } from "node:child_process";
This removal leaves the Windows branch of die calling execSync without a binding. On Windows, when a child has a PID, that call raises ReferenceError, which the surrounding catch discards, and there is no fallback kill. Every test child can therefore remain running after the runner exits, leaking processes despite the teardown code intended to prevent it.
Suggested fix:
Keep `execSync` imported from `node:child_process`, or replace the Windows teardown implementation with an available API and verify it terminates the process tree.
Minor:
_run_tests.mjs:11: This comment narrates the incident that led to the change rather than documenting behavior needed to maintain the port-selection code.
rule:none
Fix at edfbbab
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: request changes. The duplicate-port validation and Windows teardown from the prior review are corrected, but the new allocation still does not reserve ports across concurrent batteries.
1. Blocking: _run_tests.mjs:15
const freePort = () =>
new Promise((resolve, reject) => {
const srv = createServer();
srv.once("error", reject);
srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
Closing the probe socket before any child binds the selected port leaves a race between batteries. For example, battery A can obtain and close port 40000, then battery B can obtain and close that same port before A launches its stub or instance. One battery then fails with EADDRINUSE, or can attach to the other battery's service if its request arrives first. Thus simultaneous runs are not guaranteed distinct ports, which is the failure this change is intended to prevent.
Suggested fix:
Use a cross-process allocation mechanism that retains ownership until the corresponding service is ready, or make the runner and children coordinate a reservation/port assignment that cannot be reused by another battery during startup. Add a concurrency-focused test or harness check that demonstrates two simultaneous allocations cannot return an overlapping set.
rule:none
Fix at 59613df
Files: Tests: |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Verdict: approve. The test runner now allocates and retains host-wide ownership records for six distinct per-run ports, passes that configuration through to every stub, instance, and affected suite, and restores Windows process-tree teardown. The parser and concurrent-reservation coverage exercise duplicate rejection, non-overlapping allocations, retained locks, and release behavior.
Why
Main went red at 749fc44 on three
_test_subject_proxy.mjscases that had passed on the same commit's PR check, and a rerun passed. The job history explains it: every failedtestjob overlapped anothertestjob of this repository on the same self-hosted machine, and every job that ran alone passed.The battery bound fixed loopback ports (stub 8900, a dead upstream on 8901, instances 8810 to 8813). Self-hosted runner rows share the host's loopback, so two batteries at once shared one stub (one battery's
/__callscounted the other's requests: "upstream never called" cases saw a call, abodies[0]was another battery's request) and the runner's "free the port" step killed whatever listened there, including the other job's processes.What
_test_ports.mjs: one table of the battery's ports, read fromCORDON_TEST_PORTS=stub,dead,main,nosecret,noadmin,restore, with the historical fixed ports as the default so a suite can still be run by hand against a stub and an instance started by hand._run_tests.mjs: picks six free ports per run (bind 0, read, close), exports the variable to the stub and the suites, logs them, and no longer kills listeners on fixed ports._stub-upstream.mjslistens onSTUB_PORT;_test_proxy.mjs,_test_stream.mjs,_test_subject_proxy.mjstake their URLs and the dead port from the table.Proof
Two batteries started two seconds apart on one machine both print distinct
ports:lines and both end "all suites green". A singlenpm testis unchanged apart from the ports line. No src change.