From 4a5d8440eeeefdf6c21732c4d195c806b0872c7e Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 17:18:33 -0500 Subject: [PATCH] Arm daemon shutdown before publishing startup readiness --- package-lock.json | 4 ++-- package.json | 2 +- src/runtime/daemon-host.ts | 20 +++++++++++++------- tests/runtime/daemon-ownership.test.ts | 26 ++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9aa9064..a318652 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.7", + "version": "0.5.0-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.7", + "version": "0.5.0-alpha.8", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", diff --git a/package.json b/package.json index 55f8b8e..d88552e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@crosshead/runtime", - "version": "0.5.0-alpha.7", + "version": "0.5.0-alpha.8", "description": "Crosshead local runtime: persistent memory for Claude Code, Codex, Pi, Hermes and OpenCode. CLI, daemon, host adapters and hooks.", "license": "Apache-2.0", "private": true, diff --git a/src/runtime/daemon-host.ts b/src/runtime/daemon-host.ts index 4588ed7..0539d45 100644 --- a/src/runtime/daemon-host.ts +++ b/src/runtime/daemon-host.ts @@ -73,6 +73,7 @@ export const runOnlyBotsDaemonCli = async ( let cleanup: Promise | null = null; const closeComposition = () => cleanup ??= composition?.close() ?? Promise.resolve(); let daemon: OnlyBotsLocalRuntimeDaemon; + let removeSignalHandlers = () => {}; try { if (organizationalWorkerConfigPath && composeOrganizationalWorker) { composition = await composeOrganizationalWorker({ @@ -96,13 +97,8 @@ export const runOnlyBotsDaemonCli = async ( ? { organizationalWorkerConfigPath } : {}), }); - await daemon.start(port, ownership); - } catch (error) { - await closeComposition().catch(() => undefined); - await ownership.close(); - throw error; - } - + // Readiness is published inside start(). Arm signals first; stop() waits + // for an in-progress start before quiescing writers and releasing ownership. let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; @@ -125,4 +121,14 @@ export const runOnlyBotsDaemonCli = async ( }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); + removeSignalHandlers = () => { process.off("SIGTERM", shutdown); process.off("SIGINT", shutdown); }; + await daemon.start(port, ownership); + } catch (error) { + removeSignalHandlers(); + await closeComposition().catch(() => undefined); + await ownership.close(); + throw error; + } + + }; diff --git a/tests/runtime/daemon-ownership.test.ts b/tests/runtime/daemon-ownership.test.ts index 4a3b6b7..ac0e57e 100644 --- a/tests/runtime/daemon-ownership.test.ts +++ b/tests/runtime/daemon-ownership.test.ts @@ -20,10 +20,10 @@ const fixture = async (run: (home: string) => Promise) => { ensureRuntimeHomeLayout(home); try { await run(home); } finally { rmSync(home, { recursive: true, force: true }); } }; -const childDaemon = (home: string, extra: string[] = []) => { +const childDaemon = (home: string, extra: string[] = [], entry = path.resolve("dist/runtime/daemon.js")) => { // Build before this suite: these checks exercise the shipped CLI in another // OS process, including signal handlers and the parent-death kernel lock. - const child = spawn(process.execPath, [path.resolve("dist/runtime/daemon.js"), "serve", "--home", home, ...extra], { + const child = spawn(process.execPath, [entry, "serve", "--home", home, ...extra], { stdio: ["ignore", "ignore", "pipe"], env: { PATH: process.env.PATH }, }); let error = ""; @@ -62,6 +62,28 @@ const ready = async (home: string, job: Job) => { }; describe("single-home daemon ownership", () => { + it("handles SIGTERM while startup has published readiness but has not returned", async () => fixture(async home => { + const entry=path.join(home,"paused-start.cjs"); + writeFileSync(entry,` + const {OnlyBotsLocalRuntimeDaemon}=require(${JSON.stringify(path.resolve("dist/runtime/local-runtime.js"))}); + const original=OnlyBotsLocalRuntimeDaemon.prototype.start; + OnlyBotsLocalRuntimeDaemon.prototype.start=async function(...args){ + await original.apply(this,args); + await new Promise(()=>{}); // Deterministically hold the readiness boundary. + }; + require(${JSON.stringify(path.resolve("dist/runtime/daemon-host.js"))}).runOnlyBotsDaemonCli().catch(error=>{console.error(error.message);process.exit(1);}); + `,{mode:0o600}); + const job=childDaemon(home,[],entry); + try { + await ready(home,job); + await stopDetachedRuntimeDaemon(home); + expect(await waitTerminal(job)).toEqual({code:0,signal:null}); + expect(loadDaemonState(ensureRuntimeHomeLayout(home)).status).toBe("stopped"); + const successor=new OnlyBotsLocalRuntimeDaemon(home); + try {await successor.start();} finally {await successor.stop();} + } finally {await cleanup(job);} + }),15000); + it("excludes another instance and a failed contender stop cannot alter owner state", async () => fixture(async home => { const owner = new OnlyBotsLocalRuntimeDaemon(home); const contender = new OnlyBotsLocalRuntimeDaemon(home);