Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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<void>();
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<void>();
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;
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
106 changes: 78 additions & 28 deletions packages/0-framework/3-tooling/cli/src/dev/run-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
Comment on lines +69 to +78

Copy link
Copy Markdown

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 entered stopping, but done never resolves or rejects.

  • packages/0-framework/3-tooling/cli/src/dev/run-dev.ts#L69-L78: invoke cleanup through Promise.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 where cleanup throws synchronously and assert that shutdown.done rejects.
Proposed fix
-      void cleanup().then(
+      void Promise.resolve().then(cleanup).then(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void cleanup().then(
() => {
state = 'stopped';
resolve();
},
(error: unknown) => {
state = 'stopped';
reject(error);
},
);
void Promise.resolve().then(cleanup).then(
() => {
state = 'stopped';
resolve();
},
(error: unknown) => {
state = 'stopped';
reject(error);
},
);
📍 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/0-framework/3-tooling/cli/src/dev/run-dev.ts` around lines 69 - 78,
Update the cleanup handling in run-dev.ts around the shutdown controller so
cleanup is invoked via Promise.resolve().then(cleanup), routing synchronous and
asynchronous failures through the existing rejection branch and ensuring done
rejects. Add a regression test in
packages/0-framework/3-tooling/cli/src/dev/__tests__/run-dev.test.ts covering a
synchronously throwing cleanup and asserting shutdown.done rejects.

};
});

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 }[],
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 runPipeline. That task can resume and run a deploy after attachment.stopServices() completes.

Track active rebuild tasks and block further deploy work when shutdown begins. Wait for or cancel those tasks before logging [dev] stopped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/0-framework/3-tooling/cli/src/dev/run-dev.ts` around lines 303 -
314, Update the shutdown flow around createDevShutdownController and the watch
rebuild callback to track active detached rebuild tasks, set a shutdown flag
that prevents any pending or resumed task from deploying, and await or cancel
all tracked tasks before attachment.stopServices completes and “[dev] stopped.”
is logged.


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;
}
7 changes: 4 additions & 3 deletions packages/0-framework/3-tooling/cli/src/dev/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export function watchTargetsFrom(bundles: Readonly<Record<string, Bundle>>): {
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<void>;
stop(): void;
/** Stops every OS-level watcher and resolves only after chokidar has released them. */
stop(): Promise<void>;
}

/**
Expand Down Expand Up @@ -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()));
},
};
}
Loading