fix(cli): preview stops at once on Ctrl+C after Studio was open (#6782)

* fix(cli): preview stops at once on Ctrl+C while a Studio tab is open

* fix(cli): a closed Studio tab leaves no timer that holds a stopping preview open

* fix(cli): name the tab-closed wait instead of commenting it

* test(cli): the tab-disconnect test checks the route's own keep-alive timer, not every timer
This commit is contained in:
Miguel Ángel
2026-10-30 05:58:35 +00:00
committed by GitHub
parent f43bec76e7
commit 3e795ece0f
5 changed files with 45 additions and 10 deletions
+2 -1
View File
@@ -54,6 +54,7 @@ import { runRenderSetupWorker } from "../utils/cancellableProcess.js";
import { formatLintStartupMessage } from "../utils/lintFormat.js";
import {
activeServerOnPort,
closeServer,
findPortAndServe,
scanActiveServers,
killActiveServers,
@@ -1743,7 +1744,7 @@ export async function runEmbeddedMode(
.catch(() => {})
.finally(() => {
watcher.close();
result.server.close(() => resolveRun());
void closeServer(result.server).then(resolveRun);
});
};
// `on`, not `once`: a repeat Ctrl+C/SIGTERM while shutdown is running must
+24 -1
View File
@@ -2,10 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createServer, type Server } from "node:net";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
import { createServer as createHttpServer, get, type Server as HttpServer } from "node:http";
import {
PORT_PROBE_HOSTS,
activeServerOnPort,
closeServer,
detectHyperframesServer,
findPortAndServe,
testPortOnAllHosts,
@@ -306,3 +307,25 @@ describe("detectHyperframesServer", () => {
expect(result).toEqual({ type: "match" });
});
});
describe("closeServer", () => {
it("closes while a client holds an event stream open", async () => {
const server = createHttpServer((_req, res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write(": open\n\n");
});
const port = await new Promise<number>((resolve) =>
server.listen(0, "127.0.0.1", () =>
resolve((server.address() as import("node:net").AddressInfo).port),
),
);
await new Promise<void>((resolve) =>
get(`http://127.0.0.1:${port}/`, (res) => {
res.on("error", () => {});
resolve();
}).on("error", () => {}),
);
await closeServer(server);
expect(server.listening).toBe(false);
});
});
+6
View File
@@ -413,6 +413,12 @@ export async function killActiveServers(
return { killed, unverified };
}
/** Stops `server` once every open connection is closed too; an open Studio tab holds its event stream for its life. */
export function closeServer(server: import("@hono/node-server").ServerType): Promise<void> {
if ("closeAllConnections" in server) server.closeAllConnections();
return new Promise((resolve) => server.close(() => resolve()));
}
// ── Smart port selection ───────────────────────────────────────────────────
export type FindPortResult =
@@ -891,6 +891,17 @@ describe("Studio file-change SSE", () => {
return { projectDir, projectUrl };
}
it("leaves no timer behind once the tab disconnects, so a stopping CLI can exit", async () => {
server = createStudioServer({ projectDir: tmpProject() });
const timeouts = vi.spyOn(globalThis, "setTimeout");
const [stream] = await subscribe(1);
await stream!.cancel();
const keepAlive = timeouts.mock.calls.filter(([, ms]) => (ms ?? 0) >= 30_000);
timeouts.mockRestore();
expect(keepAlive).toEqual([]);
});
it("marks a notes write as not affecting the preview, so the tab does not reload", async () => {
const { projectDir } = await previewedProject();
const [stream] = await subscribe(1);
+2 -8
View File
@@ -1034,14 +1034,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
};
watcher.addListener(wrappedListener);
stream.onAbort(() => watcher.removeListener(wrappedListener));
try {
while (true) {
await stream.sleep(30000);
}
} finally {
watcher.removeListener(wrappedListener);
}
if (!stream.aborted) await new Promise<void>((tabClosed) => stream.onAbort(tabClosed));
watcher.removeListener(wrappedListener);
});
});