Hold closed fixture ports for the test's life (Vikunja #781)
leasePort({closed:true}) ignored its option: it probed an ephemeral port and
closed it, so a parallel test file could bind it and answer a request the
test expected to fail (preview.test.mjs:209 saw 200 instead of 502).
leaseClosedPort holds a bound, non-listening socket in a perl child until the
test's after hook. Connects still get ECONNREFUSED, bind() from another
process gets EADDRINUSE, and the kernel's ephemeral allocation skips the port.
Lane mode keeps its band and per-port lock, and releases the socket before
the lock. leasePort now refuses a closed option, so a stale call cannot get
the unheld port again.
Unanswerable callers (webui preview, serve, service) move to leaseClosedPort(t).
Callers that listen on the port themselves (stack-release rehearse, control-board
serve, tester-cli) keep leasePort and drop the ignored option.
This commit is contained in:
1 parent
7e0d3c93f3
commit
1009bbfaf9
8 files changed
+103
-24
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,rmSync,mkdirSync,writeFileSync } from 'node:fs';import { join } from 'node:path';import { createServer } from 'node:net';import { once } from 'node:events';
|
||||
import { mkdtempSync,rmSync,mkdirSync,writeFileSync } from 'node:fs';import { join } from 'node:path';import { createServer,connect } from 'node:net';import { once } from 'node:events';
|
||||
const api=await import('../../../scripts/testing/lane-fixtures.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const evidence=await import('../../../scripts/testing/lane-evidence.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
test('parallel lease holders use only the active lane and its shared state',async t=>{
|
||||
@@ -92,3 +92,52 @@ test('an unrelated session can append while a supervised step runs without becom
|
||||
const changed=evidence.homeMetadata(home).filter(e=>prior.get(e.path)!==JSON.stringify(e));assert.ok(changed.some(e=>e.path===file));
|
||||
const attributed=evidence.attributeEntries(changed,{nonce:'abcdef123456',tempRoot:join(root,'temp'),fixturePaths:[]});assert.equal(attributed.owned.length,0);assert.ok(attributed.unrelated.length>0);
|
||||
});
|
||||
// Vikunja #781: a port that nothing answers on is held for the test's life, so a parallel file
|
||||
// cannot bind it and answer a request the test expects to fail.
|
||||
const connectCode=port=>new Promise(resolve=>{const c=connect(port,'127.0.0.1');c.once('error',e=>resolve(e.code));c.once('connect',()=>{c.destroy();resolve('connected');});});
|
||||
const bindCode=(port,host='127.0.0.1')=>new Promise(resolve=>{const s=createServer();s.once('error',e=>resolve(e.code));s.listen(port,host,()=>s.close(()=>resolve('bound')));});
|
||||
for(const mode of ['lane','no lane'])test(`a closed port lease refuses connects and stays held against binds, other leases and the kernel until release (${mode})`,async t=>{
|
||||
const ctx=leaseContext(t),context=mode==='lane'?ctx:{lane:''},callbacks=[],fixture={after:f=>callbacks.push(f)};
|
||||
const release=async()=>{for(const f of callbacks.splice(0).reverse())await f();};t.after(release);
|
||||
const port=await api.leaseClosedPort(fixture,context),other=await api.leaseClosedPort(fixture,context),listener=await api.leasePort(fixture,context);
|
||||
const lock=n=>join(ctx.stateRoot,'ports',ctx.lane,`${n}.lock`);
|
||||
assert.equal(new Set([port,other,listener]).size,3);
|
||||
if(mode==='lane'){const [lo,hi]=BANDS[ctx.lane];for(const n of [port,other,listener]){assert.ok(n>=lo&&n<=hi);assert.equal(spawnSync('flock',['-n',lock(n),'true']).status,1);}}
|
||||
assert.equal(await connectCode(port),'ECONNREFUSED');
|
||||
assert.equal(await bindCode(port),'EADDRINUSE');assert.equal(await bindCode(port,'0.0.0.0'),'EADDRINUSE');
|
||||
if(mode==='no lane'){
|
||||
const seen=new Set();
|
||||
for(let i=0;i<200;i++){const s=createServer();await new Promise(r=>s.listen(0,'127.0.0.1',r));seen.add(s.address().port);await new Promise(r=>s.close(r));}
|
||||
assert.equal(seen.has(port),false);assert.equal(seen.has(other),false);
|
||||
}
|
||||
await release();
|
||||
assert.equal(await bindCode(port),'bound');assert.equal(await connectCode(port),'ECONNREFUSED');
|
||||
if(mode==='lane')for(const n of [port,other,listener])assert.equal(spawnSync('flock',['-n',lock(n),'true']).status,0);
|
||||
});
|
||||
test('a closed port lease skips a locked port and a foreign listener, and refuses an exhausted band',async t=>{
|
||||
const ctx=leaseContext(t),callbacks=[],original=BANDS[ctx.lane];
|
||||
const port=await api.leasePort({after:f=>callbacks.push(f)},ctx),server=createServer();
|
||||
try{
|
||||
BANDS[ctx.lane]=[port,port];
|
||||
await assert.rejects(api.leaseClosedPort(t,ctx),/exhausted/);
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);});
|
||||
await callbacks.pop()();
|
||||
await assert.rejects(api.leaseClosedPort(t,ctx),/exhausted/);assert.equal(server.listening,true);
|
||||
await new Promise(r=>server.close(r));
|
||||
const got=await api.leaseClosedPort({after:f=>callbacks.push(f)},ctx);assert.equal(got,port);
|
||||
assert.equal(await connectCode(port),'ECONNREFUSED');
|
||||
}finally{BANDS[ctx.lane]=original;if(server.listening)await new Promise(r=>server.close(r));for(const f of callbacks.splice(0).reverse())await f();}
|
||||
});
|
||||
test('leasePort refuses the closed option it used to ignore',async()=>{
|
||||
await assert.rejects(api.leasePort({after(){}},{closed:true}),/leaseClosedPort/);
|
||||
});
|
||||
test('abrupt owner death releases a closed port holder',async t=>{
|
||||
const ctx=leaseContext(t),modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
|
||||
const code=`const {leaseClosedPort}=await import(${JSON.stringify(modulePath)});const port=await leaseClosedPort({after:()=>{}},JSON.parse(process.argv[1]));console.log(port);setInterval(()=>{},1000);`;
|
||||
const child=spawn(process.execPath,['--input-type=module','-e',code,JSON.stringify(ctx)],{stdio:['ignore','pipe','pipe']});const done=new Promise(r=>child.once('exit',r));ctx.cleanup(async()=>{child.kill('SIGKILL');await done;});
|
||||
const port=await new Promise(r=>child.stdout.once('data',b=>r(Number(String(b).trim()))));
|
||||
assert.equal(await bindCode(port),'EADDRINUSE');
|
||||
child.kill('SIGKILL');await done;
|
||||
let code2;for(let i=0;i<100&&code2!=='bound';i++){code2=await bindCode(port);if(code2!=='bound')await delay(20);}
|
||||
assert.equal(code2,'bound');
|
||||
});
|
||||
@@ -2048,7 +2048,7 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
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),'')};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ function closeServer(server) {
|
||||
|
||||
// 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}, {closed:true}); }
|
||||
function getFreePort() { return leasePort({after:laneAfter}); }
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { leaseClosedPort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
||||
@@ -207,7 +206,7 @@ test('in preview mode every write answers 403 read-only, from the public name an
|
||||
});
|
||||
|
||||
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const dead = `http://127.0.0.1:${await leaseClosedPort(t)}`;
|
||||
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
||||
const loopback = { host: `127.0.0.1:${port}` };
|
||||
const inbox = await send(port, { headers: loopback });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { leaseClosedPort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, get } from 'node:http';
|
||||
@@ -98,8 +97,8 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
|
||||
} finally { await close(web); await close(upstream); }
|
||||
});
|
||||
|
||||
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
||||
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
test('unreachable board reports URL; CLI rejects unsupported options', async t => {
|
||||
const url = `http://127.0.0.1:${await leaseClosedPort(t)}`;
|
||||
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { leaseClosedPort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
@@ -48,7 +47,7 @@ function filled(flags, root = REPO) {
|
||||
|
||||
// A loopback port with nothing listening, so status's /healthz request
|
||||
// reaches no real Console.
|
||||
async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
async function closedPort(t) { return leaseClosedPort(t); }
|
||||
|
||||
function tmp(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
||||
@@ -183,7 +182,7 @@ test('tester service install and status show the tester port without changing se
|
||||
const dir = tmp(t);
|
||||
const unitDir = join(dir, 'units');
|
||||
const bin = standIn(dir);
|
||||
const port = await closedPort();
|
||||
const port = await closedPort(t);
|
||||
const installed = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--tester-port', '1024', ...SSO_SEND], bin);
|
||||
assert.equal(installed.status, 0, installed.stderr);
|
||||
assert.match(installed.stdout, /^serve: .* --tester-port 1024( |$)/m);
|
||||
@@ -454,7 +453,7 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
|
||||
const dir = tmp(t);
|
||||
const unitDir = join(dir, 'units');
|
||||
const target = join(unitDir, 'mosaic-webui.service');
|
||||
const port = await closedPort();
|
||||
const port = await closedPort(t);
|
||||
const flags = `--port ${port} --sso`;
|
||||
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...SSO_SEND]);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
@@ -728,7 +727,7 @@ test('status names the copy and whether refactor has moved past it, and says whe
|
||||
const unitDir = join(dir, 'units');
|
||||
const target = join(unitDir, 'mosaic-webui.service');
|
||||
const bin = standIn(mkdtempSync(join(dir, 'si-')));
|
||||
const port = await closedPort();
|
||||
const port = await closedPort(t);
|
||||
const head = await new Promise((resolve, reject) => {
|
||||
const git = spawn('git', ['-C', REPO, 'rev-parse', '--verify', 'refs/heads/refactor']);
|
||||
let out = '';
|
||||
@@ -804,7 +803,7 @@ test('proxy proof render refuses incomplete non-preview missing unsafe-mode and
|
||||
test('proxy proof status retains read-only and tester reporting with both proof flags', async t => {
|
||||
const dir = tmp(t), unitDir = join(dir, 'units'), file = join(dir, 'proof.fixture');
|
||||
writeFileSync(file, 'fixture', { mode: 0o600 });
|
||||
const port = await closedPort(), bin = standIn(dir);
|
||||
const port = await closedPort(t), bin = standIn(dir);
|
||||
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--tester-port', '1024', '--proof-header', 'x-console-proof', '--proof-file', file], bin);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const status = await run(dir, ['status', '--dir', unitDir], bin);
|
||||
|
||||
@@ -18,7 +18,7 @@ const get = (port, path) => new Promise((resolve, reject) => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
|
||||
}); q.on('error', reject); q.end();
|
||||
});
|
||||
async function freePort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
async function freePort() { return leasePort({after:laneAfter}); }
|
||||
|
||||
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
|
||||
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Imported only by tests. The port holder lives until the fixture closes.
|
||||
// Imported only by tests. Every port lease is held until the fixture closes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -12,21 +12,54 @@ async function hold(file){
|
||||
if(!ok)return null;
|
||||
return async()=>{child.stdin.end('\n');await exited;};
|
||||
}
|
||||
// A free port the caller will listen on itself. Nothing holds the port itself, so this is for
|
||||
// callers that bind it next; the port is probed and closed.
|
||||
async function probe(port){const s=createServer();try{await new Promise((resolve,reject)=>{s.once('error',reject);s.listen(port,'127.0.0.1',resolve);});const found=s.address().port;await new Promise(r=>s.close(r));return found;}catch(e){if(s.listening)await new Promise(r=>s.close(r));if(e.code==='EADDRINUSE')return null;throw e;}}
|
||||
export async function leasePort(t,{lane=process.env.MOSAIC_GATE_LANE,stateRoot=process.env.MOSAIC_GATE_STATE}={}){
|
||||
if(!lane){const port=await probe(0);return port;}
|
||||
const probed=async port=>{const found=await probe(port);return found?{port:found}:null;};
|
||||
// A port nothing answers on. A bound socket that never listens refuses every connect, like any
|
||||
// closed port, while bind() from another process and the kernel's ephemeral allocation both
|
||||
// skip it. Node cannot make one (its listen() always follows bind()), so a perl child holds the
|
||||
// socket and exits when its stdin closes, which also covers the fixture dying abruptly.
|
||||
const BOUND=String.raw`use strict;use Socket;$|=1;
|
||||
socket(my $s,PF_INET,SOCK_STREAM,0)or die"socket: $!\n";
|
||||
bind($s,sockaddr_in($ARGV[0],inet_aton("127.0.0.1")))or do{my $busy=$!{EADDRINUSE};print STDERR"bind: $!\n";exit($busy?75:76)};
|
||||
my($port)=sockaddr_in(getsockname($s));print"bound $port\n";<STDIN>;`;
|
||||
async function bound(port){
|
||||
const child=spawn('perl',['-e',BOUND,String(port)],{stdio:['pipe','pipe','pipe']});
|
||||
let err='',out='';child.stderr.on('data',c=>err+=c);child.stdin.on('error',()=>{});
|
||||
const exited=once(child,'exit');exited.catch(()=>{});
|
||||
const found=await new Promise((resolve,reject)=>{
|
||||
child.stdout.on('data',c=>{out+=c;const m=out.match(/^bound (\d+)\n/);if(m)resolve(Number(m[1]));});
|
||||
child.once('exit',code=>code===75?resolve(null):reject(Error(`port holder exited ${code}: ${err}`)));
|
||||
child.once('error',e=>reject(Error(`port holder did not start (perl is required): ${e.message}`)));
|
||||
});
|
||||
if(found===null)return null;
|
||||
return {port:found,release:async()=>{child.stdin.end('\n');await exited;}};
|
||||
}
|
||||
// A lane takes ports from its own band under a per-port lock; no lane takes the kernel's.
|
||||
async function lease(t,{lane=process.env.MOSAIC_GATE_LANE,stateRoot=process.env.MOSAIC_GATE_STATE}={},attempt){
|
||||
if(!lane){
|
||||
const got=await attempt(0);if(!got)refuse('no port available');
|
||||
if(got.release)t.after(got.release);
|
||||
return got.port;
|
||||
}
|
||||
if(!BANDS[lane]||!stateRoot)refuse('invalid port lane state');
|
||||
const state=realpathSync(stateRoot);if(!within(SCRATCH,state))refuse('port state outside scratch');
|
||||
const dir=join(state,'ports',lane);mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const [lo,hi]=BANDS[lane];
|
||||
for(let port=lo;port<=hi;port++){
|
||||
const release=await hold(join(dir,`${port}.lock`));if(!release)continue;
|
||||
let actual;try{actual=await probe(port);}catch(e){await release();throw e;}
|
||||
if(!actual){await release();continue;}
|
||||
t.after(release);return port;
|
||||
let got;try{got=await attempt(port);}catch(e){await release();throw e;}
|
||||
if(!got){await release();continue;}
|
||||
t.after(async()=>{try{await got.release?.();}finally{await release();}});return got.port;
|
||||
}
|
||||
refuse('lane port band exhausted');
|
||||
}
|
||||
export async function leasePort(t,{closed,...context}={}){
|
||||
if(closed!==undefined)refuse('leasePort takes no closed option; leaseClosedPort holds a port nothing answers on');
|
||||
return lease(t,context,probed);
|
||||
}
|
||||
export async function leaseClosedPort(t,context={}){return lease(t,context,bound);}
|
||||
export function fixtureCacheEnv(root){
|
||||
mkdirSync(root,{recursive:true,mode:0o700});const dir=realpathSync(root);for(const n of ['tmp','jiti','cache'])mkdirSync(join(dir,n),{recursive:true,mode:0o700});
|
||||
return {TMPDIR:join(dir,'tmp'),JITI_FS_CACHE:join(dir,'jiti'),XDG_CACHE_HOME:join(dir,'cache')};
|
||||
|
||||
Reference in new issue
Block a user