diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 5695f2be5..23ed8141a 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -1,4 +1,4 @@ -import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; +import { leaseClosedPort, leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; import { after as laneAfter } from 'node:test'; // scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants: // agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in @@ -2051,6 +2051,10 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']], } const TESTER='7499'; +// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which +// probes it and starts a Console on it later. Another process can take the port in between. The script +// takes no port 0 and learns no bound port, so that window stays here; a rehearsal that listens on its +// own is the only form that closes it. A caller that never listens takes leaseClosedPort(t) instead. const freePort=async()=>String(await leasePort({after:laneAfter})); const rh=(e,...p)=>join(e.env.HOME,...p); const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')}; @@ -2455,7 +2459,7 @@ test('T56 row 190: named instances side by side on one unit directory, $BIN and await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{ const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a); - const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort(); + const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=String(await leaseClosedPort(t)); const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme'); const unitsBefore=stamp(e.units),binBefore=stamp(e.bin); for(const [label,x,flags,code,msg] of [ diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 42737197f..74993cdb5 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -1,5 +1,3 @@ -import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; -import { after as laneAfter } from 'node:test'; import { test, after } from "node:test"; import assert from "node:assert/strict"; import { @@ -73,10 +71,6 @@ function closeServer(server) { return new Promise((resolvePromise) => server.close(resolvePromise)); } -// Grab an ephemeral free port, then hand it back immediately so a caller can -// try to bind it themselves (used to prove startServer never opened a socket). -function getFreePort() { return leasePort({after:laneAfter}); } - function runCli(args) { return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 }); } @@ -106,7 +100,16 @@ test("isLoopbackHost: rejects non-loopback hosts", () => { test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => { const root = makeRoot(); - const port = await getFreePort(); + // Vikunja #782: the test's own listener allocates the port and holds it for the whole test, so + // no number is probed, closed and bound later. A startServer that called listen() on this port, + // on 127.0.0.1 or on the wildcard address, would fail with EADDRINUSE instead of the ConfigError + // asserted below, which also holds when it closes the socket again before it throws. + const holder = createNetServer(); + await new Promise((resolvePromise, reject) => { + holder.once("error", reject); + holder.listen(0, "127.0.0.1", resolvePromise); + }); + const { port } = holder.address(); let caught = null; try { // startServer is async, so the host refusal surfaces as a rejection @@ -120,15 +123,10 @@ test("startServer: refuses a non-loopback host with ConfigError, never opens a s }); } catch (err) { caught = err; + } finally { + await closeServer(holder); } - assert.ok(caught instanceof ConfigError, "expected a ConfigError"); - - // The port must still be free: startServer must never have called listen(). - await new Promise((resolvePromise, reject) => { - const probe = createNetServer(); - probe.once("error", reject); - probe.listen(port, "127.0.0.1", () => probe.close(resolvePromise)); - }); + assert.ok(caught instanceof ConfigError, `expected a ConfigError, got ${caught}`); }); // ---------------------------------------------------------------------------