Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions packages/cli/src/generated/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof runtime.serve.start>>;
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 */ }
Expand All @@ -280,9 +278,8 @@ export async function runServe(port: number, engine: string|undefined, allowedOr
return;
}

await new Promise<void>((resolve) => {
const stopped = new Promise<void>((resolve) => {
let tornDown = false;
const wasPaused = process.stdin.isPaused();
const teardown = (): void => {
if (tornDown) return;
tornDown = true;
Expand All @@ -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();
});
Expand All @@ -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',
Expand Down
20 changes: 11 additions & 9 deletions packages/cli/src/kern/commands/serve.kern
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof runtime.serve.start>>;
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 */ }
Expand All @@ -269,9 +267,8 @@ fn name=runServe params="port:number, engine:string|undefined, allowedOrigins:st
return;
}

await new Promise<void>((resolve) => {
const stopped = new Promise<void>((resolve) => {
let tornDown = false;
const wasPaused = process.stdin.isPaused();
const teardown = (): void => {
if (tornDown) return;
tornDown = true;
Expand All @@ -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();
});
Expand All @@ -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 ───────────────────────────────────────────────────────────────
Expand Down
48 changes: 47 additions & 1 deletion tests/unit/serve-command.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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<boolean>((resolve) => {
let poll: ReturnType<typeof setInterval>;
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<number | null>((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);
});