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.
67 lines
4.4 KiB
JavaScript
67 lines
4.4 KiB
JavaScript
// 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';
|
|
import { createServer } from 'node:net';
|
|
import { once } from 'node:events';
|
|
import { BANDS, within, SCRATCH, refuse } from './lane-state.mjs';
|
|
async function hold(file){
|
|
const child=spawn('flock',['-n','-E','73',file,'bash','-c','printf "held\\n"; IFS= read -r release'],{stdio:['pipe','pipe','pipe']});
|
|
let err='';child.stderr.on('data',c=>err+=c);const exited=once(child,'exit');
|
|
const ok=await new Promise((resolve,reject)=>{child.stdout.once('data',()=>resolve(true));child.once('exit',code=>code===73?resolve(false):reject(Error(`port lock holder exited ${code}: ${err}`)));child.once('error',reject);});
|
|
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;}}
|
|
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 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')};
|
|
}
|