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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
jobs:
ci:
name: Build & Test
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout Repo
uses: actions/checkout@v6
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi

### Bug Fixes

- Wait for asynchronous plugin disposal on development shutdown, including
repeated SIGTERM signals, while preserving forced exit on a second Ctrl+C.
- Refresh Utoopack development HTML and asset metadata after client rebuilds
change entry chunks, so new visits and browser reloads load the latest code
without restarting the development server. Preserve the native server-build
Expand Down
84 changes: 47 additions & 37 deletions packages/ev/src/_internal/build/dev/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,54 +543,64 @@ export async function runDevSupervisor<TBundlerCfg>(
(error) => rejectCompletion(error),
);
}
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
let interrupted = false;
function onInterrupt(): void {
if (interrupted) process.exit(130);
interrupted = true;
onSignal();
}
// Keep ownership of the signal until asynchronous disposal has finished.
// Child-process exit handlers may otherwise restore the signal's default exit.
process.on("SIGINT", onInterrupt);
process.on("SIGTERM", onSignal);

async function stop(): Promise<void> {
stopPromise ??= (async () => {
stopping = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = undefined;
watcher?.stop();
watcher = undefined;
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
while (reconcileRunning) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
const errors: unknown[] = [];
try {
await detachCLIShortcuts(0);
} catch (error) {
errors.push(error);
}
const current = active;
active = undefined;
try {
await current?.session.close();
} catch (error) {
errors.push(error);
}
try {
await ports?.release();
} catch (error) {
errors.push(error);
stopping = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = undefined;
watcher?.stop();
watcher = undefined;
while (reconcileRunning) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
const errors: unknown[] = [];
try {
await detachCLIShortcuts(0);
} catch (error) {
errors.push(error);
}
const current = active;
active = undefined;
try {
await current?.session.close();
} catch (error) {
errors.push(error);
}
try {
await ports?.release();
} catch (error) {
errors.push(error);
} finally {
unregisterPortsExitCleanup();
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, "[evjs] Failed to stop dev.");
}
} finally {
unregisterPortsExitCleanup();
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, "[evjs] Failed to stop dev.");
process.off("SIGINT", onInterrupt);
process.off("SIGTERM", onSignal);
}
})();
return stopPromise;
}

refreshWatcher(fixedDependencies);
reconcileRequested = true;
requestReconcile();

try {
refreshWatcher(fixedDependencies);
reconcileRequested = true;
requestReconcile();
await completion;
} finally {
if (!stopping) await stop();
Expand Down
126 changes: 126 additions & 0 deletions packages/ev/tests/dev-signal-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { type ChildProcess, spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

function killOwnedProcess(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}

function nextMessage<T>(child: ChildProcess): Promise<T> {
return new Promise((resolve, reject) => {
const message = (value: T) => {
child.off("exit", exited);
resolve(value);
};
const exited = (code: number | null, signal: NodeJS.Signals | null) => {
child.off("message", message);
reject(
new Error(`Dev exited before completing disposal: ${signal ?? code}`),
);
};
child.once("message", message);
child.once("exit", exited);
});
}

// Windows child.kill() terminates the process without delivering a JS signal event.
describe.skipIf(process.platform === "win32")("dev signal disposal", () => {
it.each([
{ signal: "SIGINT", repeatedSignal: null, failDispose: false },
{ signal: "SIGTERM", repeatedSignal: "SIGTERM", failDispose: false },
{ signal: "SIGTERM", repeatedSignal: "SIGTERM", failDispose: true },
{ signal: "SIGINT", repeatedSignal: "SIGINT", failDispose: false },
] as const)("handles $signal then $repeatedSignal with execa (failure: $failDispose)", async ({
signal,
repeatedSignal,
failDispose,
}) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "evjs-dev-signal-"));
await fs.writeFile(path.join(cwd, "package.json"), '{"private":true}');
await fs.writeFile(path.join(cwd, "index.html"), '<div id="app"></div>');
const fixture = fileURLToPath(
new URL("./fixtures/dev-signal-cleanup.mjs", import.meta.url),
);
const child = spawn(process.execPath, [fixture, cwd, String(failDispose)], {
stdio: ["ignore", "ignore", "pipe", "ipc"],
});
let stderr = "";
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const exited = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve) =>
child.once("exit", (code, exitSignal) =>
resolve({ code, signal: exitSignal }),
),
);
let resourcePid: number | undefined;
try {
const ready = await nextMessage<{ type: string; resourcePid: number }>(
child,
);
expect(ready.type, stderr).toBe("ready");
resourcePid = ready.resourcePid;
const disposing = nextMessage<{ type: string }>(child);
child.kill(signal);
expect((await disposing).type).toBe("disposing");

if (repeatedSignal) child.kill(repeatedSignal);
if (repeatedSignal === "SIGINT") {
expect(await exited, stderr).toEqual({ code: 130, signal: null });
await expect(
fs.readFile(path.join(cwd, "disposed"), "utf8"),
).rejects.toMatchObject({ code: "ENOENT" });
// Forced exit still invokes execa's process-exit child cleanup.
await expect
.poll(() => {
try {
process.kill(ready.resourcePid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH")
throw error;
return false;
}
})
.toBe(false);
return;
}

// Leave disposal pending long enough to observe a repeated OS signal.
if (repeatedSignal) await delay(100);
expect(child.exitCode, stderr).toBeNull();
expect(child.signalCode, stderr).toBeNull();
child.send("release");
expect(await exited, stderr).toEqual({
code: failDispose ? 1 : 0,
signal: null,
});
expect(await fs.readFile(path.join(cwd, "disposed"), "utf8")).toBe(
"complete",
);
const closed = JSON.parse(
await fs.readFile(path.join(cwd, "closed.json"), "utf8"),
);
expect(closed.disposed).toBe(1);
expect(closed.finalListeners).toEqual(closed.initialListeners);
expect(() => process.kill(ready.resourcePid, 0)).toThrow();
} finally {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
if (resourcePid) killOwnedProcess(resourcePid);
await exited;
await fs.rm(cwd, { recursive: true, force: true });
}
}, 15_000);
});
85 changes: 85 additions & 0 deletions packages/ev/tests/fixtures/dev-signal-cleanup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execa } from "execa";
import { createJiti } from "jiti";

const [cwd, failDispose] = process.argv.slice(2);
const jiti = createJiti(import.meta.url);
const { dev } = await jiti.import(
new URL("../../src/_internal/build/commands.ts", import.meta.url).href,
);
const signals = ["SIGINT", "SIGTERM"];
const initialListeners = signals.map((name) => process.listenerCount(name));
let releaseDispose;
const disposeGate = new Promise((resolve) => {
releaseDispose = resolve;
});
process.on("message", (message) => {
if (message === "release") releaseDispose();
});

let disposed = 0;
let resolveBundler;
const done = new Promise((resolve) => {
resolveBundler = resolve;
});
try {
await dev(
{
dev: { cliShortcuts: false },
plugins: [
{
id: "async-resource",
setup() {
const resource = execa(process.execPath, [
"-e",
"setTimeout(() => {}, 30_000)",
]);
resource.catch(() => {});
return {
devServerReady() {
process.send({ type: "ready", resourcePid: resource.pid });
},
async dispose() {
disposed += 1;
process.send({ type: "disposing" });
await disposeGate;
resource.kill();
await resource.catch(() => {});
await fs.writeFile(path.join(cwd, "disposed"), "complete");
if (failDispose === "true")
throw new Error("resource close failed");
},
};
},
},
],
},
{
cwd,
bundler: {
name: "signal-test",
capabilities: { build: { server: true, rsc: true, ppr: true } },
async build() {
return {};
},
async dev() {
return { origin: "http://localhost", done, close: resolveBundler };
},
},
},
);
} catch (error) {
process.stderr.write(`${error.stack ?? error}\n`);
process.exitCode = 1;
} finally {
await fs.writeFile(
path.join(cwd, "closed.json"),
JSON.stringify({
disposed,
initialListeners,
finalListeners: signals.map((name) => process.listenerCount(name)),
}),
);
process.disconnect();
}