diff --git a/beehive/src/owner-child.ts b/beehive/src/owner-child.ts index d83c92430c..5885d81705 100644 --- a/beehive/src/owner-child.ts +++ b/beehive/src/owner-child.ts @@ -1,20 +1,21 @@ import { OwnerService } from './owner-service.ts'; const service = new OwnerService(process.env.BEEHIVE_HOME); service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); -let pending: Promise | undefined; +const pending = new Set>(); let exiting = false; process.on('message', async (message: any) => { if (message.type === 'secret') service.secret(message.action, message.value); else if (message.type === 'cancel') service.cancel(); else if (message.type === 'request') { - pending = service.request(message.request); - const ok = await pending; + const work = service.request(message.request); + pending.add(work); + const ok = await work.finally(() => pending.delete(work)); if (process.connected && !exiting) process.send?.({ type: 'result', id: message.id, ok }); } }); const stop = () => { if (exiting) return; exiting = true; service.dispose(); - void Promise.resolve(pending).finally(() => process.exit()); + void Promise.allSettled([...pending]).finally(() => process.exit()); }; process.on('disconnect', stop); process.on('SIGTERM', stop); diff --git a/beehive/test/owner-ipc.test.ts b/beehive/test/owner-ipc.test.ts index cfece0c5d8..9ddac278d1 100644 --- a/beehive/test/owner-ipc.test.ts +++ b/beehive/test/owner-ipc.test.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; @@ -27,3 +28,23 @@ test('real private IPC binds public snapshots to session, cancel, signout and re assert.equal(existsSync(join(home, '.beehive/owner/controller.json')), true); } finally { client.dispose(); rmSync(home, { recursive: true, force: true }); } }); + +test('owner child disconnect drains earlier operations after a later signout resolves', async () => { + const { fork } = await import('node:child_process'); + const { fileURLToPath } = await import('node:url'); + const home=mkdtempSync(join(tmpdir(),'owner-disconnect-')); + const loader=join(home,'isolate.mjs'), marker=join(home,'drained'); + writeFileSync(loader,`import {registerHooks} from 'node:module'; +registerHooks({load(url,ctx,next){if(!url.endsWith('/src/owner-service.ts'))return next(url,ctx);return {format:'module',shortCircuit:true,source:${JSON.stringify(`import {writeFileSync} from 'node:fs'; +export class OwnerService { + subscribe() {} secret() {} cancel() {} dispose() {} + async request(r) { if(r.action==='host-reset') { process.send({started:true});await new Promise(resolve=>setTimeout(resolve,200));writeFileSync(${JSON.stringify(marker)},'drained'); } return true; } +}`)}};}});`); + const child=fork(fileURLToPath(new URL('../src/owner-child.ts',import.meta.url)),[],{execPath:process.execPath,execArgv:['--import',loader],silent:true,env:{HOME:home,PATH:'/usr/bin:/bin'}}); + try { + const started=once(child,'message');child.send({type:'request',id:1,request:{action:'host-reset'}});await started; + const signedout=once(child,'message');child.send({type:'request',id:2,request:{action:'signout'}});assert.equal((await signedout)[0].id,2); + const exited=once(child,'exit');child.disconnect();await exited; + assert.equal(existsSync(marker),true,'earlier cleanup completed before process exit'); + } finally {child.kill();rmSync(home,{recursive:true,force:true});} +}); diff --git a/beehive/test/owner-tui-fixture-child.ts b/beehive/test/owner-tui-fixture-child.ts index 58084f8ba0..1f2226424a 100644 --- a/beehive/test/owner-tui-fixture-child.ts +++ b/beehive/test/owner-tui-fixture-child.ts @@ -10,13 +10,15 @@ const service = new OwnerService(home, async (probe, signal) => { signal.throwIfAborted(); return mode === 'missing' || mode === 'denied' ? { reason: mode === 'missing' ? 'missing' : 'access' } : probe ? { available: true } : { secret: mode === 'mismatch' ? '2'.repeat(64) : '1'.repeat(64) }; }, fixtureHostDependencies(home)); +const pending = new Set>(); service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); process.on('message', async (message: any) => { if (message.type === 'secret') service.secret(message.action, message.value); else if (message.type === 'cancel') service.cancel(); else if (message.type === 'request') { - const ok = await service.request(message.request); + const work = service.request(message.request); pending.add(work); + const ok = await work.finally(() => pending.delete(work)); if (process.connected) process.send?.({ type: 'result', id: message.id, ok }); } }); -process.on('disconnect', () => { service.dispose(); process.exit(); }); +process.on('disconnect', () => { service.dispose(); void Promise.allSettled([...pending]).finally(() => process.exit()); });