fix(beehive): drain all owner requests before controller exit

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-21 17:22:16 -04:00
parent 74f46b4695
commit db7da12f0e
3 changed files with 30 additions and 6 deletions

View File

@@ -1,20 +1,21 @@
import { OwnerService } from './owner-service.ts'; import { OwnerService } from './owner-service.ts';
const service = new OwnerService(process.env.BEEHIVE_HOME); const service = new OwnerService(process.env.BEEHIVE_HOME);
service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); });
let pending: Promise<boolean> | undefined; const pending = new Set<Promise<boolean>>();
let exiting = false; let exiting = false;
process.on('message', async (message: any) => { process.on('message', async (message: any) => {
if (message.type === 'secret') service.secret(message.action, message.value); if (message.type === 'secret') service.secret(message.action, message.value);
else if (message.type === 'cancel') service.cancel(); else if (message.type === 'cancel') service.cancel();
else if (message.type === 'request') { else if (message.type === 'request') {
pending = service.request(message.request); const work = service.request(message.request);
const ok = await pending; pending.add(work);
const ok = await work.finally(() => pending.delete(work));
if (process.connected && !exiting) process.send?.({ type: 'result', id: message.id, ok }); if (process.connected && !exiting) process.send?.({ type: 'result', id: message.id, ok });
} }
}); });
const stop = () => { const stop = () => {
if (exiting) return; exiting = true; service.dispose(); 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('disconnect', stop);
process.on('SIGTERM', stop); process.on('SIGTERM', stop);

View File

@@ -1,3 +1,4 @@
import { once } from 'node:events';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; 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); assert.equal(existsSync(join(home, '.beehive/owner/controller.json')), true);
} finally { client.dispose(); rmSync(home, { recursive: true, force: 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});}
});

View File

@@ -10,13 +10,15 @@ const service = new OwnerService(home, async (probe, signal) => {
signal.throwIfAborted(); signal.throwIfAborted();
return mode === 'missing' || mode === 'denied' ? { reason: mode === 'missing' ? 'missing' : 'access' } : probe ? { available: true } : { secret: mode === 'mismatch' ? '2'.repeat(64) : '1'.repeat(64) }; return mode === 'missing' || mode === 'denied' ? { reason: mode === 'missing' ? 'missing' : 'access' } : probe ? { available: true } : { secret: mode === 'mismatch' ? '2'.repeat(64) : '1'.repeat(64) };
}, fixtureHostDependencies(home)); }, fixtureHostDependencies(home));
const pending = new Set<Promise<boolean>>();
service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); }); service.subscribe(snapshot => { if (process.connected) process.send?.({ type: 'snapshot', snapshot }); });
process.on('message', async (message: any) => { process.on('message', async (message: any) => {
if (message.type === 'secret') service.secret(message.action, message.value); if (message.type === 'secret') service.secret(message.action, message.value);
else if (message.type === 'cancel') service.cancel(); else if (message.type === 'cancel') service.cancel();
else if (message.type === 'request') { 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 }); 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()); });