-
Notifications
You must be signed in to change notification settings - Fork 1
fix(cli): make dev shutdown reliable #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void>; | ||
| /** 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<void>, | ||
| forceExit: (code: number) => void = (code) => process.exit(code), | ||
| ): DevShutdownController { | ||
| let state: 'idle' | 'stopping' | 'stopped' = 'idle'; | ||
| let handle: (signal: DevShutdownSignal) => void = () => {}; | ||
|
|
||
| const done = new Promise<void>((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<numb | |
| // be missed entirely — wait until watching is real before handing over. | ||
| await watch.ready; | ||
|
|
||
| await new Promise<void>((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.'); | ||
| }); | ||
|
Comment on lines
+303
to
314
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Prevent an active rebuild from deploying after shutdown starts. The watch callback starts a detached rebuild task. Shutdown stops the watcher and services, but it does not cancel or await a rebuild that is already awaiting Track active rebuild tasks and block further deploy work when shutdown begins. Wait for or cancel those tasks before logging 🤖 Prompt for AI Agents |
||
|
|
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Route synchronous cleanup failures through
done.cleanup()can throw before it returns a promise. The controller has already enteredstopping, butdonenever resolves or rejects.packages/0-framework/3-tooling/cli/src/dev/run-dev.ts#L69-L78: invoke cleanup throughPromise.resolve().then(cleanup)so synchronous and asynchronous failures use the rejection branch.packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts#L27-L78: add a regression test wherecleanupthrows synchronously and assert thatshutdown.donerejects.Proposed fix
📝 Committable suggestion
📍 Affects 2 files
packages/0-framework/3-tooling/cli/src/dev/run-dev.ts#L69-L78(this comment)packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts#L27-L78🤖 Prompt for AI Agents