Skip to content
Open
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
48 changes: 48 additions & 0 deletions packages/pack/src/__test__/serveSignals.fixture.config.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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");
}
},
};
}
122 changes: 122 additions & 0 deletions packages/pack/src/__test__/serveSignals.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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);
});
13 changes: 13 additions & 0 deletions packages/pack/src/__test__/serveSignalsChild.ts
Original file line number Diff line number Diff line change
@@ -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");
6 changes: 4 additions & 2 deletions packages/pack/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
},
};
Expand Down
Loading