From 6beb056f349e426d141cf6871366e25abfc87d9a Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Thu, 12 Nov 2026 18:35:36 +0800 Subject: [PATCH 1/2] fix(pack): finish shutdown before removing signal handlers --- .../__test__/serveSignals.fixture.config.ts | 48 +++++++ .../pack/src/__test__/serveSignals.test.ts | 117 ++++++++++++++++++ .../pack/src/__test__/serveSignalsChild.ts | 13 ++ packages/pack/src/commands/dev.ts | 8 +- 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 packages/pack/src/__test__/serveSignals.fixture.config.ts create mode 100644 packages/pack/src/__test__/serveSignals.test.ts create mode 100644 packages/pack/src/__test__/serveSignalsChild.ts diff --git a/packages/pack/src/__test__/serveSignals.fixture.config.ts b/packages/pack/src/__test__/serveSignals.fixture.config.ts new file mode 100644 index 000000000..c544a44bf --- /dev/null +++ b/packages/pack/src/__test__/serveSignals.fixture.config.ts @@ -0,0 +1,48 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +export default { + server: { middlewareMode: true }, + resolve: { + alias: [ + { + find: "../core/hmr", + replacement: fileURLToPath(import.meta.url), + }, + ], + }, +}; + +// Keep the real dev server and signal handlers; control only its async flush. +export async function createHotReloader() { + let closeCalls = 0; + return { + start: async () => {}, + getClientPaths: () => [], + registerClient: () => {}, + unregisterClient: () => {}, + handleClientMessage: () => {}, + close: async () => { + closeCalls++; + await new Promise((resolve) => { + const onMessage = (message: unknown) => { + if (message !== "release") return; + process.off("message", onMessage); + resolve(); + }; + process.on("message", onMessage); + process.send?.("closing"); + }); + const directory = process.env.UTOO_SIGNAL_TEST_DIR; + if (!directory) throw new Error("Missing signal fixture directory"); + fs.writeFileSync( + path.join(directory, "flushed.json"), + JSON.stringify({ closeCalls }), + ); + if (process.env.UTOO_SIGNAL_TEST_FAIL === "1") { + throw new Error("Cache persistence failed"); + } + }, + }; +} diff --git a/packages/pack/src/__test__/serveSignals.test.ts b/packages/pack/src/__test__/serveSignals.test.ts new file mode 100644 index 000000000..6ef9808ed --- /dev/null +++ b/packages/pack/src/__test__/serveSignals.test.ts @@ -0,0 +1,117 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, "../.."); +const repoRoot = path.resolve(packageRoot, "../.."); +const viteNode = path.join(repoRoot, "node_modules/vite-node/vite-node.mjs"); + +async function runSignalsFixture( + secondSignal: "SIGTERM" | "SIGINT", + failClose = false, +) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-signals-")); + try { + fs.mkdirSync(path.join(directory, "dist")); + return await new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + flushed: { closeCalls: number } | null; + output: string; + }>((resolve, reject) => { + const child = spawn( + process.execPath, + [ + viteNode, + "--config", + path.join(testDir, "serveSignals.fixture.config.ts"), + path.join(testDir, "serveSignalsChild.ts"), + ], + { + cwd: packageRoot, + env: { + ...process.env, + UTOO_SIGNAL_TEST_DIR: directory, + UTOO_SIGNAL_TEST_FAIL: failClose ? "1" : "0", + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + let output = ""; + let release: ReturnType | undefined; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 15_000); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.on("message", (message) => { + if (message === "ready") child.kill("SIGTERM"); + if (message === "closing") { + child.kill(secondSignal); + // Keep the flush pending long enough for the second OS signal to run. + release = setTimeout(() => { + if (child.connected) child.send("release"); + }, 100); + } + }); + child.once("error", (error) => { + child.kill("SIGKILL"); + clearTimeout(timeout); + clearTimeout(release); + reject(error); + }); + child.once("close", (code, signal) => { + clearTimeout(timeout); + clearTimeout(release); + if (timedOut) { + reject(new Error(`Signal fixture timed out\n${output}`)); + return; + } + const marker = path.join(directory, "flushed.json"); + resolve({ + code, + signal, + flushed: fs.existsSync(marker) + ? JSON.parse(fs.readFileSync(marker, "utf8")) + : null, + output, + }); + }); + }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +describe.skipIf(process.platform === "win32")("dev server signals", () => { + it.each(["SIGTERM", "SIGINT"] as const)( + "finishes shutdown when %s arrives during an existing SIGTERM shutdown", + async (secondSignal) => { + const result = await runSignalsFixture(secondSignal); + + expect(result.signal, result.output).toBeNull(); + expect(result.code, result.output).toBe(0); + expect(result.flushed).toEqual({ closeCalls: 1 }); + }, + 20_000, + ); + + it("preserves a failed shutdown exit after repeated SIGTERM", async () => { + const result = await runSignalsFixture("SIGTERM", true); + + expect(result.signal, result.output).toBeNull(); + expect(result.code, result.output).toBe(1); + expect(result.flushed).toEqual({ closeCalls: 1 }); + expect(result.output).toContain("Cache persistence failed"); + }, 20_000); +}); diff --git a/packages/pack/src/__test__/serveSignalsChild.ts b/packages/pack/src/__test__/serveSignalsChild.ts new file mode 100644 index 000000000..58f1fcae3 --- /dev/null +++ b/packages/pack/src/__test__/serveSignalsChild.ts @@ -0,0 +1,13 @@ +import { serve } from "../commands/dev"; + +const directory = process.env.UTOO_SIGNAL_TEST_DIR; +if (!directory) throw new Error("Missing signal fixture directory"); + +await serve({ config: { entry: [] } }, directory, directory, { + port: 0, + hostname: "127.0.0.1", + logServerInfo: false, +}); + +// onReady runs before the signal handlers are installed; wait for serve(). +process.send?.("ready"); diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index 51ce24527..505470482 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -354,8 +354,6 @@ async function runDev( ); }; const closeResources = async () => { - process.off("SIGINT", onSignal); - process.off("SIGTERM", onSignal); process.off("rejectionHandled", rejectionHandled); process.off("uncaughtException", exception); process.off("unhandledRejection", exception); @@ -418,7 +416,11 @@ async function runDev( return hotReloader.writeAllEntrypointsToDisk(); }, close() { - closePromise ??= closeResources(); + // Keep handling forwarded signals until the pending cache flush finishes. + closePromise ??= closeResources().finally(() => { + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + }); return closePromise; }, }; From 4c0602c0610ea615823e12c17988cd2cb02ce312 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Thu, 12 Nov 2026 18:44:36 +0800 Subject: [PATCH 2/2] fix(pack): preserve forced exit on repeated SIGINT --- .../pack/src/__test__/serveSignals.test.ts | 27 +++++++++++-------- packages/pack/src/commands/dev.ts | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/pack/src/__test__/serveSignals.test.ts b/packages/pack/src/__test__/serveSignals.test.ts index 6ef9808ed..704aa278c 100644 --- a/packages/pack/src/__test__/serveSignals.test.ts +++ b/packages/pack/src/__test__/serveSignals.test.ts @@ -13,6 +13,7 @@ const viteNode = path.join(repoRoot, "node_modules/vite-node/vite-node.mjs"); async function runSignalsFixture( secondSignal: "SIGTERM" | "SIGINT", failClose = false, + firstSignal: "SIGTERM" | "SIGINT" = "SIGTERM", ) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-signals-")); try { @@ -55,7 +56,7 @@ async function runSignalsFixture( output += chunk; }); child.on("message", (message) => { - if (message === "ready") child.kill("SIGTERM"); + if (message === "ready") child.kill(firstSignal); if (message === "closing") { child.kill(secondSignal); // Keep the flush pending long enough for the second OS signal to run. @@ -94,17 +95,21 @@ async function runSignalsFixture( } describe.skipIf(process.platform === "win32")("dev server signals", () => { - it.each(["SIGTERM", "SIGINT"] as const)( - "finishes shutdown when %s arrives during an existing SIGTERM shutdown", - async (secondSignal) => { - const result = await runSignalsFixture(secondSignal); + it("finishes shutdown after repeated SIGTERM", async () => { + const result = await runSignalsFixture("SIGTERM"); - expect(result.signal, result.output).toBeNull(); - expect(result.code, result.output).toBe(0); - expect(result.flushed).toEqual({ closeCalls: 1 }); - }, - 20_000, - ); + expect(result.signal, result.output).toBeNull(); + expect(result.code, result.output).toBe(0); + expect(result.flushed).toEqual({ closeCalls: 1 }); + }, 20_000); + + it("preserves forced exit on a second SIGINT", async () => { + const result = await runSignalsFixture("SIGINT", false, "SIGINT"); + + expect(result.signal, result.output).toBe("SIGINT"); + expect(result.code, result.output).toBeNull(); + expect(result.flushed).toBeNull(); + }, 20_000); it("preserves a failed shutdown exit after repeated SIGTERM", async () => { const result = await runSignalsFixture("SIGTERM", true); diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index 505470482..041e3a532 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -354,6 +354,7 @@ async function runDev( ); }; const closeResources = async () => { + process.off("SIGINT", onSignal); process.off("rejectionHandled", rejectionHandled); process.off("uncaughtException", exception); process.off("unhandledRejection", exception); @@ -418,7 +419,6 @@ async function runDev( close() { // Keep handling forwarded signals until the pending cache flush finishes. closePromise ??= closeResources().finally(() => { - process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); }); return closePromise;