Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions docs/1.docs/50.plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

Expand All @@ -76,7 +77,32 @@ export default definePlugin((nitro) => {

### Graceful shutdown

Server will gracefully shutdown and wait for any background pending tasks initiated by event.waitUntil
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";

export default definePlugin((nitro) => {
nitro.hooks.hook("close", async () => {
await flushTelemetry();
await db.close();
});
})
```

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

Expand Down
5 changes: 1 addition & 4 deletions docs/2.deploy/10.runtimes/1.node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if NITRO_SHUTDOWN_DISABLED is referenced anywhere in the runtime/preset code
rg -n "NITRO_SHUTDOWN_DISABLED" --type=ts -g '!docs/**'

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Find the shutdown.ts file
fd "shutdown\.ts" -type f

# Also check the documentation file
cat -n docs/2.deploy/10.runtimes/1.node.md | sed -n '30,45p'

Repository: nitrojs/nitro

Length of output: 1193


🏁 Script executed:

#!/bin/bash
# Find the shutdown file properly
find . -name "*shutdown*" -type f | grep -v node_modules | grep -v dist | head -20

# Also search for setupShutdownHooks mentioned in the review
rg "setupShutdownHooks" -g '!docs/**' -g '!node_modules/**'

Repository: nitrojs/nitro

Length of output: 1161


🏁 Script executed:

#!/bin/bash
# Read the shutdown.ts file to see if it checks NITRO_SHUTDOWN_DISABLED
cat -n ./src/runtime/internal/shutdown.ts

Repository: nitrojs/nitro

Length of output: 372


Remove NITRO_SHUTDOWN_DISABLED from documentation or implement the feature in code.

The environment variable NITRO_SHUTDOWN_DISABLED is documented on line 36 but does not exist in the implementation. The setupShutdownHooks() function in src/runtime/internal/shutdown.ts unconditionally registers signal handlers with no check for this env var. Users will be misled by the documentation. Either implement the env var check in setupShutdownHooks() to conditionally skip shutdown hook registration, or remove this env var from the docs.

πŸ€– Prompt for AI Agents
In `@docs/2.deploy/10.runtimes/1.node.md` at line 36, The docs mention
NITRO_SHUTDOWN_DISABLED but the runtime always registers shutdown hooks; update
setupShutdownHooks in src/runtime/internal/shutdown.ts to read
process.env.NITRO_SHUTDOWN_DISABLED (string) and, when it equals 'true', skip
registering signal handlers and return early (or log that graceful shutdown is
disabled), otherwise proceed to register the handlers as before; ensure the
check uses the exact env var name and is evaluated before any calls to
process.on or similar so the documented behavior matches the implementation.


## Cluster mode

Expand Down
2 changes: 2 additions & 0 deletions src/presets/bun/runtime/bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -41,6 +42,7 @@ serve({
});

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
2 changes: 2 additions & 0 deletions src/presets/deno/runtime/deno-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -38,6 +39,7 @@ serve({
});

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
2 changes: 2 additions & 0 deletions src/presets/node/runtime/node-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -46,6 +47,7 @@ if (import.meta._websocket) {
}

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
2 changes: 2 additions & 0 deletions src/presets/node/runtime/node-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -38,6 +39,7 @@ if (import.meta._websocket) {
}

trapUnhandledErrors();
setupShutdownHooks();

// Scheduled tasks
if (import.meta._tasks) {
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/internal/shutdown.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +3 to +9
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Critical: handler does not await the close hook and never exits the process.

Two interrelated problems:

  1. callHook("close") returns a Promise, but the handler neither awaits it nor chains .then(). Async cleanup (flushing telemetry, draining DB pools) will not complete β€” this directly defeats the objective of issue #2735.
  2. Registering a listener on SIGTERM/SIGINT suppresses the default OS termination behavior. Since the handler returns immediately (fire-and-forget promise) and never calls process.exit(), the process will hang indefinitely after receiving the signal.

Additionally, there's no guard against double invocation β€” a rapid Ctrl+C twice, or SIGTERM followed by SIGINT, will call the close hook multiple times, which may cause errors in plugins that tear down resources once.

Proposed fix
 export function setupShutdownHooks() {
+  let shutdownPromise: Promise<void> | undefined;
   const handler = () => {
-    useNitroApp().hooks?.callHook("close");
+    if (shutdownPromise) {
+      return;
+    }
+    shutdownPromise = (useNitroApp().hooks?.callHook("close") ?? Promise.resolve())
+      .finally(() => {
+        process.exit(0);
+      });
   };
   for (const sig of ["SIGTERM", "SIGINT"] as const) {
     process.on(sig, handler);
   }
 }
πŸ€– Prompt for AI Agents
In `@src/runtime/internal/shutdown.ts` around lines 3 - 9, In setupShutdownHooks
replace the fire-and-forget handler with an async guarded handler that (1)
tracks a local boolean (e.g., "shuttingDown") to prevent re-entrance, (2) awaits
useNitroApp().hooks.callHook("close") inside try/catch, (3) on success calls
process.exit(0) and on error logs the error and calls process.exit(1), and (4)
registers that async handler for "SIGTERM" and "SIGINT" (and optionally sets a
hard timeout to force exit) so the process does not hang; update the handler
identifier and the call to useNitroApp().hooks.callHook("close") accordingly and
ensure listeners are removed or ignored after the first invocation.

}
49 changes: 49 additions & 0 deletions test/unit/shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Comment on lines +38 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The close hook's promise is neither awaited in the implementation nor verified in the test.

Looking at the implementation in src/runtime/internal/shutdown.ts:

const handler = () => {
  useNitroApp().hooks?.callHook("close");
};

callHook returns a Promise, but the handler doesn't await it. This means async cleanup (flushing telemetry, draining DB pools, etc.) can be cut short when the process exits β€” which is exactly the bug described in issue #2735.

The test should use an async mock to surface this:

Suggested test improvement (after fixing the implementation to await)
  it("calls close hook on SIGTERM", async () => {
+   callHook.mockResolvedValueOnce(undefined);
    setupShutdownHooks();
-   process.emit("SIGTERM", "SIGTERM");
+   await process.emit("SIGTERM", "SIGTERM");
    expect(callHook).toHaveBeenCalledWith("close");
  });

And in the implementation:

- const handler = () => {
-   useNitroApp().hooks?.callHook("close");
+ const handler = async () => {
+   await useNitroApp().hooks?.callHook("close");
  };
πŸ€– Prompt for AI Agents
In `@test/unit/shutdown.test.ts` around lines 38 - 48, The shutdown handler
currently calls useNitroApp().hooks?.callHook("close") without awaiting the
returned Promise; change the handler in src/runtime/internal/shutdown.ts (the
function registered by setupShutdownHooks) to be async and await
useNitroApp().hooks?.callHook("close") so async cleanup finishes before exit.
Update tests in test/unit/shutdown.test.ts to mock callHook as an async mock
(e.g., jest.fn().mockResolvedValue(undefined)), make the tests async, emit the
signal via process.emit, then await a microtask flush (await Promise.resolve()
or await new Promise(setImmediate)) before asserting
expect(callHook).toHaveBeenCalledWith("close") so the promise resolution is
observed.

});
Loading