import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { connect } from 'node:net'; import * as runner from '../src/runner.mjs'; import { callTool } from '../src/tools.mjs'; const turn = () => new Promise(resolve => setImmediate(resolve)); test('a running instance is a wait: repeated launch calls are delayed, independently per instance', async t => { const guard = runner.launchBackoff ?? (send => send); const dir = fs.mkdtempSync(join(tmpdir(),'backoff797-')); t.after(() => fs.rmSync(dir, {recursive:true,force:true})); const calls=[], delays=[]; let now=0; const launch = guard(async ({instance}) => { calls.push(instance); if (instance === 'pm') throw Object.assign(new Error('instance-running'), {code:'instance-running'}); return {run:'other-run'}; }, {now:()=>now, wait:ms=>new Promise(resolve=>delays.push({ms,resolve}))}); const path=join(dir,'tools.sock'); const server=await runner.toolServer({path,tools:[{name:'launch',verb:'launch'}],client:{},local:{launch}}); t.after(()=>new Promise(resolve=>server.close(resolve))); await assert.rejects(callTool(path,'launch',{instance:'pm'}),/instance-running.*already running.*waiting messages.*claims.*must not launch.*relay/i); const second=callTool(path,'launch',{instance:'pm'}); for(let n=0;n<100 && !delays.length;n++) await new Promise(resolve=>setTimeout(resolve,2)); assert.deepEqual(calls,['pm']); assert.equal(delays.length,1); assert.ok(delays[0].ms >= 30000); assert.deepEqual(await callTool(path,'launch',{instance:'coder'}),{run:'other-run'}); now += delays[0].ms; delays[0].resolve(); await assert.rejects(second,/instance-running.*already running.*waiting messages.*claims.*must not launch.*relay/i); assert.deepEqual(calls,['pm','coder','pm']); }); test('a success clears the instance-running delay', async () => { const guard = runner.launchBackoff ?? (send => send); let now=0, calls=0, refusal=true; const waits=[]; const launch=guard(async () => {calls++; if(refusal) throw Object.assign(new Error('instance-running'),{code:'instance-running'}); return {run:'r1'};}, {now:()=>now,wait:async ms=>{waits.push(ms);now+=ms;}}); await assert.rejects(launch({instance:'coder'}),{code:'instance-running'}); refusal=false; assert.deepEqual(await launch({instance:'coder'}),{run:'r1'}); assert.deepEqual(waits,[30000]); assert.deepEqual(await launch({instance:'coder'}),{run:'r1'}); assert.deepEqual(waits,[30000]); assert.equal(calls,3); }); test('no-route fails fast and starts no cooldown after the route is set', async () => { let calls=0, routed=false; const waits=[]; const launch=runner.launchBackoff(async () => { calls++; if (!routed) throw Object.assign(new Error('no-route'), {code:'no-route'}); return {run:'r1'}; }, {now:()=>0, wait:async ms=>waits.push(ms)}); await assert.rejects(launch({instance:'coder'}), {code:'no-route'}); routed=true; assert.deepEqual(await launch({instance:'coder'}), {run:'r1'}); assert.deepEqual(waits, []); assert.equal(calls, 2); }); test('disconnecting a delayed launch cancels it before it can reach the host', async () => { const guard = runner.launchBackoff ?? (send => send); let calls=0; const controller=new AbortController(); const launch=guard(async () => {calls++; throw Object.assign(new Error('instance-running'),{code:'instance-running'});}, {now:()=>0,wait:(ms,signal)=>new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(Object.assign(new Error('cancelled'),{code:'cancelled'})),{once:true}))}); await assert.rejects(launch({instance:'pm'}),{code:'instance-running'}); const waiting=launch({instance:'pm'},{signal:controller.signal}).catch(e=>e); await turn(); assert.equal(calls,1,'no second host call before the waiting period'); controller.abort(); await waiting; assert.equal(calls,1,'a disconnected caller cannot trigger a later launch'); }); test('simultaneous calls for one instance cannot bypass a refusal', async () => { const guard = runner.launchBackoff ?? (send => send); let calls=0, finish; const waits=[]; const launch=guard(async () => { calls++; if (calls === 1) await new Promise(resolve => { finish=resolve; }); throw Object.assign(new Error('instance-running'),{code:'instance-running'}); }, {now:()=>0,wait:ms=>new Promise(resolve=>waits.push({ms,resolve}))}); const first=launch({instance:'pm'}).catch(e=>e); await turn(); const second=launch({instance:'pm'}).catch(e=>e); await turn(); assert.equal(calls,1); finish(); await first; await turn(); assert.equal(calls,1); assert.equal(waits.length,1); waits[0].resolve(); await second; assert.equal(calls,2); }); test('a disconnected tool socket cancels a launch waiting for backoff', async t => { const guard = runner.launchBackoff ?? (send => send); const dir=fs.mkdtempSync(join(tmpdir(),'backoff797-socket-')); t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); let calls=0, waiting; const launch=guard(async ()=>{calls++;throw Object.assign(new Error('instance-running'),{code:'instance-running'});}, {now:()=>0,wait:(ms,signal)=>new Promise((resolve,reject)=>{waiting={resolve,signal};signal.addEventListener('abort',()=>reject(new Error('cancelled')),{once:true});})}); const path=join(dir,'tools.sock'); const server=await runner.toolServer({path,tools:[{name:'launch',verb:'launch'}],client:{},local:{launch}}); await assert.rejects(callTool(path,'launch',{instance:'pm'})); const socket=connect(path); t.after(()=>{ socket.destroy(); return new Promise(resolve=>server.close(resolve)); }); socket.on('error',()=>{}); socket.write(JSON.stringify({tool:'launch',args:{instance:'pm'}})+'\n'); for(let n=0;n<100 && !waiting;n++) await new Promise(resolve=>setTimeout(resolve,2)); assert.ok(waiting); socket.destroy(); for(let n=0;n<100 && !waiting.signal.aborted;n++) await new Promise(resolve=>setTimeout(resolve,2)); assert.equal(waiting.signal.aborted,true); waiting.resolve(); await turn(); assert.equal(calls,1); });