mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(beehive): own CLI shutdown across startup and persistence failures
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -1,3 +1,86 @@
|
||||
## Recovery 5360 — final validation / publication candidate
|
||||
|
||||
Recovered base HEAD: 425feb343e07f5d36fb7ee18b9502c0fd172b15d. The source
|
||||
candidate is the commit containing this section; all surviving predecessor edits
|
||||
were preserved and completed, not reset or duplicated.
|
||||
|
||||
**Explicit relay-review R1: CLOSED.** CLI consumes relay close callback, drains
|
||||
queued persistence, surfaces sanitized possible durability uncertainty and exits 1;
|
||||
actual subprocess directory-fault (wire1011 + possible replacement) and clean
|
||||
SIGINT exit0 controls pass. No storage-fence/uncertain-vs-rejected change.
|
||||
|
||||
Final strict PASS; exactly ONE FULL DEFAULT concurrent installed-enabled suite
|
||||
**78/78, zero skips/cancellations, natural 44.186s**, on Node 24.15.0/pnpm 11.4.0.
|
||||
Both host CLI service journeys, pre-ready SIGINT/SIGTERM and policy driver pass.
|
||||
No serial mask, retry-to-green, timeout increase, force exit or weak assertions.
|
||||
No executable edits after this run; only acceptance documentation. The prior
|
||||
71/73 broker/reply-tool failures and older disconnect/admission observations
|
||||
remain separately scoped, not classified by nonrecurrence. Independent diagnosis
|
||||
and normal-transition review operate the immutable base and remain to consume.
|
||||
|
||||
Self-review: one natural host close promise owns startup abort and ready shutdown;
|
||||
all slots drain with allSettled, uncertain teardown retains lock, and CLI awaits
|
||||
errors. No PID-derived cleanup authority. Policy barrier is public inventory for
|
||||
exact host/agent/revision, not sleep or operation retry; product CAS unchanged.
|
||||
Recovered service test uses actual CLI from wizard installation, never direct host
|
||||
construction. Captured child output is capped at 8192 characters.
|
||||
Evidence directory: WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/RECOVERY_5360, including
|
||||
final-strict.log, full-installed-default.log and separate source/installed hashes.
|
||||
Installed pins unchanged (buzz-acp 10612d00…441ea; Buzz CLI 147cc2cc…766bb2af5a37b856c5).
|
||||
Configured Logan Johnson <loganj@squareup.com> author/committer + DCO; no invented
|
||||
cryptographic signer. Publication verification recorded in that evidence directory.
|
||||
|
||||
Remaining: independent findings reconciliation, actual Goose/provider login and
|
||||
production service/current kind40002 admission; Claude/Codex/providers/presets/
|
||||
custom/mesh/compute and full parity. Installed Goose is a deterministic TS fixture,
|
||||
not real Goose or live LLM proof. No owner credentials, OAuth caches, private run
|
||||
journals, provider calls, production operations, Rust/native/client changes, repo
|
||||
CI, merge, release or full-product claim.
|
||||
|
||||
## Recovery 5360 — pre-final exact candidate gate
|
||||
|
||||
R1 CLOSED locally: actual CLI fault/clean SIGINT 2/2 + strict on pinned
|
||||
Node 24.15.0/pnpm 11.4.0. Startup/policy focused 8/8 (5.349s): actual
|
||||
SIGINT/SIGTERM before withheld upgrade ready releases clean lock, unchanged
|
||||
journal, subsequent startup/ready shutdown; quarantine keeps lock and double
|
||||
close shares teardown. No reconnect/deadline changes.
|
||||
Policy driver now waits for relevant PUBLIC host+agent running inventory rev1
|
||||
before spawning Stop TUI. This source-grounded bounded barrier preserves all
|
||||
CAS/unknown/retry/operation-count assertions; pinned runtime reproduces accepted
|
||||
unchanged original Stop at rev2 in both loss modes. Not a product recovery fix.
|
||||
Recovered service subprocess journeys PASS 2/2 (38.142s): wizard-produced initial
|
||||
normal and selected-B NEW immutable conversion, actual host CLI subprocess,
|
||||
installed buzz-acp/Buzz CLI, four signed same-agent replies including Restart,
|
||||
model/profile/history/Y assertions. Goose is still a TS protocol fixture, no
|
||||
live provider/current-kind40002 admission or production service deployment.
|
||||
Bounded recovered test stdout/stderr tails to 8 KiB. Evidence RECOVERY_5360/
|
||||
{r1,startup-policy,service}.log; older CLI_333D partial log retained unchanged.
|
||||
Next: strict + ONE FULL DEFAULT concurrent installed-enabled run of this exact
|
||||
executable candidate, no serial masking/retries/deadline increase/force exit.
|
||||
Historical full 71/73 and older failures remain separately scoped.
|
||||
|
||||
## Recovery 5360 — preserved working state, before validation
|
||||
|
||||
Recovered HEAD 425feb343e07f5d36fb7ee18b9502c0fd172b15d; no new commits.
|
||||
Preserved prior edits to cli.ts, host.ts, normal-conversation/startup-transport tests,
|
||||
and untracked cli-shutdown.test.ts / relay-cli-fault.ts. No reset/cleanup.
|
||||
R1 patch already present (callback diagnostic/nonzero exit); prior local 2/2 claim
|
||||
retained below, fresh pinned validation pending. Startup abort and actual service
|
||||
journey edits also survived; not yet accepted. Policy public-inventory driver fix
|
||||
was absent and is next after R1 validation. Evidence: CLI_333D existing public logs;
|
||||
new evidence will be in RECOVERY_5360. No private run journals consulted.
|
||||
|
||||
## CLI lifecycle continuation 333d — early R1 checkpoint
|
||||
|
||||
425feb actual CLI still discarded relay.close errors: R1 was OPEN, not fixed by
|
||||
normal wizard work. Now the real CLI consumes close callback, reports sanitized
|
||||
possible durability uncertainty, exits 1, and leaves history/fences intact. Clean
|
||||
SIGINT exits 0. Actual CLI subprocess regressions (test-only module load injection
|
||||
at existing directory boundary, no copied CLI) pass 2/2, strict passes on Node
|
||||
24.15.0/pnpm 11.4.0. Replacement present + wire1011 is NOT durable rejection or
|
||||
accepted data loss. R1 implementation/regression CLOSED locally; final gate and
|
||||
publication pending. Startup signals and service subprocess work follow.
|
||||
|
||||
# Beehive executable checkpoint
|
||||
|
||||
## Normal conversation wizard A661 — final gate / scoped handoff
|
||||
|
||||
@@ -514,3 +514,21 @@ no secret echo, retained assignment, 0600). Initial/imported executable paths he
|
||||
are deliberately metadata-only fixtures; no provider/login or runtime spawn claimed.
|
||||
Existing Goose/assignment tests retain actual standby Start denial and secure-input
|
||||
negatives. The normal installed journeys run only with the existing installed opt-in.
|
||||
|
||||
## CLI lifecycle recovery 5360
|
||||
|
||||
`cli-shutdown.test.ts` launches the actual relay CLI. Test-only module loading
|
||||
injects the existing directory persistence boundary: wire1011 and possible
|
||||
replacement must lead to uncertainty stderr and SIGINT exit1, versus clean exit0.
|
||||
`startup-transport.test.ts` adds actual host CLI SIGINT/SIGTERM while upgrade is
|
||||
withheld, clean lock/journal/restart checks, quarantine retention and double close.
|
||||
`policy-tui.test.ts` now waits for relevant PUBLIC running rev1 inventory before
|
||||
spawning Stop TUI; private journal rev1 is not asynchronous relay visibility.
|
||||
Original unknown/byte-identical retry/accepted rev2 assertions remain unchanged.
|
||||
`normal-conversation.test.ts` now starts actual host CLI subprocesses from the
|
||||
wizard-produced installations instead of in-process host construction. Initial
|
||||
normal and selected-B conversion still require four signed installed CLI replies
|
||||
including Restart, same identity, exact model/profile and unchanged sibling/history.
|
||||
Strict + one default concurrent installed-enabled full run: 78/78, natural 44.186s.
|
||||
This closes scoped service-subprocess continuity, not real Goose/provider or
|
||||
production kind40002/service deployment proof. See CHECKPOINT for historical failures.
|
||||
|
||||
@@ -195,12 +195,33 @@ async function main() {
|
||||
} else if (command === 'relay') {
|
||||
const server = await relay(Number(args[0]),text(args[1]),resolve(text(args[2])));
|
||||
console.log(`Development relay listening ${JSON.stringify(server.address())}`);
|
||||
process.once('SIGINT',() => { for (const c of server.clients) c.close(); server.close(); });
|
||||
let closing = false;
|
||||
const close = () => {
|
||||
if (closing) return; closing = true;
|
||||
for (const c of server.clients) c.close();
|
||||
server.close(error => {
|
||||
if (error) { console.error('Relay shutdown failed; storage durability may be uncertain. Retain history for reconciliation.'); process.exitCode = 1; }
|
||||
});
|
||||
};
|
||||
process.on('SIGINT', close); process.on('SIGTERM', close);
|
||||
} else if (command === 'host') {
|
||||
const running = await host(resolve(text(args[0])),text(args[1]));
|
||||
console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`);
|
||||
process.once('SIGINT',() => { void running.close(); });
|
||||
process.once('SIGTERM',() => { void running.close(); });
|
||||
const controller = new AbortController();
|
||||
const stop = () => controller.abort();
|
||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
||||
try {
|
||||
const running = await host(resolve(text(args[0])), text(args[1]), controller.signal);
|
||||
console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`);
|
||||
// The host owns teardown both before and after ready. Observe its one
|
||||
// completion promise so uncertain teardown cannot disappear as a rejection.
|
||||
await new Promise<void>(resolve => {
|
||||
if (controller.signal.aborted) resolve();
|
||||
else controller.signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
await running.close();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted || error !== controller.signal.reason) throw error;
|
||||
// Transport ready rejection caused by intentional startup cancellation.
|
||||
} finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
|
||||
} else if (command === 'tui') {
|
||||
const secret = text(object(readPrivate(resolve(text(args[0])))).secret);
|
||||
const inventory = new Map<string,Message>();
|
||||
|
||||
@@ -501,7 +501,8 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri
|
||||
}
|
||||
|
||||
/** One installation owns every slot, lock and management transport. */
|
||||
export async function host(directory: string, url: string) {
|
||||
export async function host(directory: string, url: string, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
validateRelayURL(url);
|
||||
const lock = join(directory, 'host.lock');
|
||||
mkdirSync(lock, { mode: 0o700 }); // Never infer ownership from a recovered PID.
|
||||
@@ -510,6 +511,18 @@ export async function host(directory: string, url: string) {
|
||||
const slots = new Map<string, ReturnType<typeof slot>>();
|
||||
const profiles = new Profiles();
|
||||
const publish = (m: Message) => { try { client?.send(m); } catch { /* Slot outbox survives transport loss. */ } };
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let closing: Promise<void> | undefined;
|
||||
const close = () => closing ??= (async () => {
|
||||
initialized = false; client?.close(); clearInterval(heartbeat);
|
||||
const results = await Promise.allSettled([...slots.values()].map(s => s.close()));
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason);
|
||||
if (errors.length) throw new AggregateError(errors, 'Incomplete owned teardown; installation remains locked');
|
||||
rmdirSync(lock);
|
||||
})();
|
||||
// Abort uses the same owner as ready shutdown, including quarantine fences.
|
||||
const abort = () => { void close().catch(() => {}); };
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
const entries = installationSlots(directory);
|
||||
for (const entry of entries) {
|
||||
@@ -535,19 +548,17 @@ export async function host(directory: string, url: string) {
|
||||
else if (['save','start','restart','stop','move'].includes(m.type)) publish(message('receipt',setup.host,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' }));
|
||||
}, () => { if (initialized) for (const s of slots.values()) s.replay(); });
|
||||
await client.ready;
|
||||
} catch (e) { client?.close(); rmdirSync(lock); throw e; }
|
||||
signal?.throwIfAborted();
|
||||
} catch (e) {
|
||||
try { await close(); } finally { signal?.removeEventListener('abort', abort); }
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
throw e;
|
||||
}
|
||||
initialized = true;
|
||||
for (const s of slots.values()) s.replay();
|
||||
const heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000);
|
||||
let closing: Promise<void> | undefined;
|
||||
heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000);
|
||||
return { agent: [...slots.keys()][0]!, agents: [...slots.keys()], close() {
|
||||
return closing ??= (async () => {
|
||||
initialized = false; client?.close(); clearInterval(heartbeat);
|
||||
// allSettled starts every teardown and preserves each slot's durable truth.
|
||||
const results = await Promise.allSettled([...slots.values()].map(s => s.close()));
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason);
|
||||
if (errors.length) throw new AggregateError(errors, 'Incomplete owned teardown; installation remains locked');
|
||||
rmdirSync(lock);
|
||||
})();
|
||||
signal?.removeEventListener('abort', abort);
|
||||
return close();
|
||||
} };
|
||||
}
|
||||
|
||||
35
beehive/test/cli-shutdown.test.ts
Normal file
35
beehive/test/cli-shutdown.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { WebSocket } from 'ws';
|
||||
import { newKey, publicKey, seal, message } from '../src/protocol.ts';
|
||||
|
||||
for (const fault of [false, true]) test(`actual relay CLI SIGINT surfaces uncertainty=${fault}`, { timeout: 10000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'beehive-cli-close-'));
|
||||
const secret = newKey(), path = join(dir, 'history.json');
|
||||
const child = spawn(process.execPath, [...(fault ? ['--import', './test/relay-cli-fault.ts'] : []), 'src/cli.ts', 'relay', '0', publicKey(secret), path], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '', errors = ''; child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192));
|
||||
const exited = once(child, 'exit');
|
||||
let ws: WebSocket | undefined;
|
||||
try {
|
||||
while (!output.includes('\n')) await once(child.stdout, 'data');
|
||||
const address = JSON.parse(output.trim().replace('Development relay listening ', ''));
|
||||
ws = new WebSocket(`ws://127.0.0.1:${address.port}`); await once(ws, 'open');
|
||||
const observed = once(ws, fault ? 'close' : 'message');
|
||||
const envelope = seal(message('inspect', 'h', 'a'), secret); ws.send(JSON.stringify(envelope));
|
||||
const result = await observed;
|
||||
if (fault) assert.equal(result[0], 1011);
|
||||
assert.deepEqual(JSON.parse(readFileSync(path, 'utf8')), [envelope], 'replacement is possible, not a durable rejection');
|
||||
child.kill('SIGINT');
|
||||
assert.deepEqual(await exited, [fault ? 1 : 0, null]);
|
||||
if (fault) assert.match(errors, /storage durability may be uncertain/); else assert.equal(errors, '');
|
||||
} finally {
|
||||
ws?.terminate();
|
||||
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; }
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { terminal } from './local-terminal.ts';
|
||||
import { installationSlots } from '../src/slots.ts';
|
||||
import { host } from '../src/host.ts';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { relay } from '../src/relay.ts';
|
||||
import { readPrivate, writePrivate } from '../src/storage.ts';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -19,7 +20,7 @@ import { profileRevision } from '../src/profiles.ts';
|
||||
import { newKey, publicKey, message, type Message } from '../src/protocol.ts';
|
||||
|
||||
// Explicitly opt-in installed executable; never opens an owner profile or provider.
|
||||
for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart + production host + installed CLI signed replies (${converting ? 'selected diagnostic B conversion' : 'initial normal default'})`, { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => {
|
||||
for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart + host CLI service subprocess + installed CLI signed replies (${converting ? 'selected diagnostic B conversion' : 'initial normal default'})`, { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => {
|
||||
const goose = true;
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-installed-')));
|
||||
writeFileSync(join(dir, 'mode'), 'ok');
|
||||
@@ -67,7 +68,13 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart
|
||||
{ prompt: 'Save NEW Goose binding only', answer: 'yes' },
|
||||
]);
|
||||
assert.ok(!setupOutput.includes(ownerSecret));
|
||||
const state = () => readPrivate(entry.path) as any;
|
||||
// Lifecycle decisions come from signed public inventory, not a private journal.
|
||||
let inventory: Message | undefined;
|
||||
const state = () => {
|
||||
assert.ok(inventory, 'public host inventory required');
|
||||
return { revision: inventory.revision, phase: inventory.body.phase,
|
||||
selected: inventory.body.selectedNext as any, actual: inventory.body.actualRun as any };
|
||||
};
|
||||
const channel = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
||||
const replies: any[] = [];
|
||||
const subscriptionsBySocket = new Map<string, { socket: any; id: string }>();
|
||||
@@ -138,7 +145,31 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart
|
||||
const management = await relay(0, owner, join(dir, 'management.json'));
|
||||
const ma = management.address(); assert.ok(ma && typeof ma !== 'string');
|
||||
const managementURL = `ws://127.0.0.1:${ma.port}`;
|
||||
let h = await host(installation, managementURL);
|
||||
const observer = connect(managementURL, ownerSecret, m => {
|
||||
if (m.type === 'inventory' && m.host === 'journey' && m.agent === agent) inventory = m;
|
||||
});
|
||||
await observer.ready;
|
||||
const service = async () => {
|
||||
const child = spawn(process.execPath, ['src/cli.ts', 'host', installation, managementURL], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '', errors = '';
|
||||
child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192));
|
||||
const exited = once(child, 'exit');
|
||||
let closing: Promise<void> | undefined;
|
||||
const close = () => closing ??= (async () => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
assert.deepEqual(await exited, [0, null], errors);
|
||||
assert.equal(errors, '');
|
||||
assert.equal(existsSync(join(installation, 'host.lock')), false);
|
||||
})();
|
||||
try {
|
||||
for (let n = 0; n < 400 && !(output.includes('Host online;') && inventory); n++) {
|
||||
assert.equal(child.exitCode, null, errors); await delay(20);
|
||||
}
|
||||
assert.match(output, /Host online;/); assert.ok(inventory);
|
||||
return { close };
|
||||
} catch (error) { await close(); throw error; }
|
||||
};
|
||||
let h = await service();
|
||||
if (converting) {
|
||||
await terminal(['tui', identity, managementURL], [
|
||||
{ prompt: 'beehive> ', answer: 'select 1' },
|
||||
@@ -163,7 +194,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart
|
||||
assert.deepEqual(readFileSync(entry.path), beforeJournal);
|
||||
assert.deepEqual(installationSlots(installation)[0]!.bindings.B, beforeB);
|
||||
assert.deepEqual(installationSlots(installation)[0]!.bindings.default, originalA);
|
||||
h = await host(installation, managementURL);
|
||||
inventory = undefined; h = await service();
|
||||
await terminal(['tui', identity, managementURL], [
|
||||
{ prompt: 'beehive> ', answer: 'select 1' },
|
||||
{ prompt: 'beehive> ', answer: 'binding B-normal', observedRevision: 3 },
|
||||
@@ -234,7 +265,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart
|
||||
]);
|
||||
assert.notEqual(state().actual.evidence.session, oldActual.evidence.session);
|
||||
assert.equal(state().actual.evidence.model, oldActual.evidence.model);
|
||||
assert.deepEqual(state().runs[oldActual.run], oldActual);
|
||||
assert.deepEqual((readPrivate(entry.path) as any).runs[oldActual.run], oldActual, 'fresh fixture history audit only; never used to admit execution');
|
||||
assert.deepEqual(readFileSync(join(installation, 'setup.json')), manifest);
|
||||
assert.deepEqual(readFileSync(sibling.path), originalY);
|
||||
assert.deepEqual(readFileSync(join(installation, 'setup.json')), convertedManifest);
|
||||
@@ -274,6 +305,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart
|
||||
|
||||
} finally {
|
||||
if (goose && existsSync(join(dir, 'goose-rpc-methods'))) console.log(readFileSync(join(dir, 'goose-rpc-methods'), 'utf8'));
|
||||
observer.close();
|
||||
await session.stop();
|
||||
for (const file of ['harness-pid', 'descendant-pid', 'tool-shim-pid']) {
|
||||
if (!existsSync(join(dir, file))) continue;
|
||||
|
||||
@@ -30,10 +30,16 @@ test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const url = `ws://127.0.0.1:${address.port}`;
|
||||
const h = await host(dir, url);
|
||||
const control = connect(url, secret, () => {}); await control.ready;
|
||||
let relayVisibleRunning = false;
|
||||
const control = connect(url, secret, m => {
|
||||
// A private journal commit is not yet public relay inventory visibility.
|
||||
if (m.type === 'inventory' && m.host === 'policy-host' && m.agent === agent && m.revision === 1 && m.body.phase === 'running') relayVisibleRunning = true;
|
||||
}); await control.ready;
|
||||
const journal = () => JSON.parse(readFileSync(join(dir, 'journal.json'), 'utf8'));
|
||||
control.send(message('start', 'policy-host', agent, 0));
|
||||
await until(() => journal().phase === 'running');
|
||||
// Later TUI connections replay this durably committed revision before Stop.
|
||||
await until(() => relayVisibleRunning);
|
||||
let deny = true; let denials = 0;
|
||||
const attempts: string[] = [];
|
||||
const upstreams = new Set<WebSocket>();
|
||||
|
||||
11
beehive/test/relay-cli-fault.ts
Normal file
11
beehive/test/relay-cli-fault.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Test-only module instrumentation: execute the real CLI, inject only the existing
|
||||
// storage boundary. No production environment switch or copied shutdown owner.
|
||||
import { registerHooks } from 'node:module';
|
||||
registerHooks({ load(url, context, nextLoad) {
|
||||
const loaded = nextLoad(url, context);
|
||||
if (!url.endsWith('/src/relay.ts')) return loaded;
|
||||
const source = String(loaded.source);
|
||||
const needle = 'boundary?: SnapshotBoundary';
|
||||
if (!source.includes(needle)) throw Error('Relay boundary instrumentation drift');
|
||||
return { ...loaded, source: source.replace(needle, "boundary: SnapshotBoundary = async phase => { if (phase === 'directory') throw Error('injected directory fault'); }") };
|
||||
} });
|
||||
@@ -98,3 +98,62 @@ test('initial HTTP policy denial fails explicitly without automatic retry even w
|
||||
assert.equal(e.upgrades, 1); assert.equal(recovered, 0);
|
||||
} finally { client.close(); await e.close(); }
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) test(`actual host CLI ${signal} during withheld upgrade drains startup; subsequent ready shutdown`, { timeout: 10000 }, async () => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-cli-startup-')));
|
||||
const secret = newKey(), agentSecret = newKey();
|
||||
const e = await endpoint();
|
||||
provision(dir, { host: 'startup', ownerSecret: secret, agentSecret, runner: process.execPath,
|
||||
args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' },
|
||||
createGenesis(publicKey(secret), publicKey(agentSecret), 'startup'));
|
||||
const before = readFileSync(join(dir, 'journal.json'));
|
||||
const launch = () => {
|
||||
const child = spawn(process.execPath, ['src/cli.ts', 'host', dir, e.url], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '', errors = '';
|
||||
child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192));
|
||||
return { child, exited: once(child, 'exit'), get output() { return output; }, get errors() { return errors; } };
|
||||
};
|
||||
let running = launch();
|
||||
try {
|
||||
await until(() => e.upgrades === 1);
|
||||
assert.ok(existsSync(join(dir, 'host.lock')));
|
||||
running.child.kill(signal);
|
||||
assert.deepEqual(await running.exited, [0, null], running.errors);
|
||||
assert.equal(running.errors, '');
|
||||
assert.equal(existsSync(join(dir, 'host.lock')), false);
|
||||
assert.deepEqual(readFileSync(join(dir, 'journal.json')), before);
|
||||
assert.equal(e.upgrades, 1);
|
||||
e.mode = 'accept'; running = launch();
|
||||
await until(() => running.output.includes('Host online;'));
|
||||
running.child.kill(signal);
|
||||
assert.deepEqual(await running.exited, [0, null], running.errors);
|
||||
assert.equal(existsSync(join(dir, 'host.lock')), false);
|
||||
assert.deepEqual(readFileSync(join(dir, 'journal.json')), before);
|
||||
} finally {
|
||||
if (running.child.exitCode === null && running.child.signalCode === null) { running.child.kill('SIGTERM'); await running.exited; }
|
||||
await e.close(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('startup abort retains quarantine lock; ready double close shares teardown', async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-abort-fence-')));
|
||||
const secret = newKey(), agentSecret = newKey();
|
||||
const e = await endpoint();
|
||||
const { writePrivate, readPrivate } = await import('../src/storage.ts');
|
||||
try {
|
||||
provision(dir, { host: 'startup', ownerSecret: secret, agentSecret, runner: process.execPath,
|
||||
args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' },
|
||||
createGenesis(publicKey(secret), publicKey(agentSecret), 'startup'));
|
||||
e.mode = 'accept'; const clean = await host(dir, e.url);
|
||||
const one = clean.close(); assert.equal(clean.close(), one); await one;
|
||||
const path = join(dir, 'journal.json');
|
||||
const state = readPrivate(path) as Record<string, unknown>;
|
||||
state.phase = 'quarantined'; writePrivate(path, state);
|
||||
e.mode = 'stall'; const controller = new AbortController();
|
||||
const pending = host(dir, e.url, controller.signal);
|
||||
const rejected = assert.rejects(pending, /Incomplete owned teardown/);
|
||||
await until(() => e.upgrades === 2); controller.abort(); await rejected;
|
||||
assert.equal(existsSync(join(dir, 'host.lock')), true, 'unproven execution remains fenced');
|
||||
} finally { await e.close(); rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user