From 22835fe118b5a42d907d5383fd5d31052faa5af4 Mon Sep 17 00:00:00 2001 From: Tarik Ermis Date: Sun, 9 Aug 2026 09:10:02 +0200 Subject: [PATCH 1/3] fix(node): call `close` hooks on server shutdown Restore v2 behavior for the `node_server` and `node_cluster` presets: run runtime `close` hooks when srvx closes the server on SIGINT/SIGTERM. Resolves #4502 --- src/presets/node/runtime/node-cluster.ts | 18 +++++++- src/presets/node/runtime/node-server.ts | 18 +++++++- test/fixture/server/plugins/close.ts | 9 ++++ test/presets/node.test.ts | 54 +++++++++++++++++++++++- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 test/fixture/server/plugins/close.ts diff --git a/src/presets/node/runtime/node-cluster.ts b/src/presets/node/runtime/node-cluster.ts index e9bc592c21..818a174c3f 100644 --- a/src/presets/node/runtime/node-cluster.ts +++ b/src/presets/node/runtime/node-cluster.ts @@ -3,7 +3,7 @@ import cluster from "node:cluster"; import { NodeRequest, serve } from "srvx/node"; import wsAdapter from "crossws/adapters/node"; -import { useNitroApp } from "nitro/app"; +import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; @@ -32,6 +32,22 @@ const server = serve({ fetch: nitroApp.fetch, }); +// Run `close` hooks on server shutdown (srvx closes the server on `SIGINT`/`SIGTERM`) +const closeServer = server.close.bind(server); +let closeHooksCalled = false; +server.close = async (closeActiveConnections?: boolean) => { + try { + await closeServer(closeActiveConnections); + } finally { + if (!closeHooksCalled) { + closeHooksCalled = true; + await useNitroHooks() + .callHook("close") + ?.catch((error) => console.error("[close]", error)); + } + } +}; + if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); server.node!.server!.on("upgrade", (req, socket, head) => { diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 1d513d65a4..06f9b0299f 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -2,7 +2,7 @@ import "#nitro/virtual/polyfills"; import { NodeRequest, serve } from "srvx/node"; import wsAdapter from "crossws/adapters/node"; -import { useNitroApp } from "nitro/app"; +import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; @@ -26,6 +26,22 @@ const server = serve({ plugins: [...tracingSrvxPlugins], }); +// Run `close` hooks on server shutdown (srvx closes the server on `SIGINT`/`SIGTERM`) +const closeServer = server.close.bind(server); +let closeHooksCalled = false; +server.close = async (closeActiveConnections?: boolean) => { + try { + await closeServer(closeActiveConnections); + } finally { + if (!closeHooksCalled) { + closeHooksCalled = true; + await useNitroHooks() + .callHook("close") + ?.catch((error) => console.error("[close]", error)); + } + } +}; + if (import.meta._websocket) { const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); server.node!.server!.on("upgrade", (req, socket, head) => { diff --git a/test/fixture/server/plugins/close.ts b/test/fixture/server/plugins/close.ts new file mode 100644 index 0000000000..5bdfc72e04 --- /dev/null +++ b/test/fixture/server/plugins/close.ts @@ -0,0 +1,9 @@ +import { definePlugin } from "nitro"; + +export default definePlugin((nitroApp) => { + nitroApp.hooks.hook("close", () => { + if (globalThis.process?.env?.NITRO_TEST_CLOSE_HOOK) { + console.log("[fixture] close hook called"); + } + }); +}); diff --git a/test/presets/node.test.ts b/test/presets/node.test.ts index e6d7d68c5a..915c6e4202 100644 --- a/test/presets/node.test.ts +++ b/test/presets/node.test.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { resolve } from "pathe"; -// import { isWindows } from "std-env"; +import { isWindows } from "std-env"; +import { execa } from "execa"; +import { getRandomPort, waitForPort } from "get-port-please"; import { describe, expect, it } from "vitest"; import { setupTest, startServer, testNitro } from "../tests.ts"; @@ -38,3 +40,53 @@ describe("nitro:preset:node-middleware", async () => { expect(existsSync(resolve(serverNodeModules, "@fixture/nitro-utils/extra.mjs"))).toBe(true); }); }); + +describe("nitro:preset:node-server", async () => { + const ctx = await setupTest("node-server"); + + it.skipIf(isWindows)( + "calls the `close` hook on shutdown", + async () => { + const port = await getRandomPort(); + const entryPath = resolve(ctx.outDir, "server/index.mjs"); + // srvx graceful shutdown is disabled when the CI/TEST env vars are set + const env: Record = { + ...process.env, + NITRO_PORT: String(port), + NITRO_HOST: "127.0.0.1", + NITRO_TEST_CLOSE_HOOK: "true", + }; + delete env.CI; + delete env.TEST; + const child = execa(process.execPath, [entryPath], { env, extendEnv: false, reject: false }); + + let output = ""; + child.stdout!.on("data", (data) => (output += data)); + child.stderr!.on("data", (data) => (output += data)); + + await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" }); + + child.kill("SIGTERM"); + // Wait for the close hook marker or process exit (the fixture task scheduler + // can keep the event loop alive after the server closed) + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 10_000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + child.stdout!.on("data", (data) => { + if (String(data).includes("[fixture] close hook called")) { + clearTimeout(timeout); + resolve(); + } + }); + }); + child.kill("SIGKILL"); + + expect(output).toContain("[fixture] close hook called"); + expect(output).not.toContain("unhandledRejection"); + }, + 40_000 + ); +}); From cee4697d316b603a5931ecffe6fd5ed40aeb16db Mon Sep 17 00:00:00 2001 From: Tarik Ermis Date: Sat, 22 Aug 2026 08:57:42 +0200 Subject: [PATCH 2/3] test(node): use execa native process events --- test/presets/node.test.ts | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/test/presets/node.test.ts b/test/presets/node.test.ts index 915c6e4202..871c66e403 100644 --- a/test/presets/node.test.ts +++ b/test/presets/node.test.ts @@ -64,28 +64,33 @@ describe("nitro:preset:node-server", async () => { child.stdout!.on("data", (data) => (output += data)); child.stderr!.on("data", (data) => (output += data)); - await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" }); + try { + await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" }); - child.kill("SIGTERM"); - // Wait for the close hook marker or process exit (the fixture task scheduler - // can keep the event loop alive after the server closed) - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 10_000); - child.on("close", () => { - clearTimeout(timeout); - resolve(); - }); - child.stdout!.on("data", (data) => { - if (String(data).includes("[fixture] close hook called")) { + child.kill("SIGTERM"); + await new Promise((resolve) => { + const done = () => { clearTimeout(timeout); + child.nodeChildProcess.off("close", done); + child.stdout!.off("data", onData); resolve(); - } + }; + const onData = (data: unknown) => { + if (String(data).includes("[fixture] close hook called")) { + done(); + } + }; + const timeout = setTimeout(done, 10_000); + child.nodeChildProcess.once("close", done); + child.stdout!.on("data", onData); }); - }); - child.kill("SIGKILL"); - expect(output).toContain("[fixture] close hook called"); - expect(output).not.toContain("unhandledRejection"); + expect(output).toContain("[fixture] close hook called"); + expect(output).not.toContain("unhandledRejection"); + } finally { + child.kill("SIGKILL"); + await child; + } }, 40_000 ); From d510c28f8f95eb574533c32845cf53e19ff786ff Mon Sep 17 00:00:00 2001 From: Tarik Ermis Date: Sat, 29 Aug 2026 16:08:02 +0200 Subject: [PATCH 3/3] test(node): prove close hooks are awaited before shutdown completes Wait for the child process close event before cleanup instead of resolving on the fixture marker, so SIGKILL cannot cut graceful shutdown short. The fixture close hook is now deliberately async: the marker only appears before process exit when shutdown awaits the hook. Addresses the coderabbit review on #4522 --- test/fixture/server/plugins/close.ts | 5 ++++- test/presets/node.test.ts | 12 +++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/fixture/server/plugins/close.ts b/test/fixture/server/plugins/close.ts index 5bdfc72e04..dccfd7176f 100644 --- a/test/fixture/server/plugins/close.ts +++ b/test/fixture/server/plugins/close.ts @@ -1,8 +1,11 @@ import { definePlugin } from "nitro"; export default definePlugin((nitroApp) => { - nitroApp.hooks.hook("close", () => { + nitroApp.hooks.hook("close", async () => { if (globalThis.process?.env?.NITRO_TEST_CLOSE_HOOK) { + // Deliberately async: the shutdown test asserts the marker is printed + // before the process exits, which only holds when `close` hooks are awaited + await new Promise((resolve) => setTimeout(resolve, 250)); console.log("[fixture] close hook called"); } }); diff --git a/test/presets/node.test.ts b/test/presets/node.test.ts index 871c66e403..0e4f0b048c 100644 --- a/test/presets/node.test.ts +++ b/test/presets/node.test.ts @@ -68,21 +68,19 @@ describe("nitro:preset:node-server", async () => { await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" }); child.kill("SIGTERM"); + // Wait for the process to actually close before cleanup, so SIGKILL + // cannot cut graceful shutdown short and the marker assertion below + // runs after closure. The fixture task scheduler can keep the event + // loop alive after the server closed, so a missing close event falls + // through after 10s. await new Promise((resolve) => { const done = () => { clearTimeout(timeout); child.nodeChildProcess.off("close", done); - child.stdout!.off("data", onData); resolve(); }; - const onData = (data: unknown) => { - if (String(data).includes("[fixture] close hook called")) { - done(); - } - }; const timeout = setTimeout(done, 10_000); child.nodeChildProcess.once("close", done); - child.stdout!.on("data", onData); }); expect(output).toContain("[fixture] close hook called");