From bdc7061f98f1dbe85ced75f4e95026e4d288088e Mon Sep 17 00:00:00 2001 From: Mu-Tsun Tsai Date: Sat, 25 Apr 2026 11:49:26 +0800 Subject: [PATCH] fix(cli): shut down preview embedded-mode server on Ctrl+C (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): shut down preview embedded-mode server on Ctrl+C runEmbeddedMode awaited a promise that never resolved, relying on Node to exit on SIGINT. Two things kept that from working in practice: 1. @hono/node-server's listening handle keeps the event loop alive after the signal fires, so the process hangs even when SIGINT does arrive. 2. On Windows, some terminals (Git Bash / MSYS) don't deliver Ctrl+C to the Node process as a SIGINT at all — the keystroke is eaten at the TTY layer. Register a SIGINT/SIGTERM handler that closes the server explicitly and resolves the promise, with a 2s force-exit fallback. On Windows, run a readline interface on stdin to catch Ctrl+C at the TTY and re-emit it as SIGINT so the same handler fires. Print "Shutting down studio..." as soon as the signal is received — server.close() can take a second or two to drain keep-alive connections and an unmarked pause reads as "stuck". Exit code 0 because a user-initiated Ctrl+C isn't an error; non-zero codes make pnpm print ELIFECYCLE right where the user just asked the process to stop. * fix(cli): close readline interface in shutdown to honour exit-code intent After the SIGINT handler removes itself, the Windows readline interface is still alive and listening. A second Ctrl+C during the 2s grace period would re-emit SIGINT with no registered handler, triggering Node's default exit-130 behaviour and contradicting the explicit exit(0) we chose for clean teardown. Hoist the readline handle out of the win32 branch so shutdown can close it before invoking server.close(). Also pass the signal name to process.emit("SIGINT", "SIGINT") to match Node's ProcessEvents overload — runtime behaviour is unchanged. --- packages/cli/src/commands/preview.ts | 46 ++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index db425ebdf..4fe17a9c1 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -387,7 +387,47 @@ async function runEmbeddedMode( console.log(); import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {}); - // 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(() => {}); + // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening + // HTTP server keeps handles open, so the event loop stays alive after the + // signal handler fires. Close the server explicitly and resolve the promise + // so `run()` returns cleanly instead of requiring a second Ctrl+C (or, + // worse, the user force-killing the terminal). + // + // Windows wrinkle: Ctrl+C in some terminals (Git Bash / MSYS) doesn't reach + // Node as a SIGINT at all — the process just sits there. Run a readline + // interface on stdin so the keystroke is observed at the TTY layer and + // re-emit it as SIGINT. No-op on platforms where the signal already arrives. + let rl: import("node:readline").Interface | undefined; + if (process.platform === "win32") { + const readline = await import("node:readline"); + rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.on("SIGINT", () => { + process.emit("SIGINT", "SIGINT"); + }); + } + + return new Promise((resolveRun) => { + let shuttingDown = false; + const shutdown = (): void => { + if (shuttingDown) return; + shuttingDown = true; + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + // Close the readline interface so a second Ctrl+C during the grace + // period below doesn't re-emit SIGINT and trigger Node's default + // exit-130 behaviour, contradicting our intent to exit cleanly. + rl?.close(); + // `server.close()` can take a second or two to drain keep-alive + // connections; surface progress so the terminal doesn't look frozen. + console.log(); + console.log(` ${c.dim("Shutting down studio...")}`); + result.server.close(() => resolveRun()); + // If close() hangs on an open connection, force exit after a short + // grace period. Exit 0 because user-initiated Ctrl+C isn't an error + // — a non-zero code makes pnpm / npm print ELIFECYCLE. + setTimeout(() => process.exit(0), 2000).unref(); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); }