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..704aa278c --- /dev/null +++ b/packages/pack/src/__test__/serveSignals.test.ts @@ -0,0 +1,122 @@ +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, + firstSignal: "SIGTERM" | "SIGINT" = "SIGTERM", +) { + 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(firstSignal); + 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("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); + + 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); + + 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..041e3a532 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -355,7 +355,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 +417,10 @@ async function runDev( return hotReloader.writeAllEntrypointsToDisk(); }, close() { - closePromise ??= closeResources(); + // Keep handling forwarded signals until the pending cache flush finishes. + closePromise ??= closeResources().finally(() => { + process.off("SIGTERM", onSignal); + }); return closePromise; }, };