From 564dfadd497ae639063fe7103b2bb7db5aaf9021 Mon Sep 17 00:00:00 2001 From: "vercel-gh-bot-3[bot]" <282332853+vercel-gh-bot-3[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:42:19 +0000 Subject: [PATCH] e0: prepare changes Co-authored-by: ruiconti <1834568+ruiconti@users.noreply.github.com> --- .changeset/close-interrupted-dev-startup.md | 5 + packages/eve/src/cli/run.test.ts | 104 +++++++++++++++++++- packages/eve/src/cli/run.ts | 61 ++++++------ packages/eve/src/cli/shutdown-signal.ts | 43 ++++++++ 4 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 .changeset/close-interrupted-dev-startup.md create mode 100644 packages/eve/src/cli/shutdown-signal.ts diff --git a/.changeset/close-interrupted-dev-startup.md b/.changeset/close-interrupted-dev-startup.md new file mode 100644 index 000000000..7cd55e648 --- /dev/null +++ b/.changeset/close-interrupted-dev-startup.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`eve dev` now closes its local server when Ctrl-C interrupts startup, preventing the server from remaining open after the interruption. diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index fb69fea92..6662164ad 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { resolveDevUiMode, resolveTuiDisplayOptions, runCli } from "#cli/run.js"; import type { RunDevelopmentTuiInput } from "#cli/dev/tui/tui.js"; -import type { DevelopmentServerOptions } from "#internal/nitro/host/types.js"; +import type { + DevelopmentServerOptions, + StartedDevelopmentServer, +} from "#internal/nitro/host/types.js"; async function withInteractiveTerminal(fn: () => Promise): Promise { const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -359,6 +362,105 @@ describe("eve dev boot progress", () => { }); describe("eve dev local server ownership", () => { + it("closes a server when SIGINT interrupts startup", async () => { + const initialSignalListeners = new Set(process.listeners("SIGINT")); + let resolveStart: ((handle: StartedDevelopmentServer) => void) | undefined; + const started = new Promise((resolve) => { + resolveStart = resolve; + }); + const close = vi.fn(async () => { + await started; + }); + const start = vi.fn(() => started); + const runDevelopmentTui = vi.fn(async () => {}); + const running = withInteractiveTerminal(() => + runCli( + ["dev"], + { error: () => {}, log: () => {} }, + { + runDevelopmentTui, + startHost: () => ({ close, start }), + }, + ), + ); + + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + const shutdownListener = process + .listeners("SIGINT") + .find((listener) => !initialSignalListeners.has(listener)); + let shutdownListenerPresentForLaterHandlers = false; + process.once("SIGINT", () => { + shutdownListenerPresentForLaterHandlers = + shutdownListener !== undefined && process.listeners("SIGINT").includes(shutdownListener); + }); + process.emit("SIGINT"); + await new Promise((resolve) => setImmediate(resolve)); + const closeStartedBeforeStartupFinished = close.mock.calls.length > 0; + + if (resolveStart === undefined) { + throw new Error("Expected the development server to start."); + } + resolveStart({ + kind: "started", + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }); + await running; + + expect(closeStartedBeforeStartupFinished).toBe(true); + expect(shutdownListenerPresentForLaterHandlers).toBe(true); + expect(close).toHaveBeenCalledOnce(); + expect(runDevelopmentTui).not.toHaveBeenCalled(); + }); + + it("stops before the first TUI paint when SIGINT interrupts connection", async () => { + const close = vi.fn(async () => {}); + let continueTui: (() => void) | undefined; + let painted = false; + const runDevelopmentTui = vi.fn( + (input: RunDevelopmentTuiInput) => + new Promise((resolve, reject) => { + continueTui = () => { + try { + input.onBootProgress?.({ type: "before-first-paint" }); + painted = true; + resolve(); + } catch (error) { + reject(error); + } + }; + }), + ); + const running = withInteractiveTerminal(() => + runCli( + ["dev"], + { error: () => {}, log: () => {} }, + { + runDevelopmentTui, + startHost: () => ({ + close, + start: async () => ({ + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + }), + }, + ), + ); + + await vi.waitFor(() => expect(runDevelopmentTui).toHaveBeenCalledOnce()); + process.emit("SIGINT"); + if (continueTui === undefined) { + throw new Error("Expected the terminal UI to begin connecting."); + } + continueTui(); + await running; + + expect(painted).toBe(false); + expect(close).toHaveBeenCalledOnce(); + }); + it("uses the host's canonical root and leaves an attached server running", async () => { const startHost = vi.fn(() => ({ start: async () => ({ diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index e3a7e19fe..6356bab99 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -18,6 +18,7 @@ import { resolveTuiTitle, type DevelopmentTuiTarget } from "#cli/dev/tui/target. import { parseDevelopmentServerUrl } from "#cli/dev/url.js"; import { startCliLiveRow } from "#cli/ui/live-row.js"; import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js"; +import { listenForShutdownSignal, waitForShutdownSignal } from "#cli/shutdown-signal.js"; import { createLogger } from "#internal/logging.js"; import type { ApplicationBuildOptions, @@ -167,30 +168,6 @@ function shouldPrintCliBootBanner(actionCommand: Command): boolean { ); } -async function waitForShutdownSignal(input: { close(): Promise }): Promise { - await new Promise((resolve, reject) => { - let settled = false; - - const cleanup = () => { - process.off("SIGINT", handleSignal); - process.off("SIGTERM", handleSignal); - }; - - const handleSignal = () => { - if (settled) { - return; - } - - settled = true; - cleanup(); - void input.close().then(resolve, reject); - }; - - process.once("SIGINT", handleSignal); - process.once("SIGTERM", handleSignal); - }); -} - async function waitForProductionServer(input: ProductionServerHandle): Promise { await Promise.race([ input.wait(), @@ -549,7 +526,15 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm // Print spacing before the live row; a later write would strand the row. if (mode === "tui") logger.log(""); const buildProgress = mode === "tui" ? startCliLiveRow(logger) : undefined; - const onBootProgress = createDevBootProgressReporter(buildProgress); + const reportBootProgress = createDevBootProgressReporter(buildProgress); + let shutdownSignal: ReturnType | undefined; + const onBootProgress: DevBootProgressReporter = (event) => { + reportBootProgress(event); + if (event.type === "before-first-paint") { + shutdownSignal?.dispose(); + shutdownSignal?.signal.throwIfAborted(); + } + }; buildProgress?.update("Building your agent"); let closed = false; @@ -566,13 +551,20 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm try { const startHost = runtime.startHost ?? (await loadStartHost()); + shutdownSignal = listenForShutdownSignal(); server = startHost(appRoot, { existing: mode === "tui" ? "attach-if-unconfigured" : "reject", host: options.host, onBootProgress, port: options.port, }); - const handle = await server.start(); + const handle = await Promise.race([ + server.start(), + shutdownSignal.received.then(() => undefined), + ]); + if (handle === undefined) { + return; + } // The terminal UI's header already shows the server URL, and startup // no longer clears the screen, so the line would linger as noise. @@ -601,13 +593,22 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm ); } - return await waitForShutdownSignal({ - close: closeServer, - }); + await shutdownSignal.received; + return; } - await runInteractiveUi({ appRoot: handle.appRoot, serverUrl: handle.url }, onBootProgress); + try { + await runInteractiveUi( + { appRoot: handle.appRoot, serverUrl: handle.url }, + onBootProgress, + ); + } catch (error) { + if (!shutdownSignal.signal.aborted || error !== shutdownSignal.signal.reason) { + throw error; + } + } } finally { + shutdownSignal?.dispose(); buildProgress?.stop(); await closeServer(); } diff --git a/packages/eve/src/cli/shutdown-signal.ts b/packages/eve/src/cli/shutdown-signal.ts new file mode 100644 index 000000000..4cd54c8f2 --- /dev/null +++ b/packages/eve/src/cli/shutdown-signal.ts @@ -0,0 +1,43 @@ +export interface ShutdownSignal { + readonly received: Promise; + readonly signal: AbortSignal; + dispose(): void; +} + +export function listenForShutdownSignal(): ShutdownSignal { + const controller = new AbortController(); + let resolveSignal: (() => void) | undefined; + const received = new Promise((resolve) => { + resolveSignal = resolve; + }); + const dispose = () => { + process.off("SIGINT", handleSignal); + process.off("SIGTERM", handleSignal); + }; + const handleSignal = () => { + if (resolveSignal === undefined) { + return; + } + + // Keep handling the signal until the caller finishes cleanup. Other exit + // listeners may re-raise it when no application listener remains. + controller.abort(); + resolveSignal(); + resolveSignal = undefined; + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + + return { dispose, received, signal: controller.signal }; +} + +export async function waitForShutdownSignal(input: { close(): Promise }): Promise { + const shutdownSignal = listenForShutdownSignal(); + try { + await shutdownSignal.received; + await input.close(); + } finally { + shutdownSignal.dispose(); + } +}