diff --git a/packages/cli/src/generated/commands/serve.ts b/packages/cli/src/generated/commands/serve.ts index bf39cb27..1b344b0b 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,9 +278,8 @@ export async function runServe(port: number, engine: string|undefined, allowedOr return; } - await new Promise((resolve) => { + const stopped = new Promise((resolve) => { let tornDown = false; - const wasPaused = process.stdin.isPaused(); const teardown = (): void => { if (tornDown) return; tornDown = true; @@ -299,7 +296,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(); }); @@ -310,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:306 +// @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 c924775b..c2d3321e 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,9 +267,8 @@ 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 wasPaused = process.stdin.isPaused(); const teardown = (): void => { if (tornDown) return; tornDown = true; @@ -288,7 +285,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(); }); @@ -299,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 f0682db3..3b84fff2 100644 --- a/tests/unit/serve-command.test.ts +++ b/tests/unit/serve-command.test.ts @@ -1,8 +1,14 @@ 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'; + +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). @@ -294,3 +300,43 @@ describe('agon serve — runtime wiring (integration)', () => { } }, 15000); }); + +describeProcessMaybe('agon serve process lifecycle', () => { + it('exits zero after the first SIGINT', async () => { + const home = mkdtempSync(join(tmpdir(), 'agon-serve-signal-')); + 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'], + }); + 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) => { + 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); + }); + 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); +});