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
5 changes: 5 additions & 0 deletions .changeset/close-interrupted-dev-startup.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 103 additions & 1 deletion packages/eve/src/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fn: () => Promise<T>): Promise<T> {
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Expand Down Expand Up @@ -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<StartedDevelopmentServer>((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<void>((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 () => ({
Expand Down
61 changes: 31 additions & 30 deletions packages/eve/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,30 +168,6 @@ function shouldPrintCliBootBanner(actionCommand: Command): boolean {
);
}

async function waitForShutdownSignal(input: { close(): Promise<void> }): Promise<void> {
await new Promise<void>((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<void> {
await Promise.race([
input.wait(),
Expand Down Expand Up @@ -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<typeof listenForShutdownSignal> | 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;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/cli/shutdown-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface ShutdownSignal {
readonly received: Promise<void>;
readonly signal: AbortSignal;
dispose(): void;
}

export function listenForShutdownSignal(): ShutdownSignal {
const controller = new AbortController();
let resolveSignal: (() => void) | undefined;
const received = new Promise<void>((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<void> }): Promise<void> {
const shutdownSignal = listenForShutdownSignal();
try {
await shutdownSignal.received;
await input.close();
} finally {
shutdownSignal.dispose();
}
}
Loading