From cd7850a6a7db921ab390079e1827ee38d4499784 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 16:48:11 -0600 Subject: [PATCH 01/19] feat: implement NITRO_UNIX_SOCKET and graceful shutdown env vars Wire NITRO_UNIX_SOCKET, NITRO_SHUTDOWN_DISABLED, and NITRO_SHUTDOWN_TIMEOUT through to srvx serve options in the Node, Bun, and Deno server presets. Co-authored-by: Cursor --- docs/2.deploy/10.runtimes/1.node.md | 8 +++----- src/presets/bun/runtime/bun.ts | 12 +++++++++++- src/presets/deno/runtime/deno-server.ts | 10 +++++++++- src/presets/node/runtime/node-server.ts | 12 +++++++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 475b4a0eae..4b18658b4e 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -31,12 +31,10 @@ You can customize server behavior using following environment variables: - `NITRO_PORT` or `PORT` (defaults to `3000`) - `NITRO_HOST` or `HOST` -- `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket. +- `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket (Node.js and Bun only). - `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL. -- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. If it's set to `'true'`, the graceful shutdown is bypassed to speed up the development process. Defaults to `'false'`. -- `NITRO_SHUTDOWN_SIGNALS` - Allows you to specify which signals should be handled. Each signal should be separated with a space. Defaults to `'SIGINT SIGTERM'`. -- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `'30000'` milliseconds. -- `NITRO_SHUTDOWN_FORCE` - When set to true, it triggers `process.exit()` at the end of the shutdown process. If it's set to `'false'`, the process will simply let the event loop clear. Defaults to `'true'`. +- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `5000` milliseconds. ## Cluster mode diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 7407e9e625..d01c9d3a35 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -13,7 +13,15 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO +const socketPath = process.env.NITRO_UNIX_SOCKET; + +const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); +const gracefulShutdown = + process.env.NITRO_SHUTDOWN_DISABLED === "true" + ? false + : _shutdownTimeout > 0 + ? { gracefulTimeout: _shutdownTimeout / 1000 } + : undefined; const nitroApp = useNitroApp(); @@ -35,7 +43,9 @@ serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: _fetch, + gracefulShutdown, bun: { + unix: socketPath, websocket: import.meta._websocket ? ws?.websocket : undefined, }, }); diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index 005af67e43..75c9899263 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -14,7 +14,14 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO + +const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); +const gracefulShutdown = + process.env.NITRO_SHUTDOWN_DISABLED === "true" + ? false + : _shutdownTimeout > 0 + ? { gracefulTimeout: _shutdownTimeout / 1000 } + : undefined; const nitroApp = useNitroApp(); @@ -35,6 +42,7 @@ serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: _fetch, + gracefulShutdown, }); trapUnhandledErrors(); diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 6356f4dc42..6d34676222 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -13,7 +13,15 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; -// const socketPath = process.env.NITRO_UNIX_SOCKET; // TODO +const socketPath = process.env.NITRO_UNIX_SOCKET; + +const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); +const gracefulShutdown = + process.env.NITRO_SHUTDOWN_DISABLED === "true" + ? false + : _shutdownTimeout > 0 + ? { gracefulTimeout: _shutdownTimeout / 1000 } + : undefined; const nitroApp = useNitroApp(); @@ -22,6 +30,8 @@ const server = serve({ hostname: host, tls: cert && key ? { cert, key } : undefined, fetch: nitroApp.fetch, + gracefulShutdown, + node: socketPath ? { path: socketPath } : undefined, }); if (import.meta._websocket) { From 4218d87820db380f410e64786a70626dccb3a708 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 16:48:39 -0600 Subject: [PATCH 02/19] docs: add env var documentation to bun and deno runtime pages Co-authored-by: Cursor --- docs/2.deploy/10.runtimes/bun.md | 11 +++++++++++ docs/2.deploy/10.runtimes/deno.md | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/2.deploy/10.runtimes/bun.md b/docs/2.deploy/10.runtimes/bun.md index e619cdadeb..140d64b5b4 100644 --- a/docs/2.deploy/10.runtimes/bun.md +++ b/docs/2.deploy/10.runtimes/bun.md @@ -17,3 +17,14 @@ bun run ./.output/server/index.mjs ``` :read-more{to="https://bun.sh"} + +### Environment Variables + +You can customize server behavior using following environment variables: + +- `NITRO_PORT` or `PORT` (defaults to `3000`) +- `NITRO_HOST` or `HOST` +- `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket. +- `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL. +- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `3000` milliseconds. diff --git a/docs/2.deploy/10.runtimes/deno.md b/docs/2.deploy/10.runtimes/deno.md index cc8a0cfdf5..9613ac4ab4 100644 --- a/docs/2.deploy/10.runtimes/deno.md +++ b/docs/2.deploy/10.runtimes/deno.md @@ -18,6 +18,16 @@ NITRO_PRESET=deno_server npm run build deno run --unstable --allow-net --allow-read --allow-env .output/server/index.ts ``` +### Environment Variables + +You can customize server behavior using following environment variables: + +- `NITRO_PORT` or `PORT` (defaults to `3000`) +- `NITRO_HOST` or `HOST` +- `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL. +- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`. +- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `3000` milliseconds. + ## Deno Deploy :read-more{to="/deploy/providers/deno-deploy"} From 34820648d5efe70e1bdcf8f6e7b118c03bd92172 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 18:39:45 -0600 Subject: [PATCH 03/19] docs(node): fix handler preset name and export The Handler (advanced) section referenced preset `node` and export `listener`, but `node` is an alias for `node-server` (not the middleware handler). The actual preset name is `node-middleware` and the export is `middleware`. Co-authored-by: Cursor --- docs/2.deploy/10.runtimes/1.node.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 4b18658b4e..71b374770e 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -50,18 +50,18 @@ In addition to environment variables from the `node_server` preset, you can cust ## Handler (advanced) -**Preset:** `node` +**Preset:** `node_middleware` -Nitro also has a more low-level preset that directly exports a function with `(req, res) => {}` signature usable for middleware and custom servers. +Nitro also has a more low-level preset that directly exports a Node.js compatible handler usable for middleware and custom servers. -When running `nitro build` with the Node preset, the result will be an entry point exporting a function with the `(req, res) => {}` signature. +When running `nitro build` with the Node middleware preset, the result will be an entry point exporting a `middleware` handler. **Example:** ```js import { createServer } from 'node:http' -import { listener } from './.output/server' +import { middleware } from './.output/server' -const server = createServer(listener) +const server = createServer(middleware) server.listen(8080) ``` From 853f67f174822d0d10d1d07958a0c26deb3f7543 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 19:14:41 -0600 Subject: [PATCH 04/19] fix: call runtime `close` hook on shutdown signals Bridge SIGTERM/SIGINT to `nitroApp.hooks.callHook("close")` so plugins can run async cleanup (flush telemetry, drain connections, stop queues) when the server shuts down. The close hook stopped firing after the srvx migration in #3705 removed the old `setupGracefulShutdown` machinery. srvx handles HTTP-level shutdown (connection draining) but never calls Nitro's application-level close hook. Adds `setupShutdownHooks()` utility following the same pattern as `trapUnhandledErrors()` and wires it into node-server, node-cluster, bun, and deno-server runtime entries. Resolves #4015 Resolves #2735 Resolves #2566 Co-authored-by: Cursor --- docs/1.docs/50.plugins.md | 20 ++++++++-- docs/2.deploy/10.runtimes/1.node.md | 5 +-- src/presets/bun/runtime/bun.ts | 2 + src/presets/deno/runtime/deno-server.ts | 2 + src/presets/node/runtime/node-cluster.ts | 2 + src/presets/node/runtime/node-server.ts | 2 + src/runtime/internal/shutdown.ts | 10 +++++ test/unit/shutdown.test.ts | 49 ++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/runtime/internal/shutdown.ts create mode 100644 test/unit/shutdown.test.ts diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 5b568167e4..135b6b1433 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -54,9 +54,10 @@ export default definePlugin((nitro) => { ### Available hooks -- `"request", (event) => {}` -- `"error", (error, { event? }) => {}` -- `"response", (event, { body }) => {}` +- `"request", (event) => {}` - Called when a request is received. +- `"error", (error, { event? }) => {}` - Called when an error is captured. +- `"response", (response, event) => {}` - Called when a response is sent. +- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). ## Examples @@ -76,7 +77,18 @@ export default definePlugin((nitro) => { ### Graceful shutdown -Server will gracefully shutdown and wait for any background pending tasks initiated by event.waitUntil +When the server receives a shutdown signal (`SIGTERM` or `SIGINT`), the `close` hook is called, allowing plugins to run async cleanup before the process exits. This is useful for flushing telemetry, draining database connections, stopping job queues, and other teardown tasks. + +```ts +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("close", async () => { + await flushTelemetry(); + await db.close(); + }); +}) +``` ### Request and response lifecycle diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 475b4a0eae..b9099a81e0 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -33,10 +33,7 @@ You can customize server behavior using following environment variables: - `NITRO_HOST` or `HOST` - `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket. - `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL. -- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. If it's set to `'true'`, the graceful shutdown is bypassed to speed up the development process. Defaults to `'false'`. -- `NITRO_SHUTDOWN_SIGNALS` - Allows you to specify which signals should be handled. Each signal should be separated with a space. Defaults to `'SIGINT SIGTERM'`. -- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `'30000'` milliseconds. -- `NITRO_SHUTDOWN_FORCE` - When set to true, it triggers `process.exit()` at the end of the shutdown process. If it's set to `'false'`, the process will simply let the event loop clear. Defaults to `'true'`. +- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`. ## Cluster mode diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 7407e9e625..bc90a4c210 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -41,6 +42,7 @@ serve({ }); trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index 005af67e43..a06cda5b40 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/deno"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -38,6 +39,7 @@ serve({ }); trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/node/runtime/node-cluster.ts b/src/presets/node/runtime/node-cluster.ts index 0687360c44..f24a051268 100644 --- a/src/presets/node/runtime/node-cluster.ts +++ b/src/presets/node/runtime/node-cluster.ts @@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -46,6 +47,7 @@ if (import.meta._websocket) { } trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 6356f4dc42..d19e9de2a5 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -5,6 +5,7 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; +import { setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -38,6 +39,7 @@ if (import.meta._websocket) { } trapUnhandledErrors(); +setupShutdownHooks(); // Scheduled tasks if (import.meta._tasks) { diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts new file mode 100644 index 0000000000..81e37b7fe6 --- /dev/null +++ b/src/runtime/internal/shutdown.ts @@ -0,0 +1,10 @@ +import { useNitroApp } from "../app.ts"; + +export function setupShutdownHooks() { + const handler = () => { + useNitroApp().hooks?.callHook("close"); + }; + for (const sig of ["SIGTERM", "SIGINT"] as const) { + process.on(sig, handler); + } +} diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts new file mode 100644 index 0000000000..aefbe4f6c4 --- /dev/null +++ b/test/unit/shutdown.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const callHook = vi.fn(); + +vi.mock("../../src/runtime/internal/app.ts", () => ({ + useNitroApp: () => ({ + hooks: { callHook }, + }), +})); + +import { setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts"; + +describe("setupShutdownHooks", () => { + let savedSIGTERM: Function[]; + let savedSIGINT: Function[]; + + beforeEach(() => { + savedSIGTERM = process.listeners("SIGTERM").slice(); + savedSIGINT = process.listeners("SIGINT").slice(); + callHook.mockClear(); + }); + + afterEach(() => { + process.removeAllListeners("SIGTERM"); + process.removeAllListeners("SIGINT"); + for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener); + for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener); + }); + + it("registers SIGTERM and SIGINT handlers", () => { + const beforeTERM = process.listenerCount("SIGTERM"); + const beforeINT = process.listenerCount("SIGINT"); + setupShutdownHooks(); + expect(process.listenerCount("SIGTERM")).toBe(beforeTERM + 1); + expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1); + }); + + it("calls close hook on SIGTERM", () => { + setupShutdownHooks(); + process.emit("SIGTERM", "SIGTERM"); + expect(callHook).toHaveBeenCalledWith("close"); + }); + + it("calls close hook on SIGINT", () => { + setupShutdownHooks(); + process.emit("SIGINT", "SIGINT"); + expect(callHook).toHaveBeenCalledWith("close"); + }); +}); From ea4235a3cb72531ee417e6fe7d7f534c727d5509 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 19:26:02 -0600 Subject: [PATCH 05/19] docs: clarify close hook availability across preset types Note that the close hook only fires on long-running server presets (node, bun, deno) and not in serverless/edge environments. Guide plugin authors toward the response hook or waitUntil for per-request cleanup that works across all presets. Co-authored-by: Cursor --- docs/1.docs/50.plugins.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/1.docs/50.plugins.md b/docs/1.docs/50.plugins.md index 135b6b1433..e206c86aff 100644 --- a/docs/1.docs/50.plugins.md +++ b/docs/1.docs/50.plugins.md @@ -54,10 +54,10 @@ export default definePlugin((nitro) => { ### Available hooks -- `"request", (event) => {}` - Called when a request is received. -- `"error", (error, { event? }) => {}` - Called when an error is captured. -- `"response", (response, event) => {}` - Called when a response is sent. -- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). +- `"request", (event) => {}` - Called when a request is received. Available in all presets. +- `"error", (error, { event? }) => {}` - Called when an error is captured. Available in all presets. +- `"response", (response, event) => {}` - Called when a response is sent. Available in all presets. +- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`). Only available in long-running server presets (Node.js, Bun, Deno). Not called in serverless or edge environments. ## Examples @@ -77,7 +77,7 @@ export default definePlugin((nitro) => { ### Graceful shutdown -When the server receives a shutdown signal (`SIGTERM` or `SIGINT`), the `close` hook is called, allowing plugins to run async cleanup before the process exits. This is useful for flushing telemetry, draining database connections, stopping job queues, and other teardown tasks. +On long-running server presets (`node-server`, `node-cluster`, `bun`, `deno-server`), the `close` hook fires when the process receives `SIGTERM` or `SIGINT`, allowing plugins to run async cleanup before exit. ```ts import { definePlugin } from "nitro"; @@ -90,6 +90,20 @@ export default definePlugin((nitro) => { }) ``` +Serverless and edge runtimes (Cloudflare Workers, AWS Lambda, Vercel, Netlify, Deno Deploy) do not have a shutdown signal–the platform terminates the execution context without notice. The `close` hook will not fire in these environments. + +For per-request cleanup that works across all presets, use the `"response"` hook or `request.waitUntil()` instead: + +```ts +import { definePlugin } from "nitro"; + +export default definePlugin((nitro) => { + nitro.hooks.hook("response", async (response, event) => { + await flushRequestTelemetry(event); + }); +}) +``` + ### Request and response lifecycle You can use plugins to register a hook that can run on request lifecycle: From 77f90e448b88fb9969d9668288f4a8265c84f471 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 19:45:41 -0600 Subject: [PATCH 06/19] revert: remove unrelated node middleware handler docs change Co-authored-by: Cursor --- docs/2.deploy/10.runtimes/1.node.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/2.deploy/10.runtimes/1.node.md b/docs/2.deploy/10.runtimes/1.node.md index 71b374770e..4b18658b4e 100644 --- a/docs/2.deploy/10.runtimes/1.node.md +++ b/docs/2.deploy/10.runtimes/1.node.md @@ -50,18 +50,18 @@ In addition to environment variables from the `node_server` preset, you can cust ## Handler (advanced) -**Preset:** `node_middleware` +**Preset:** `node` -Nitro also has a more low-level preset that directly exports a Node.js compatible handler usable for middleware and custom servers. +Nitro also has a more low-level preset that directly exports a function with `(req, res) => {}` signature usable for middleware and custom servers. -When running `nitro build` with the Node middleware preset, the result will be an entry point exporting a `middleware` handler. +When running `nitro build` with the Node preset, the result will be an entry point exporting a function with the `(req, res) => {}` signature. **Example:** ```js import { createServer } from 'node:http' -import { middleware } from './.output/server' +import { listener } from './.output/server' -const server = createServer(middleware) +const server = createServer(listener) server.listen(8080) ``` From 70627b2bc5269dc3202b578a388d80705251452d Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:31:47 -0600 Subject: [PATCH 07/19] fix: make shutdown hook handler async with error handling Await callHook("close") so async cleanup (telemetry flush, db close, etc.) completes before the process exits. Catch and log errors so a failing hook doesn't silently swallow the exception. Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 8 ++++++-- test/unit/shutdown.test.ts | 27 ++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index 81e37b7fe6..74dd528f76 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -1,8 +1,12 @@ import { useNitroApp } from "../app.ts"; export function setupShutdownHooks() { - const handler = () => { - useNitroApp().hooks?.callHook("close"); + const handler = async () => { + try { + await useNitroApp().hooks?.callHook("close"); + } catch (error) { + console.error("[nitro] Error running close hook:", error); + } }; for (const sig of ["SIGTERM", "SIGINT"] as const) { process.on(sig, handler); diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index aefbe4f6c4..64d0b40560 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const callHook = vi.fn(); +const callHook = vi.fn().mockResolvedValue(undefined); vi.mock("../../src/runtime/internal/app.ts", () => ({ useNitroApp: () => ({ @@ -18,6 +18,7 @@ describe("setupShutdownHooks", () => { savedSIGTERM = process.listeners("SIGTERM").slice(); savedSIGINT = process.listeners("SIGINT").slice(); callHook.mockClear(); + callHook.mockResolvedValue(undefined); }); afterEach(() => { @@ -35,15 +36,31 @@ describe("setupShutdownHooks", () => { expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1); }); - it("calls close hook on SIGTERM", () => { + it("calls close hook on SIGTERM", async () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); - expect(callHook).toHaveBeenCalledWith("close"); + await vi.waitFor(() => { + expect(callHook).toHaveBeenCalledWith("close"); + }); }); - it("calls close hook on SIGINT", () => { + it("calls close hook on SIGINT", async () => { setupShutdownHooks(); process.emit("SIGINT", "SIGINT"); - expect(callHook).toHaveBeenCalledWith("close"); + await vi.waitFor(() => { + expect(callHook).toHaveBeenCalledWith("close"); + }); + }); + + it("logs error if close hook throws", async () => { + const error = new Error("cleanup failed"); + callHook.mockRejectedValueOnce(error); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setupShutdownHooks(); + process.emit("SIGTERM", "SIGTERM"); + await vi.waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); + }); + consoleSpy.mockRestore(); }); }); From 6261cd61b2af22d1e324a21624146ab326a28bb3 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:32:53 -0600 Subject: [PATCH 08/19] refactor: extract resolveGracefulShutdownConfig helper Lift the duplicated NITRO_SHUTDOWN_DISABLED / NITRO_SHUTDOWN_TIMEOUT env var parsing into a shared helper in the shutdown module. All three server presets (node, bun, deno) now import it instead of inlining the same logic. Co-authored-by: Cursor --- src/presets/bun/runtime/bun.ts | 11 ++----- src/presets/deno/runtime/deno-server.ts | 11 ++----- src/presets/node/runtime/node-server.ts | 11 ++----- src/runtime/internal/shutdown.ts | 9 ++++++ test/unit/shutdown.test.ts | 39 ++++++++++++++++++++++++- 5 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 2d2ea67f0a..3aad3a3c67 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -6,7 +6,7 @@ import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -15,14 +15,7 @@ const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; const socketPath = process.env.NITRO_UNIX_SOCKET; - -const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); -const gracefulShutdown = - process.env.NITRO_SHUTDOWN_DISABLED === "true" - ? false - : _shutdownTimeout > 0 - ? { gracefulTimeout: _shutdownTimeout / 1000 } - : undefined; +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index 2dd50cf4ee..ce29f93657 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -6,7 +6,7 @@ import wsAdapter from "crossws/adapters/deno"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -15,14 +15,7 @@ const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; - -const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); -const gracefulShutdown = - process.env.NITRO_SHUTDOWN_DISABLED === "true" - ? false - : _shutdownTimeout > 0 - ? { gracefulTimeout: _shutdownTimeout / 1000 } - : undefined; +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index c50c3863b7..e58ce06244 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -5,7 +5,7 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); @@ -15,14 +15,7 @@ const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; const key = process.env.NITRO_SSL_KEY; const socketPath = process.env.NITRO_UNIX_SOCKET; - -const _shutdownTimeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); -const gracefulShutdown = - process.env.NITRO_SHUTDOWN_DISABLED === "true" - ? false - : _shutdownTimeout > 0 - ? { gracefulTimeout: _shutdownTimeout / 1000 } - : undefined; +const gracefulShutdown = resolveGracefulShutdownConfig(); const nitroApp = useNitroApp(); diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index 74dd528f76..e673f56824 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -1,5 +1,14 @@ import { useNitroApp } from "../app.ts"; +export function resolveGracefulShutdownConfig() { + const _timeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); + return process.env.NITRO_SHUTDOWN_DISABLED === "true" + ? false + : _timeout > 0 + ? { gracefulTimeout: _timeout / 1000 } + : undefined; +} + export function setupShutdownHooks() { const handler = async () => { try { diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 64d0b40560..84dbbc7ae2 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -8,7 +8,44 @@ vi.mock("../../src/runtime/internal/app.ts", () => ({ }), })); -import { setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts"; + +describe("resolveGracefulShutdownConfig", () => { + const env = process.env; + + afterEach(() => { + process.env = env; + }); + + it("returns undefined by default", () => { + process.env = { ...env }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); + + it("returns false when NITRO_SHUTDOWN_DISABLED is 'true'", () => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "true" }; + expect(resolveGracefulShutdownConfig()).toBe(false); + }); + + it("returns gracefulTimeout in seconds from NITRO_SHUTDOWN_TIMEOUT ms", () => { + process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "10000" }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + expect(resolveGracefulShutdownConfig()).toEqual({ gracefulTimeout: 10 }); + }); + + it("disabled takes priority over timeout", () => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "true", NITRO_SHUTDOWN_TIMEOUT: "10000" }; + expect(resolveGracefulShutdownConfig()).toBe(false); + }); + + it("ignores non-numeric timeout", () => { + process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "abc" }; + delete process.env.NITRO_SHUTDOWN_DISABLED; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); +}); describe("setupShutdownHooks", () => { let savedSIGTERM: Function[]; From 8e1f996660351677ad86d79dacd1cd4f3f096b4f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:33:31 +0000 Subject: [PATCH 09/19] chore: apply automated updates --- test/unit/shutdown.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 84dbbc7ae2..1e0e2629b5 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -8,7 +8,10 @@ vi.mock("../../src/runtime/internal/app.ts", () => ({ }), })); -import { resolveGracefulShutdownConfig, setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts"; +import { + resolveGracefulShutdownConfig, + setupShutdownHooks, +} from "../../src/runtime/internal/shutdown.ts"; describe("resolveGracefulShutdownConfig", () => { const env = process.env; From 5ebc5f9e1fd1d76665f8a8e80d55c345984dc41d Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:39:15 -0600 Subject: [PATCH 10/19] refactor: clean up shutdown module and format preset entries - Add srvx ServerOptions type to resolveGracefulShutdownConfig return - Extract _shutdownHandler as a named module-level async function - Check NITRO_SHUTDOWN_DISABLED as truthy instead of strict "true" - Reformat long import/expression lines across preset runtime entries Co-authored-by: Cursor --- src/presets/bun/runtime/bun.ts | 18 +++++++--- src/presets/deno/runtime/deno-server.ts | 9 +++-- src/presets/node/runtime/node-server.ts | 9 +++-- src/runtime/internal/shutdown.ts | 44 +++++++++++++++---------- test/unit/shutdown.test.ts | 17 +++++++--- 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index 3aad3a3c67..f59dfed492 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -6,10 +6,15 @@ import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { + resolveGracefulShutdownConfig, + setupShutdownHooks, +} from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); +const _parsedPort = Number.parseInt( + process.env.NITRO_PORT ?? process.env.PORT ?? "" +); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; @@ -21,12 +26,17 @@ const nitroApp = useNitroApp(); let _fetch = nitroApp.fetch; -const ws = import.meta._websocket ? wsAdapter({ resolve: resolveWebsocketHooks }) : undefined; +const ws = import.meta._websocket + ? wsAdapter({ resolve: resolveWebsocketHooks }) + : undefined; if (import.meta._websocket) { _fetch = (req: ServerRequest) => { if (req.headers.get("upgrade") === "websocket") { - return ws!.handleUpgrade(req, req.runtime!.bun!.server) as Promise; + return ws!.handleUpgrade( + req, + req.runtime!.bun!.server + ) as Promise; } return nitroApp.fetch(req); }; diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index ce29f93657..3c0b32fa13 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -6,10 +6,15 @@ import wsAdapter from "crossws/adapters/deno"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { + resolveGracefulShutdownConfig, + setupShutdownHooks, +} from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); +const _parsedPort = Number.parseInt( + process.env.NITRO_PORT ?? process.env.PORT ?? "" +); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index e58ce06244..3b45ebfad2 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -5,10 +5,15 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; +import { + resolveGracefulShutdownConfig, + setupShutdownHooks, +} from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); +const _parsedPort = Number.parseInt( + process.env.NITRO_PORT ?? process.env.PORT ?? "" +); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index e673f56824..a11b6c0e4b 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -1,23 +1,33 @@ import { useNitroApp } from "../app.ts"; +import type { ServerOptions } from "srvx"; -export function resolveGracefulShutdownConfig() { - const _timeout = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10); - return process.env.NITRO_SHUTDOWN_DISABLED === "true" - ? false - : _timeout > 0 - ? { gracefulTimeout: _timeout / 1000 } - : undefined; +export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown"] { + if (!!process.env.NITRO_SHUTDOWN_DISABLED) { + return false; + } + + const timeoutMs = Number.parseInt( + process.env.NITRO_SHUTDOWN_TIMEOUT ?? "", + 10 + ); + + if (timeoutMs > 0) { + // srvx expects timeout in seconds + return { gracefulTimeout: timeoutMs / 1000 }; + } + + return undefined; } -export function setupShutdownHooks() { - const handler = async () => { - try { - await useNitroApp().hooks?.callHook("close"); - } catch (error) { - console.error("[nitro] Error running close hook:", error); - } - }; - for (const sig of ["SIGTERM", "SIGINT"] as const) { - process.on(sig, handler); +async function _shutdownHandler() { + try { + await useNitroApp().hooks?.callHook("close"); + } catch (error) { + console.error("[nitro] Error running close hook:", error); } } + +export function setupShutdownHooks() { + process.on("SIGTERM", _shutdownHandler); + process.on("SIGINT", _shutdownHandler); +} diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 1e0e2629b5..2a943b0b90 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -39,7 +39,11 @@ describe("resolveGracefulShutdownConfig", () => { }); it("disabled takes priority over timeout", () => { - process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "true", NITRO_SHUTDOWN_TIMEOUT: "10000" }; + process.env = { + ...env, + NITRO_SHUTDOWN_DISABLED: "true", + NITRO_SHUTDOWN_TIMEOUT: "10000", + }; expect(resolveGracefulShutdownConfig()).toBe(false); }); @@ -64,8 +68,10 @@ describe("setupShutdownHooks", () => { afterEach(() => { process.removeAllListeners("SIGTERM"); process.removeAllListeners("SIGINT"); - for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener); - for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener); + for (const fn of savedSIGTERM) + process.on("SIGTERM", fn as NodeJS.SignalsListener); + for (const fn of savedSIGINT) + process.on("SIGINT", fn as NodeJS.SignalsListener); }); it("registers SIGTERM and SIGINT handlers", () => { @@ -99,7 +105,10 @@ describe("setupShutdownHooks", () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); + expect(consoleSpy).toHaveBeenCalledWith( + "[nitro] Error running close hook:", + error + ); }); consoleSpy.mockRestore(); }); From ad08c19f56b8f215c92db7083a2cc3300767ef3e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:40:04 +0000 Subject: [PATCH 11/19] chore: apply automated updates --- src/presets/bun/runtime/bun.ts | 18 ++++-------------- src/presets/deno/runtime/deno-server.ts | 9 ++------- src/presets/node/runtime/node-server.ts | 9 ++------- src/runtime/internal/shutdown.ts | 5 +---- test/unit/shutdown.test.ts | 11 +++-------- 5 files changed, 12 insertions(+), 40 deletions(-) diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index f59dfed492..3aad3a3c67 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -6,15 +6,10 @@ import wsAdapter from "crossws/adapters/bun"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { - resolveGracefulShutdownConfig, - setupShutdownHooks, -} from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt( - process.env.NITRO_PORT ?? process.env.PORT ?? "" -); +const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; const cert = process.env.NITRO_SSL_CERT; @@ -26,17 +21,12 @@ const nitroApp = useNitroApp(); let _fetch = nitroApp.fetch; -const ws = import.meta._websocket - ? wsAdapter({ resolve: resolveWebsocketHooks }) - : undefined; +const ws = import.meta._websocket ? wsAdapter({ resolve: resolveWebsocketHooks }) : undefined; if (import.meta._websocket) { _fetch = (req: ServerRequest) => { if (req.headers.get("upgrade") === "websocket") { - return ws!.handleUpgrade( - req, - req.runtime!.bun!.server - ) as Promise; + return ws!.handleUpgrade(req, req.runtime!.bun!.server) as Promise; } return nitroApp.fetch(req); }; diff --git a/src/presets/deno/runtime/deno-server.ts b/src/presets/deno/runtime/deno-server.ts index 3c0b32fa13..ce29f93657 100644 --- a/src/presets/deno/runtime/deno-server.ts +++ b/src/presets/deno/runtime/deno-server.ts @@ -6,15 +6,10 @@ import wsAdapter from "crossws/adapters/deno"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { - resolveGracefulShutdownConfig, - setupShutdownHooks, -} from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt( - process.env.NITRO_PORT ?? process.env.PORT ?? "" -); +const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; diff --git a/src/presets/node/runtime/node-server.ts b/src/presets/node/runtime/node-server.ts index 3b45ebfad2..e58ce06244 100644 --- a/src/presets/node/runtime/node-server.ts +++ b/src/presets/node/runtime/node-server.ts @@ -5,15 +5,10 @@ import wsAdapter from "crossws/adapters/node"; import { useNitroApp } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; -import { - resolveGracefulShutdownConfig, - setupShutdownHooks, -} from "#nitro/runtime/shutdown"; +import { resolveGracefulShutdownConfig, setupShutdownHooks } from "#nitro/runtime/shutdown"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const _parsedPort = Number.parseInt( - process.env.NITRO_PORT ?? process.env.PORT ?? "" -); +const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? ""); const port = Number.isNaN(_parsedPort) ? 3000 : _parsedPort; const host = process.env.NITRO_HOST || process.env.HOST; diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index a11b6c0e4b..537add01ff 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -6,10 +6,7 @@ export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown return false; } - const timeoutMs = Number.parseInt( - process.env.NITRO_SHUTDOWN_TIMEOUT ?? "", - 10 - ); + const timeoutMs = Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT ?? "", 10); if (timeoutMs > 0) { // srvx expects timeout in seconds diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 2a943b0b90..f880f2d3a1 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -68,10 +68,8 @@ describe("setupShutdownHooks", () => { afterEach(() => { process.removeAllListeners("SIGTERM"); process.removeAllListeners("SIGINT"); - for (const fn of savedSIGTERM) - process.on("SIGTERM", fn as NodeJS.SignalsListener); - for (const fn of savedSIGINT) - process.on("SIGINT", fn as NodeJS.SignalsListener); + for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener); + for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener); }); it("registers SIGTERM and SIGINT handlers", () => { @@ -105,10 +103,7 @@ describe("setupShutdownHooks", () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - "[nitro] Error running close hook:", - error - ); + expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); }); consoleSpy.mockRestore(); }); From b25ed2fcece7bbb979aa24657b60319483c29fc0 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:40:13 -0600 Subject: [PATCH 12/19] chore: apply project formatting and fix lint warning Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index 537add01ff..dc8033557b 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -2,7 +2,7 @@ import { useNitroApp } from "../app.ts"; import type { ServerOptions } from "srvx"; export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown"] { - if (!!process.env.NITRO_SHUTDOWN_DISABLED) { + if (process.env.NITRO_SHUTDOWN_DISABLED) { return false; } From 8769db6bc077f897813b4df10f87131bad783469 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:46:55 -0600 Subject: [PATCH 13/19] fix: guard process.on behind runtime check, add await regression test Guard process.on calls in setupShutdownHooks with a typeof check to prevent reference errors if the module is ever imported in edge/serverless targets where process is unavailable. Add regression test verifying the handler awaits a delayed close hook promise to completion. Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 6 ++++-- test/unit/shutdown.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index dc8033557b..0effe208b6 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -25,6 +25,8 @@ async function _shutdownHandler() { } export function setupShutdownHooks() { - process.on("SIGTERM", _shutdownHandler); - process.on("SIGINT", _shutdownHandler); + if (typeof process !== "undefined" && process.on) { + process.on("SIGTERM", _shutdownHandler); + process.on("SIGINT", _shutdownHandler); + } } diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index f880f2d3a1..afe4920868 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -96,6 +96,22 @@ describe("setupShutdownHooks", () => { }); }); + it("awaits the close hook promise", async () => { + let resolved = false; + callHook.mockImplementation( + () => + new Promise((r) => { + setTimeout(() => { + resolved = true; + r(); + }, 50); + }) + ); + setupShutdownHooks(); + process.emit("SIGTERM", "SIGTERM"); + await vi.waitFor(() => expect(resolved).toBe(true)); + }); + it("logs error if close hook throws", async () => { const error = new Error("cleanup failed"); callHook.mockRejectedValueOnce(error); From 2127dc3079457407b4366d427ab384a14a0f9199 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:48:58 -0600 Subject: [PATCH 14/19] fix: use strict === "true" check for NITRO_SHUTDOWN_DISABLED Match the documented behavior ("set to 'true'") instead of treating any truthy string as disabled. Add tests for "false" and empty string values. Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 2 +- test/unit/shutdown.test.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index 0effe208b6..e9a9bb6ff8 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -2,7 +2,7 @@ import { useNitroApp } from "../app.ts"; import type { ServerOptions } from "srvx"; export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown"] { - if (process.env.NITRO_SHUTDOWN_DISABLED) { + if (process.env.NITRO_SHUTDOWN_DISABLED === "true") { return false; } diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index afe4920868..8d27de755a 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -47,6 +47,18 @@ describe("resolveGracefulShutdownConfig", () => { expect(resolveGracefulShutdownConfig()).toBe(false); }); + it("does not disable for 'false'", () => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "false" }; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); + + it("does not disable for empty string", () => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "" }; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBeUndefined(); + }); + it("ignores non-numeric timeout", () => { process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "abc" }; delete process.env.NITRO_SHUTDOWN_DISABLED; @@ -68,8 +80,8 @@ describe("setupShutdownHooks", () => { afterEach(() => { process.removeAllListeners("SIGTERM"); process.removeAllListeners("SIGINT"); - for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener); - for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener); + for (const fn of savedSIGTERM) process.on("SIGTERM", fn as () => void); + for (const fn of savedSIGINT) process.on("SIGINT", fn as () => void); }); it("registers SIGTERM and SIGINT handlers", () => { @@ -119,7 +131,10 @@ describe("setupShutdownHooks", () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); + expect(consoleSpy).toHaveBeenCalledWith( + "[nitro] Error running close hook:", + error + ); }); consoleSpy.mockRestore(); }); From 4b688240dddf30ba26c8d8711bdbfb1db94076c8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:49:34 +0000 Subject: [PATCH 15/19] chore: apply automated updates --- test/unit/shutdown.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 8d27de755a..a0b26ce2ee 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -131,10 +131,7 @@ describe("setupShutdownHooks", () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - "[nitro] Error running close hook:", - error - ); + expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); }); consoleSpy.mockRestore(); }); From 8fc90dd1a1f1c41089655d4e8b7729e5e1cbde3b Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 20:49:56 -0600 Subject: [PATCH 16/19] test: combine NITRO_SHUTDOWN_DISABLED tests into it.each Co-authored-by: Cursor --- test/unit/shutdown.test.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index a0b26ce2ee..f5cbecea85 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -27,9 +27,16 @@ describe("resolveGracefulShutdownConfig", () => { expect(resolveGracefulShutdownConfig()).toBeUndefined(); }); - it("returns false when NITRO_SHUTDOWN_DISABLED is 'true'", () => { - process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "true" }; - expect(resolveGracefulShutdownConfig()).toBe(false); + it.each([ + { value: "true", expected: false }, + { value: "false", expected: undefined }, + { value: "", expected: undefined }, + { value: "1", expected: undefined }, + { value: "yes", expected: undefined }, + ])("NITRO_SHUTDOWN_DISABLED=$value returns $expected", ({ value, expected }) => { + process.env = { ...env, NITRO_SHUTDOWN_DISABLED: value }; + delete process.env.NITRO_SHUTDOWN_TIMEOUT; + expect(resolveGracefulShutdownConfig()).toBe(expected); }); it("returns gracefulTimeout in seconds from NITRO_SHUTDOWN_TIMEOUT ms", () => { @@ -47,18 +54,6 @@ describe("resolveGracefulShutdownConfig", () => { expect(resolveGracefulShutdownConfig()).toBe(false); }); - it("does not disable for 'false'", () => { - process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "false" }; - delete process.env.NITRO_SHUTDOWN_TIMEOUT; - expect(resolveGracefulShutdownConfig()).toBeUndefined(); - }); - - it("does not disable for empty string", () => { - process.env = { ...env, NITRO_SHUTDOWN_DISABLED: "" }; - delete process.env.NITRO_SHUTDOWN_TIMEOUT; - expect(resolveGracefulShutdownConfig()).toBeUndefined(); - }); - it("ignores non-numeric timeout", () => { process.env = { ...env, NITRO_SHUTDOWN_TIMEOUT: "abc" }; delete process.env.NITRO_SHUTDOWN_DISABLED; From 12a6cacfec56dbed1a6862d2b3d3ddb5db575e60 Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 21:21:11 -0600 Subject: [PATCH 17/19] fix: add idempotency guard to shutdown handler Prevent the close hook from firing multiple times when both SIGTERM and SIGINT arrive (e.g. Ctrl+C during a SIGTERM shutdown). A module-level boolean short-circuits duplicate invocations. Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 10 ++++++++++ test/unit/shutdown.test.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index e9a9bb6ff8..34a259375a 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -16,7 +16,13 @@ export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown return undefined; } +let _isShuttingDown = false; + async function _shutdownHandler() { + if (_isShuttingDown) { + return; + } + _isShuttingDown = true; try { await useNitroApp().hooks?.callHook("close"); } catch (error) { @@ -30,3 +36,7 @@ export function setupShutdownHooks() { process.on("SIGINT", _shutdownHandler); } } + +export function _resetShutdownState() { + _isShuttingDown = false; +} diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index f5cbecea85..96f685b32a 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -9,6 +9,7 @@ vi.mock("../../src/runtime/internal/app.ts", () => ({ })); import { + _resetShutdownState, resolveGracefulShutdownConfig, setupShutdownHooks, } from "../../src/runtime/internal/shutdown.ts"; @@ -70,6 +71,7 @@ describe("setupShutdownHooks", () => { savedSIGINT = process.listeners("SIGINT").slice(); callHook.mockClear(); callHook.mockResolvedValue(undefined); + _resetShutdownState(); }); afterEach(() => { @@ -119,6 +121,17 @@ describe("setupShutdownHooks", () => { await vi.waitFor(() => expect(resolved).toBe(true)); }); + it("only calls close hook once across multiple signals", async () => { + setupShutdownHooks(); + process.emit("SIGTERM", "SIGTERM"); + process.emit("SIGINT", "SIGINT"); + process.emit("SIGTERM", "SIGTERM"); + await vi.waitFor(() => { + expect(callHook).toHaveBeenCalledWith("close"); + }); + expect(callHook).toHaveBeenCalledTimes(1); + }); + it("logs error if close hook throws", async () => { const error = new Error("cleanup failed"); callHook.mockRejectedValueOnce(error); From 9c5aee147d10c3c3471644261bb3aefac85526ff Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 21:23:09 -0600 Subject: [PATCH 18/19] refactor: simplify setupShutdownHooks to match trapUnhandledErrors Drop the async handler, idempotency guard, runtime check, and error logging to match the pattern used by the sibling trapUnhandledErrors utility. Keep it minimal: process.on + callHook. Co-authored-by: Cursor --- src/runtime/internal/shutdown.ts | 24 +++----------- test/unit/shutdown.test.ts | 54 +++----------------------------- 2 files changed, 8 insertions(+), 70 deletions(-) diff --git a/src/runtime/internal/shutdown.ts b/src/runtime/internal/shutdown.ts index 34a259375a..cbbdc4854c 100644 --- a/src/runtime/internal/shutdown.ts +++ b/src/runtime/internal/shutdown.ts @@ -16,27 +16,11 @@ export function resolveGracefulShutdownConfig(): ServerOptions["gracefulShutdown return undefined; } -let _isShuttingDown = false; - -async function _shutdownHandler() { - if (_isShuttingDown) { - return; - } - _isShuttingDown = true; - try { - await useNitroApp().hooks?.callHook("close"); - } catch (error) { - console.error("[nitro] Error running close hook:", error); - } +function _onShutdownSignal() { + useNitroApp().hooks?.callHook("close"); } export function setupShutdownHooks() { - if (typeof process !== "undefined" && process.on) { - process.on("SIGTERM", _shutdownHandler); - process.on("SIGINT", _shutdownHandler); - } -} - -export function _resetShutdownState() { - _isShuttingDown = false; + process.on("SIGTERM", _onShutdownSignal); + process.on("SIGINT", _onShutdownSignal); } diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index 96f685b32a..d2ad6f6b9f 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -9,7 +9,6 @@ vi.mock("../../src/runtime/internal/app.ts", () => ({ })); import { - _resetShutdownState, resolveGracefulShutdownConfig, setupShutdownHooks, } from "../../src/runtime/internal/shutdown.ts"; @@ -70,8 +69,6 @@ describe("setupShutdownHooks", () => { savedSIGTERM = process.listeners("SIGTERM").slice(); savedSIGINT = process.listeners("SIGINT").slice(); callHook.mockClear(); - callHook.mockResolvedValue(undefined); - _resetShutdownState(); }); afterEach(() => { @@ -89,58 +86,15 @@ describe("setupShutdownHooks", () => { expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1); }); - it("calls close hook on SIGTERM", async () => { + it("calls close hook on SIGTERM", () => { setupShutdownHooks(); process.emit("SIGTERM", "SIGTERM"); - await vi.waitFor(() => { - expect(callHook).toHaveBeenCalledWith("close"); - }); + expect(callHook).toHaveBeenCalledWith("close"); }); - it("calls close hook on SIGINT", async () => { + it("calls close hook on SIGINT", () => { setupShutdownHooks(); process.emit("SIGINT", "SIGINT"); - await vi.waitFor(() => { - expect(callHook).toHaveBeenCalledWith("close"); - }); - }); - - it("awaits the close hook promise", async () => { - let resolved = false; - callHook.mockImplementation( - () => - new Promise((r) => { - setTimeout(() => { - resolved = true; - r(); - }, 50); - }) - ); - setupShutdownHooks(); - process.emit("SIGTERM", "SIGTERM"); - await vi.waitFor(() => expect(resolved).toBe(true)); - }); - - it("only calls close hook once across multiple signals", async () => { - setupShutdownHooks(); - process.emit("SIGTERM", "SIGTERM"); - process.emit("SIGINT", "SIGINT"); - process.emit("SIGTERM", "SIGTERM"); - await vi.waitFor(() => { - expect(callHook).toHaveBeenCalledWith("close"); - }); - expect(callHook).toHaveBeenCalledTimes(1); - }); - - it("logs error if close hook throws", async () => { - const error = new Error("cleanup failed"); - callHook.mockRejectedValueOnce(error); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - setupShutdownHooks(); - process.emit("SIGTERM", "SIGTERM"); - await vi.waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith("[nitro] Error running close hook:", error); - }); - consoleSpy.mockRestore(); + expect(callHook).toHaveBeenCalledWith("close"); }); }); From 6330afe316fd4d88a6c6cdb9ad7e1e0e588715fb Mon Sep 17 00:00:00 2001 From: wadefletch Date: Mon, 9 Feb 2026 21:31:14 -0600 Subject: [PATCH 19/19] refactor: clean up shutdown tests with it.each and signal helpers Co-authored-by: Cursor --- test/unit/shutdown.test.ts | 43 ++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/test/unit/shutdown.test.ts b/test/unit/shutdown.test.ts index d2ad6f6b9f..e42a8e5496 100644 --- a/test/unit/shutdown.test.ts +++ b/test/unit/shutdown.test.ts @@ -12,6 +12,7 @@ import { resolveGracefulShutdownConfig, setupShutdownHooks, } from "../../src/runtime/internal/shutdown.ts"; +import type { ProcessEventMap } from "node:process"; describe("resolveGracefulShutdownConfig", () => { const env = process.env; @@ -62,39 +63,31 @@ describe("resolveGracefulShutdownConfig", () => { }); describe("setupShutdownHooks", () => { - let savedSIGTERM: Function[]; - let savedSIGINT: Function[]; + let signals: (keyof ProcessEventMap)[] = ["SIGTERM", "SIGINT"]; + let priors: Record void)[]> = Object.fromEntries( + signals.map((s) => [s, []]) + ); beforeEach(() => { - savedSIGTERM = process.listeners("SIGTERM").slice(); - savedSIGINT = process.listeners("SIGINT").slice(); callHook.mockClear(); - }); - afterEach(() => { - process.removeAllListeners("SIGTERM"); - process.removeAllListeners("SIGINT"); - for (const fn of savedSIGTERM) process.on("SIGTERM", fn as () => void); - for (const fn of savedSIGINT) process.on("SIGINT", fn as () => void); + for (const signal of signals) { + priors[signal] = process.listeners(signal).slice(); + } }); - it("registers SIGTERM and SIGINT handlers", () => { - const beforeTERM = process.listenerCount("SIGTERM"); - const beforeINT = process.listenerCount("SIGINT"); - setupShutdownHooks(); - expect(process.listenerCount("SIGTERM")).toBe(beforeTERM + 1); - expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1); - }); - - it("calls close hook on SIGTERM", () => { - setupShutdownHooks(); - process.emit("SIGTERM", "SIGTERM"); - expect(callHook).toHaveBeenCalledWith("close"); + afterEach(() => { + for (const signal of signals) { + process.removeAllListeners(signal); + for (const fn of priors[signal]) { + process.on(signal, fn); + } + } }); - it("calls close hook on SIGINT", () => { + it.each(["SIGTERM", "SIGINT"])("calls close hook on %s", (signal) => { setupShutdownHooks(); - process.emit("SIGINT", "SIGINT"); - expect(callHook).toHaveBeenCalledWith("close"); + process.emit(signal, true); + expect(callHook).toHaveBeenCalledOnce(); }); });