diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts index 9c514c22c..82f2719d8 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { renderFrontDoor } from '../run-dev.ts'; +import { createDevShutdownController, renderFrontDoor } from '../run-dev.ts'; describe('renderFrontDoor()', () => { test('starts with "[dev] ready:", then orders by address depth (fewest dots first), then lexicographic', () => { @@ -23,3 +23,56 @@ describe('renderFrontDoor()', () => { expect(renderFrontDoor([])).toEqual(['[dev] ready:']); }); }); + +describe('createDevShutdownController()', () => { + test('waits for graceful cleanup and ignores signals after it completes', async () => { + const cleanup = Promise.withResolvers(); + let cleanupCalls = 0; + const forcedExitCodes: number[] = []; + const shutdown = createDevShutdownController( + () => { + cleanupCalls += 1; + return cleanup.promise; + }, + (code) => forcedExitCodes.push(code), + ); + + let stopped = false; + void shutdown.done.then(() => { + stopped = true; + }); + + shutdown.handle('SIGINT'); + await Promise.resolve(); + expect(cleanupCalls).toBe(1); + expect(stopped).toBe(false); + + cleanup.resolve(); + await shutdown.done; + shutdown.handle('SIGINT'); + + expect(stopped).toBe(true); + expect(forcedExitCodes).toEqual([]); + }); + + test('a second signal during cleanup forces the conventional signal exit code', async () => { + for (const [signal, exitCode] of [ + ['SIGINT', 130], + ['SIGTERM', 143], + ] as const) { + const cleanup = Promise.withResolvers(); + const forcedExitCodes: number[] = []; + const shutdown = createDevShutdownController( + () => cleanup.promise, + (code) => forcedExitCodes.push(code), + ); + + shutdown.handle(signal); + shutdown.handle(signal); + + expect(forcedExitCodes).toEqual([exitCode]); + cleanup.resolve(); + await shutdown.done; + } + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts index da9756027..4ce027337 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/watch.test.ts @@ -64,7 +64,7 @@ describe('startWatch()', () => { await until(() => calls === 1, 2000); expect(calls).toBe(1); } finally { - watch.stop(); + await watch.stop(); fs.rmSync(dir, { recursive: true, force: true }); } }, 10_000); @@ -78,7 +78,7 @@ describe('startWatch()', () => { const watch = startWatch([{ address: 'a', paths: [file] }], () => { calls += 1; }); - watch.stop(); + await watch.stop(); fs.writeFileSync(file, 'a2'); await sleep(500); @@ -123,7 +123,7 @@ describe('startWatch()', () => { await until(() => calls === 2, 3000); expect(calls).toBe(2); } finally { - watch.stop(); + await watch.stop(); fs.rmSync(dir, { recursive: true, force: true }); } }, 10_000); diff --git a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts index e8432aa76..e9b28cc57 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/run-dev.ts @@ -37,6 +37,51 @@ function toCliError(error: unknown): CliError { : new CliError(error instanceof Error ? error.message : String(error)); } +type DevShutdownSignal = 'SIGINT' | 'SIGTERM'; + +export interface DevShutdownController { + /** Resolves after the graceful cleanup started by the first signal completes. */ + readonly done: Promise; + /** Starts graceful cleanup, or forces termination when cleanup is already in progress. */ + handle(signal: DevShutdownSignal): void; +} + +/** + * Makes the first signal graceful and the second decisive. A stuck watcher or + * attachment must never leave a dev process that swallows every later Ctrl-C. + */ +export function createDevShutdownController( + cleanup: () => Promise, + forceExit: (code: number) => void = (code) => process.exit(code), +): DevShutdownController { + let state: 'idle' | 'stopping' | 'stopped' = 'idle'; + let handle: (signal: DevShutdownSignal) => void = () => {}; + + const done = new Promise((resolve, reject) => { + handle = (signal) => { + if (state === 'stopped') return; + if (state === 'stopping') { + forceExit(signal === 'SIGINT' ? 130 : 143); + return; + } + + state = 'stopping'; + void cleanup().then( + () => { + state = 'stopped'; + resolve(); + }, + (error: unknown) => { + state = 'stopped'; + reject(error); + }, + ); + }; + }); + + return { done, handle }; +} + /** `[dev] ready:` then one line per endpoint, ordered by address depth (fewest dots first) then lexicographic. Exported for tests. */ export function renderFrontDoor( endpoints: readonly { readonly address: string; readonly url: string }[], @@ -255,38 +300,43 @@ export async function runDev(args: DevArgs, deps: DevRunDeps = {}): Promise((resolve) => { - let stopping = false; - - const finish = (): void => { - if (stopping) return; - stopping = true; - console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); - watch.stop(); - void (async () => { + const shutdown = createDevShutdownController(async () => { + console.log("[dev] stopping — the app's services are stopping; emulators and data stay up."); + await Promise.all([ + watch.stop(), + (async () => { for (const attachment of attachments) { await attachment.stopServices().catch(() => undefined); } - console.log('[dev] stopped.'); - resolve(); - })(); - }; - - // alchemy's own library code (imported transitively while loading the - // app's config/providers) registers its own process-level SIGINT/SIGTERM - // listeners for ITS OWN in-process resource bookkeeping — irrelevant - // here, since the actual converge runs in a separate spawned `alchemy` - // child process (run-alchemy.ts), never in this one. Left in place, - // whichever of its listeners runs first can call process.exit() - // synchronously and tear this process down before the watch loop's own - // async cleanup (stopping the app's services) ever gets a turn. This is - // this process's OWN signal handling from here on: strip whatever else - // is registered and become the only listener. - process.removeAllListeners('SIGINT'); - process.removeAllListeners('SIGTERM'); - process.on('SIGINT', finish); - process.on('SIGTERM', finish); + })(), + ]); + console.log('[dev] stopped.'); }); + const onSigint = (): void => shutdown.handle('SIGINT'); + const onSigterm = (): void => shutdown.handle('SIGTERM'); + + // alchemy's own library code (imported transitively while loading the + // app's config/providers) registers its own process-level SIGINT/SIGTERM + // listeners for ITS OWN in-process resource bookkeeping — irrelevant + // here, since the actual converge runs in a separate spawned `alchemy` + // child process (run-alchemy.ts), never in this one. Left in place, + // whichever of its listeners runs first can call process.exit() + // synchronously and tear this process down before the watch loop's own + // async cleanup (stopping the app's services) ever gets a turn. This is + // this process's OWN signal handling from here on: strip whatever else + // is registered and become the only listener. + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGTERM'); + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + + try { + await shutdown.done; + } finally { + process.off('SIGINT', onSigint); + process.off('SIGTERM', onSigterm); + } + return 0; } diff --git a/packages/0-framework/3-tooling/cli/src/dev/watch.ts b/packages/0-framework/3-tooling/cli/src/dev/watch.ts index b97e9b0a5..40c66ad0a 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/watch.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/watch.ts @@ -49,7 +49,8 @@ export function watchTargetsFrom(bundles: Readonly>): { export interface WatchHandle { /** Resolves once chokidar's OS-level watches are attached — a change made before this can be missed entirely. Also resolves on `stop()` so an awaiting caller can never hang. */ readonly ready: Promise; - stop(): void; + /** Stops every OS-level watcher and resolves only after chokidar has released them. */ + stop(): Promise; } /** @@ -129,10 +130,10 @@ export function startWatch(targets: readonly WatchTarget[], onChange: () => void return { ready, - stop: () => { + stop: async () => { if (timer !== undefined) clearTimeout(timer); markReady(); - for (const watcher of watchers) void watcher.close(); + await Promise.all(watchers.map((watcher) => watcher.close())); }, }; }