test(row 333): hold the port a refusal test uses instead of probing and closing it (Vikunja #782)

control-board serve.test: the non-loopback refusal test took a port from a
probe-and-close helper and then called startServer on it. It now binds its
own listener, keeps it for the whole test, and closes it in finally, so the
port cannot be taken by another process in between.

stack-release.test T-I4: the free port goes only to webui-service.sh install
rows that refuse before any listen, so it is now a closed lease
(leaseClosedPort), which refuses connects and blocks other binds.

The 22 other freePort callers hand the number to `stack-release.sh rehearse`,
which probes it and starts a Console later. That needs a script change and
is left as a design, with a comment above freePort naming the window.
This commit is contained in:
jason.woltje committed 2026-11-02 20:22:14 -06:00
1 parent 0e7e203db8
commit 5fbf3d2b51
2 files changed
+19 -17

No files matched your search

+6 -2
View File
@@ -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'; import { after as laneAfter } from 'node:test';
// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants: // 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 // 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'; 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 freePort=async()=>String(await leasePort({after:laneAfter}));
const rh=(e,...p)=>join(e.env.HOME,...p); const rh=(e,...p)=>join(e.env.HOME,...p);
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')}; 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)=>{ 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 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 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); const unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
for(const [label,x,flags,code,msg] of [ for(const [label,x,flags,code,msg] of [
+13 -15
View File
@@ -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 { test, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
@@ -73,10 +71,6 @@ function closeServer(server) {
return new Promise((resolvePromise) => server.close(resolvePromise)); 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) { function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 }); 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 () => { test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => {
const root = makeRoot(); 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; let caught = null;
try { try {
// startServer is async, so the host refusal surfaces as a rejection // 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) { } catch (err) {
caught = err; caught = err;
} finally {
await closeServer(holder);
} }
assert.ok(caught instanceof ConfigError, "expected a ConfigError"); assert.ok(caught instanceof ConfigError, `expected a ConfigError, got ${caught}`);
// 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));
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------