From d72fcf3872f7772dc3f934aac023f05250eacd3b Mon Sep 17 00:00:00 2001 From: fireairforce <32598811+fireairforce@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:01:36 +0800 Subject: [PATCH 1/5] fix: await dev cleanup before releasing signal handlers --- CHANGELOG.md | 2 + .../ev/src/_internal/build/dev/supervisor.ts | 78 ++++++------- packages/ev/tests/dev-signal-cleanup.test.ts | 104 ++++++++++++++++++ .../ev/tests/fixtures/dev-signal-cleanup.mjs | 90 +++++++++++++++ 4 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 packages/ev/tests/dev-signal-cleanup.test.ts create mode 100644 packages/ev/tests/fixtures/dev-signal-cleanup.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb7ad58..79f92049 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 when development shutdown receives a + signal, including when plugins manage child processes. - 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..b3a25218 100644 --- a/packages/ev/src/_internal/build/dev/supervisor.ts +++ b/packages/ev/src/_internal/build/dev/supervisor.ts @@ -543,54 +543,58 @@ export async function runDevSupervisor( (error) => rejectCompletion(error), ); } - process.once("SIGINT", onSignal); - process.once("SIGTERM", 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", onSignal); + 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", onSignal); + 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..5f85b93e --- /dev/null +++ b/packages/ev/tests/dev-signal-cleanup.test.ts @@ -0,0 +1,104 @@ +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 { 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: "SIGTERM", failDispose: false }, + { signal: "SIGINT", failDispose: true }, + ] as const)("awaits disposal with execa on $signal (failure: $failDispose)", async ({ + signal, + 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, signal, 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"); + + // A repeated signal must share the in-flight cleanup, not interrupt it. + const repeated = nextMessage<{ type: string }>(child); + child.send("repeat"); + expect((await repeated).type).toBe("repeated"); + 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..1c02a84b --- /dev/null +++ b/packages/ev/tests/fixtures/dev-signal-cleanup.mjs @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execa } from "execa"; +import { createJiti } from "jiti"; + +const [cwd, signal, 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 === "repeat") { + process.emit(signal); + process.send({ type: "repeated" }); + } else 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(); +} From 0f4e84fa0347e2bfbb732194aaa80a8b662fe7a8 Mon Sep 17 00:00:00 2001 From: fireairforce <32598811+fireairforce@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:13:49 +0800 Subject: [PATCH 2/5] ci: pin browser tests to supported Ubuntu image --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 9343cf0da4e42ec762c685734d7909e9a9c14e9c Mon Sep 17 00:00:00 2001 From: fireairforce <32598811+fireairforce@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:21:02 +0800 Subject: [PATCH 3/5] test: notify webpack readiness after writing framework artifacts --- packages/bundler-webpack/tests/adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundler-webpack/tests/adapter.test.ts b/packages/bundler-webpack/tests/adapter.test.ts index 04468c90..7414c271 100644 --- a/packages/bundler-webpack/tests/adapter.test.ts +++ b/packages/bundler-webpack/tests/adapter.test.ts @@ -437,7 +437,6 @@ async function emitFrameworkArtifacts(options: { rscManifests: options.facts.rscManifests, }); frameworkRuntimeByOutput.set(output, frameworkRuntime); - await options.onBuildOutput?.(output); const rootDir = path.join(options.cwd, options.plan.distDir); const clientDir = path.resolve(options.cwd, options.plan.output.clientDir); @@ -500,6 +499,7 @@ async function emitFrameworkArtifacts(options: { await fs.writeFile(outPath, finalHtml, "utf-8"); } + await options.onBuildOutput?.(output); return output; } From c666ad84e44bfba34b879be138cb21dfa824ea05 Mon Sep 17 00:00:00 2001 From: fireairforce <32598811+fireairforce@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:25:50 +0800 Subject: [PATCH 4/5] Revert "test: notify webpack readiness after writing framework artifacts" This reverts commit 9343cf0da4e42ec762c685734d7909e9a9c14e9c. --- packages/bundler-webpack/tests/adapter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundler-webpack/tests/adapter.test.ts b/packages/bundler-webpack/tests/adapter.test.ts index 7414c271..04468c90 100644 --- a/packages/bundler-webpack/tests/adapter.test.ts +++ b/packages/bundler-webpack/tests/adapter.test.ts @@ -437,6 +437,7 @@ async function emitFrameworkArtifacts(options: { rscManifests: options.facts.rscManifests, }); frameworkRuntimeByOutput.set(output, frameworkRuntime); + await options.onBuildOutput?.(output); const rootDir = path.join(options.cwd, options.plan.distDir); const clientDir = path.resolve(options.cwd, options.plan.output.clientDir); @@ -499,7 +500,6 @@ async function emitFrameworkArtifacts(options: { await fs.writeFile(outPath, finalHtml, "utf-8"); } - await options.onBuildOutput?.(output); return output; } From fee89edb4c2fe66ddaeee06f3d4803aa104cbb8e Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Thu, 12 Nov 2026 20:26:57 +0800 Subject: [PATCH 5/5] fix: preserve forced dev exit on repeated interrupt --- CHANGELOG.md | 4 +- .../ev/src/_internal/build/dev/supervisor.ts | 10 +++- packages/ev/tests/dev-signal-cleanup.test.ts | 50 +++++++++++++------ .../ev/tests/fixtures/dev-signal-cleanup.mjs | 9 +--- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f92049..46ac9c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi ### Bug Fixes -- Wait for asynchronous plugin disposal when development shutdown receives a - signal, including when plugins manage child processes. +- 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 b3a25218..d39a5f5f 100644 --- a/packages/ev/src/_internal/build/dev/supervisor.ts +++ b/packages/ev/src/_internal/build/dev/supervisor.ts @@ -543,9 +543,15 @@ export async function runDevSupervisor( (error) => rejectCompletion(error), ); } + 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", onSignal); + process.on("SIGINT", onInterrupt); process.on("SIGTERM", onSignal); async function stop(): Promise { @@ -584,7 +590,7 @@ export async function runDevSupervisor( throw new AggregateError(errors, "[evjs] Failed to stop dev."); } } finally { - process.off("SIGINT", onSignal); + process.off("SIGINT", onInterrupt); process.off("SIGTERM", onSignal); } })(); diff --git a/packages/ev/tests/dev-signal-cleanup.test.ts b/packages/ev/tests/dev-signal-cleanup.test.ts index 5f85b93e..e7c52ef5 100644 --- a/packages/ev/tests/dev-signal-cleanup.test.ts +++ b/packages/ev/tests/dev-signal-cleanup.test.ts @@ -2,6 +2,7 @@ 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"; @@ -33,10 +34,13 @@ function nextMessage(child: ChildProcess): Promise { // Windows child.kill() terminates the process without delivering a JS signal event. describe.skipIf(process.platform === "win32")("dev signal disposal", () => { it.each([ - { signal: "SIGTERM", failDispose: false }, - { signal: "SIGINT", failDispose: true }, - ] as const)("awaits disposal with execa on $signal (failure: $failDispose)", async ({ + { 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-")); @@ -45,13 +49,9 @@ describe.skipIf(process.platform === "win32")("dev signal disposal", () => { const fixture = fileURLToPath( new URL("./fixtures/dev-signal-cleanup.mjs", import.meta.url), ); - const child = spawn( - process.execPath, - [fixture, cwd, signal, String(failDispose)], - { - stdio: ["ignore", "ignore", "pipe", "ipc"], - }, - ); + const child = spawn(process.execPath, [fixture, cwd, String(failDispose)], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); let stderr = ""; child.stderr?.on("data", (chunk) => { stderr += chunk; @@ -75,10 +75,32 @@ describe.skipIf(process.platform === "win32")("dev signal disposal", () => { child.kill(signal); expect((await disposing).type).toBe("disposing"); - // A repeated signal must share the in-flight cleanup, not interrupt it. - const repeated = nextMessage<{ type: string }>(child); - child.send("repeat"); - expect((await repeated).type).toBe("repeated"); + 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, diff --git a/packages/ev/tests/fixtures/dev-signal-cleanup.mjs b/packages/ev/tests/fixtures/dev-signal-cleanup.mjs index 1c02a84b..1fbab5fc 100644 --- a/packages/ev/tests/fixtures/dev-signal-cleanup.mjs +++ b/packages/ev/tests/fixtures/dev-signal-cleanup.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { execa } from "execa"; import { createJiti } from "jiti"; -const [cwd, signal, failDispose] = process.argv.slice(2); +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, @@ -15,12 +15,7 @@ const disposeGate = new Promise((resolve) => { releaseDispose = resolve; }); process.on("message", (message) => { - if (message === "repeat") { - process.emit(signal); - process.send({ type: "repeated" }); - } else if (message === "release") { - releaseDispose(); - } + if (message === "release") releaseDispose(); }); let disposed = 0;