fix(cli): exit on Ctrl+C during hyperframes dev

The SIGINT handlers that were meant to do graceful cleanup (close server,
remove symlinks) override Node's default exit behavior. If the cleanup
hangs (e.g. server.close() blocked by open connections), the process is
stuck and Ctrl+C does nothing.

Fix: don't intercept SIGINT at all. Node's default behavior exits the
process immediately on Ctrl+C. The OS reclaims the port and file handles.
Use process.on("exit") for best-effort symlink cleanup instead.

Also fixes running the CLI via `tsx` in dev mode — __CLI_VERSION__ is a
tsup build-time define that crashes at runtime without a fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-03-27 06:35:41 +00:00
co-authored by Claude Opus 4.6
parent 2915b68633
commit 89beb95a87
2 changed files with 25 additions and 41 deletions
+23 -39
View File
@@ -163,28 +163,20 @@ async function runDevMode(dir: string): Promise<void> {
console.error(c.dim(err.message));
});
function cleanup(): void {
if (createdSymlink && existsSync(symlinkPath)) {
if (createdSymlink) {
process.on("exit", () => {
try {
unlinkSync(symlinkPath);
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
}
});
}
return new Promise<void>((resolvePromise) => {
// Temporarily ignore SIGINT on the parent so Ctrl+C only kills the child.
// The child gets SIGINT from the terminal's process group signal.
// When the child exits, we clean up and resolve back to the caller.
const noop = (): void => {};
process.on("SIGINT", noop);
child.on("close", () => {
process.removeListener("SIGINT", noop);
cleanup();
resolvePromise();
});
// Wait for child to exit. Ctrl+C sends SIGINT to the entire process group,
// so the child (Vite) receives it directly — no need to intercept or forward.
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
}
@@ -263,20 +255,18 @@ async function runLocalStudioMode(dir: string): Promise<void> {
console.error(c.dim(err.message));
});
return new Promise<void>((resolvePromise) => {
const noop = (): void => {};
process.on("SIGINT", noop);
child.on("close", () => {
process.removeListener("SIGINT", noop);
if (createdSymlink && existsSync(symlinkPath)) {
try {
unlinkSync(symlinkPath);
} catch {
/* ignore */
}
if (createdSymlink) {
process.on("exit", () => {
try {
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
} catch {
/* ignore */
}
resolvePromise();
});
}
return new Promise<void>((resolve) => {
child.on("close", () => resolve());
});
}
@@ -288,22 +278,20 @@ async function runEmbeddedMode(dir: string, startPort: number): Promise<void> {
const { createStudioServer } = await import("../server/studioServer.js");
const projectName = basename(dir);
const { app, watcher } = createStudioServer({ projectDir: dir });
const { app } = createStudioServer({ projectDir: dir });
clack.intro(c.bold("hyperframes dev"));
const s = clack.spinner();
s.start("Starting studio...");
let server: import("@hono/node-server").ServerType;
let actualPort: number;
try {
({ server, port: actualPort } = await serveWithPortFallback(app.fetch, startPort));
({ port: actualPort } = await serveWithPortFallback(app.fetch, startPort));
} catch (err: unknown) {
s.stop(c.error("Failed to start studio"));
console.error();
console.error(` ${(err as Error).message}`);
console.error();
watcher.close();
process.exitCode = 1;
return;
}
@@ -322,11 +310,7 @@ async function runEmbeddedMode(dir: string, startPort: number): Promise<void> {
console.log();
import("open").then((mod) => mod.default(`${url}#project/${projectName}`)).catch(() => {});
return new Promise<void>((resolvePromise) => {
process.on("SIGINT", () => {
console.log();
watcher.close();
server.close(() => resolvePromise());
});
});
// Block until the process is killed. Ctrl+C (SIGINT) uses Node's default
// behavior — exit immediately. The OS reclaims the port and file handles.
return new Promise<void>(() => {});
}
+2 -2
View File
@@ -1,2 +1,2 @@
declare const __CLI_VERSION__: string;
export const VERSION = __CLI_VERSION__;
declare const __CLI_VERSION__: string | undefined;
export const VERSION = typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "0.0.0-dev";