From ad7cc29413db8ebc57f0898796f7aad3b8e96c53 Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Thu, 30 Jul 2026 09:17:40 +0200 Subject: [PATCH 1/2] fix(cli): release stdin on serve shutdown --- packages/cli/src/generated/commands/serve.ts | 5 +-- packages/cli/src/kern/commands/serve.kern | 3 +- tests/unit/serve-command.test.ts | 41 +++++++++++++++++++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/generated/commands/serve.ts b/packages/cli/src/generated/commands/serve.ts index bf39cb273..58617968b 100644 --- a/packages/cli/src/generated/commands/serve.ts +++ b/packages/cli/src/generated/commands/serve.ts @@ -282,7 +282,6 @@ export async function runServe(port: number, engine: string|undefined, allowedOr await new Promise((resolve) => { let tornDown = false; - const wasPaused = process.stdin.isPaused(); const teardown = (): void => { if (tornDown) return; tornDown = true; @@ -299,7 +298,7 @@ export async function runServe(port: number, engine: string|undefined, allowedOr .finally(() => { try { eventLogFlush(runtime.sessionId); } catch { /* best-effort */ } removeServeConnectionFile(runtime.sessionId); - try { if (wasPaused) process.stdin.pause(); } catch { /* best-effort */ } + try { process.stdin.pause(); } catch { /* best-effort */ } success('agon serve stopped.'); resolve(); }); @@ -312,7 +311,7 @@ export async function runServe(port: number, engine: string|undefined, allowedOr }); } -// @kern-source: serve:306 +// @kern-source: serve:305 export const serveCommand: any = defineCommand({ meta: { name: 'serve', diff --git a/packages/cli/src/kern/commands/serve.kern b/packages/cli/src/kern/commands/serve.kern index c924775b8..b312a4488 100644 --- a/packages/cli/src/kern/commands/serve.kern +++ b/packages/cli/src/kern/commands/serve.kern @@ -271,7 +271,6 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st await new Promise((resolve) => { let tornDown = false; - const wasPaused = process.stdin.isPaused(); const teardown = (): void => { if (tornDown) return; tornDown = true; @@ -288,7 +287,7 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st .finally(() => { try { eventLogFlush(runtime.sessionId); } catch { /* best-effort */ } removeServeConnectionFile(runtime.sessionId); - try { if (wasPaused) process.stdin.pause(); } catch { /* best-effort */ } + try { process.stdin.pause(); } catch { /* best-effort */ } success('agon serve stopped.'); resolve(); }); diff --git a/tests/unit/serve-command.test.ts b/tests/unit/serve-command.test.ts index f0682db3b..81044fb6a 100644 --- a/tests/unit/serve-command.test.ts +++ b/tests/unit/serve-command.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { mkdtempSync, existsSync, statSync, readFileSync } from 'node:fs'; +import { mkdtempSync, existsSync, statSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; // Point AGON_HOME at a throwaway home BEFORE anything resolves a path (the event // ledger + agonPath resolve at call time, so setting it pre-import is enough). @@ -294,3 +296,40 @@ describe('agon serve — runtime wiring (integration)', () => { } }, 15000); }); + +describe('agon serve process lifecycle', () => { + it('exits zero after the first SIGINT', async () => { + const home = mkdtempSync(join(tmpdir(), 'agon-serve-signal-')); + const cliEntry = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', 'packages', 'cli', 'dist', 'index.js'); + const child = spawn(process.execPath, [cliEntry, 'serve', '--port', '0', '--emit-connection'], { + env: { ...process.env, AGON_HOME: home, AGON_NO_STACK_TRACE_MAPPER: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout?.setEncoding('utf-8'); + child.stderr?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr?.on('data', (chunk: string) => { stderr += chunk; }); + try { + const ready = await new Promise((resolve) => { + const deadline = setTimeout(() => resolve(false), 10_000); + const poll = setInterval(() => { + if (!stdout.includes('__AGON_CONNECTION__')) return; + clearTimeout(deadline); clearInterval(poll); resolve(true); + }, 25); + }); + expect(ready, stderr).toBe(true); + child.kill('SIGINT'); + const exitCode = await Promise.race([ + new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve); }), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 5_000)), + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('agon serve stopped'); + } finally { + if (child.exitCode === null) child.kill('SIGKILL'); + rmSync(home, { recursive: true, force: true }); + } + }, 20_000); +}); From 1ee3480fe2da71a9c552219b0730fa69ceca482c Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Wed, 5 Aug 2026 09:30:21 +0200 Subject: [PATCH 2/2] fix(cli): install serve signal handlers before readiness --- packages/cli/src/generated/commands/serve.ts | 19 +++++++++++-------- packages/cli/src/kern/commands/serve.kern | 17 ++++++++++------- tests/unit/serve-command.test.ts | 17 ++++++++++++----- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/generated/commands/serve.ts b/packages/cli/src/generated/commands/serve.ts index 58617968b..1b344b0b3 100644 --- a/packages/cli/src/generated/commands/serve.ts +++ b/packages/cli/src/generated/commands/serve.ts @@ -259,17 +259,15 @@ export async function runServe(port: number, engine: string|undefined, allowedOr // the already-opened brain (an engine subprocess) or leave a half-open bridge // with no valid handoff file. Tear down everything acquired so far and fail // CLOSED (exit 2), never an unhandled-rejection crash. + let started: Awaited>; + let tokenPath: string; try { - const started = await runtime.serve.start(port); - const tokenPath = writeServeConnectionFile(runtime.sessionId, started.url, started.token, engineId, allowedOrigins); + started = await runtime.serve.start(port); + tokenPath = writeServeConnectionFile(runtime.sessionId, started.url, started.token, engineId, allowedOrigins); // Record the provenance frame BEFORE handing out the URL: a client that reads the // machine-readable line and immediately attaches must find the "which brain @ where" // frame already in the ledger (the documented verify-before-you-send invariant). recordServeReady(runtime.sessionId, engineId, started.url, allowedOrigins); - // Machine-readable handoff (the native host reads this line), then the human banner. - // Gated so a normal `agon serve` is unchanged. - if (emitConnection) emitServeConnectionLine(started.url, started.token, runtime.sessionId, engineId, allowedOrigins, tokenPath); - printServeBanner(started.url, started.token, tokenPath, runtime.sessionId, engineId, allowedOrigins); } catch (err) { warn(`failed to start: ${err instanceof Error ? err.message : String(err)}`); try { await runtime.serve.close(); } catch { /* may never have bound */ } @@ -280,7 +278,7 @@ export async function runServe(port: number, engine: string|undefined, allowedOr return; } - await new Promise((resolve) => { + const stopped = new Promise((resolve) => { let tornDown = false; const teardown = (): void => { if (tornDown) return; @@ -309,9 +307,14 @@ export async function runServe(port: number, engine: string|undefined, allowedOr process.on('SIGINT', teardown); process.on('SIGTERM', teardown); }); + // Install the stop handlers before the machine-readable readiness line. + // A controller can send SIGINT as soon as it reads this line. + if (emitConnection) emitServeConnectionLine(started.url, started.token, runtime.sessionId, engineId, allowedOrigins, tokenPath); + printServeBanner(started.url, started.token, tokenPath, runtime.sessionId, engineId, allowedOrigins); + await stopped; } -// @kern-source: serve:305 +// @kern-source: serve:308 export const serveCommand: any = defineCommand({ meta: { name: 'serve', diff --git a/packages/cli/src/kern/commands/serve.kern b/packages/cli/src/kern/commands/serve.kern index b312a4488..c2d3321e9 100644 --- a/packages/cli/src/kern/commands/serve.kern +++ b/packages/cli/src/kern/commands/serve.kern @@ -248,17 +248,15 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st // the already-opened brain (an engine subprocess) or leave a half-open bridge // with no valid handoff file. Tear down everything acquired so far and fail // CLOSED (exit 2), never an unhandled-rejection crash. + let started: Awaited>; + let tokenPath: string; try { - const started = await runtime.serve.start(port); - const tokenPath = writeServeConnectionFile(runtime.sessionId, started.url, started.token, engineId, allowedOrigins); + started = await runtime.serve.start(port); + tokenPath = writeServeConnectionFile(runtime.sessionId, started.url, started.token, engineId, allowedOrigins); // Record the provenance frame BEFORE handing out the URL: a client that reads the // machine-readable line and immediately attaches must find the "which brain @ where" // frame already in the ledger (the documented verify-before-you-send invariant). recordServeReady(runtime.sessionId, engineId, started.url, allowedOrigins); - // Machine-readable handoff (the native host reads this line), then the human banner. - // Gated so a normal `agon serve` is unchanged. - if (emitConnection) emitServeConnectionLine(started.url, started.token, runtime.sessionId, engineId, allowedOrigins, tokenPath); - printServeBanner(started.url, started.token, tokenPath, runtime.sessionId, engineId, allowedOrigins); } catch (err) { warn(`failed to start: ${err instanceof Error ? err.message : String(err)}`); try { await runtime.serve.close(); } catch { /* may never have bound */ } @@ -269,7 +267,7 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st return; } - await new Promise((resolve) => { + const stopped = new Promise((resolve) => { let tornDown = false; const teardown = (): void => { if (tornDown) return; @@ -298,6 +296,11 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st process.on('SIGINT', teardown); process.on('SIGTERM', teardown); }); + // Install the stop handlers before the machine-readable readiness line. + // A controller can send SIGINT as soon as it reads this line. + if (emitConnection) emitServeConnectionLine(started.url, started.token, runtime.sessionId, engineId, allowedOrigins, tokenPath); + printServeBanner(started.url, started.token, tokenPath, runtime.sessionId, engineId, allowedOrigins); + await stopped; >>> // ── Command ─────────────────────────────────────────────────────────────── diff --git a/tests/unit/serve-command.test.ts b/tests/unit/serve-command.test.ts index 81044fb6a..3b84fff2a 100644 --- a/tests/unit/serve-command.test.ts +++ b/tests/unit/serve-command.test.ts @@ -6,6 +6,10 @@ import { createServer } from 'node:http'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const CLI_ENTRY = join(HERE, '..', '..', 'packages', 'cli', 'dist', 'index.js'); +const describeProcessMaybe = existsSync(CLI_ENTRY) ? describe : describe.skip; + // Point AGON_HOME at a throwaway home BEFORE anything resolves a path (the event // ledger + agonPath resolve at call time, so setting it pre-import is enough). process.env.AGON_HOME = mkdtempSync(join(tmpdir(), 'agon-serve-cmd-test-')); @@ -297,11 +301,10 @@ describe('agon serve — runtime wiring (integration)', () => { }, 15000); }); -describe('agon serve process lifecycle', () => { +describeProcessMaybe('agon serve process lifecycle', () => { it('exits zero after the first SIGINT', async () => { const home = mkdtempSync(join(tmpdir(), 'agon-serve-signal-')); - const cliEntry = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', 'packages', 'cli', 'dist', 'index.js'); - const child = spawn(process.execPath, [cliEntry, 'serve', '--port', '0', '--emit-connection'], { + const child = spawn(process.execPath, [CLI_ENTRY, 'serve', '--port', '0', '--emit-connection'], { env: { ...process.env, AGON_HOME: home, AGON_NO_STACK_TRACE_MAPPER: '1' }, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -313,8 +316,12 @@ describe('agon serve process lifecycle', () => { child.stderr?.on('data', (chunk: string) => { stderr += chunk; }); try { const ready = await new Promise((resolve) => { - const deadline = setTimeout(() => resolve(false), 10_000); - const poll = setInterval(() => { + let poll: ReturnType; + const deadline = setTimeout(() => { + clearInterval(poll); + resolve(false); + }, 10_000); + poll = setInterval(() => { if (!stdout.includes('__AGON_CONNECTION__')) return; clearTimeout(deadline); clearInterval(poll); resolve(true); }, 25);