diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 886f4c46..3244c42d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb7ad58..46ac9c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/ev/src/_internal/build/dev/supervisor.ts b/packages/ev/src/_internal/build/dev/supervisor.ts index 464c6d52..d39a5f5f 100644 --- a/packages/ev/src/_internal/build/dev/supervisor.ts +++ b/packages/ev/src/_internal/build/dev/supervisor.ts @@ -543,54 +543,64 @@ export async function runDevSupervisor( (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 { 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((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((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(); diff --git a/packages/ev/tests/dev-signal-cleanup.test.ts b/packages/ev/tests/dev-signal-cleanup.test.ts new file mode 100644 index 00000000..e7c52ef5 --- /dev/null +++ b/packages/ev/tests/dev-signal-cleanup.test.ts @@ -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(child: ChildProcess): Promise { + 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"), '
'); + 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); +}); diff --git a/packages/ev/tests/fixtures/dev-signal-cleanup.mjs b/packages/ev/tests/fixtures/dev-signal-cleanup.mjs new file mode 100644 index 00000000..1fbab5fc --- /dev/null +++ b/packages/ev/tests/fixtures/dev-signal-cleanup.mjs @@ -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(); +}