Skip to content

Conversation

@wadefletch
Copy link
Contributor

@wadefletch wadefletch commented Feb 10, 2026

🔗 Linked issue

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme, or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

The runtime 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, making it impossible for plugins to run async cleanup on shutdown (flush telemetry, drain database connections, stop job queues, etc.).

Root cause: src/runtime/internal/shutdown.ts and its setupGracefulShutdown() function (which listened for SIGTERM/SIGINT and called useNitroHooks().callHook("close")) were deleted in #3705 when the presets were rewritten to use srvx. The HTTP shutdown was delegated to srvx's gracefulShutdownPlugin, but the bridge from OS signals to Nitro's hook system was never recreated.

Fix: Adds a setupShutdownHooks() utility that registers SIGTERM/SIGINT handlers calling nitroApp.hooks.callHook("close"). Follows the same pattern as the existing trapUnhandledErrors() utility -- a small function imported and called from each runtime entry.

Changes:

  • New src/runtime/internal/shutdown.ts with setupShutdownHooks()
  • Wired into node-server, node-cluster, bun, and deno-server runtime entries
  • Unit tests for signal handler registration and hook invocation
  • Docs: added "close" to the available hooks list, expanded graceful shutdown section with example, clarified that the close hook is only available on long-running server presets
  • Docs: removed NITRO_SHUTDOWN_SIGNALS, NITRO_SHUTDOWN_TIMEOUT, and NITRO_SHUTDOWN_FORCE env vars from Node.js page (these are no longer configurable through srvx)

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

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 nitrojs#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 nitrojs#4015
Resolves nitrojs#2735
Resolves nitrojs#2566

Co-authored-by: Cursor <cursoragent@cursor.com>
@wadefletch wadefletch requested a review from pi0 as a code owner February 10, 2026 01:15
@vercel
Copy link

vercel bot commented Feb 10, 2026

@wadefletch is attempting to deploy a commit to the Nitro Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

Adds runtime shutdown wiring: a new setupShutdownHooks utility registers SIGTERM/SIGINT handlers to call Nitro's "close" hook; this utility is invoked in Bun, Deno, Node (server & cluster) presets. Documentation and tests for shutdown behavior were added/updated.

Changes

Cohort / File(s) Summary
Documentation
docs/1.docs/50.plugins.md, docs/2.deploy/10.runtimes/1.node.md
Added close hook docs, updated response hook signature, expanded graceful-shutdown guidance and examples, and simplified NITRO_SHUTDOWN_DISABLED description.
Runtime shutdown utility
src/runtime/internal/shutdown.ts
New setupShutdownHooks() exports; registers SIGTERM/SIGINT handlers that call useNitroApp().hooks?.callHook("close").
Runtime integrations
src/presets/bun/runtime/bun.ts, src/presets/deno/runtime/deno-server.ts, src/presets/node/runtime/node-cluster.ts, src/presets/node/runtime/node-server.ts
Imported and invoked setupShutdownHooks() after trapUnhandledErrors() in each runtime entry to wire shutdown handlers at startup.
Tests
test/unit/shutdown.test.ts
New unit tests verifying handlers are registered and that emitting SIGTERM/SIGINT triggers hooks.callHook("close"), with preservation/restoration of existing listeners.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'fix:' prefix and clearly describes the main change: implementing the close hook on shutdown signals.
Description check ✅ Passed The description comprehensively explains the issue, root cause, fix approach, and lists all changes including documentation updates and affected runtime entries.
Linked Issues check ✅ Passed The PR fully addresses all three linked issues: restores close hook invocation on SIGTERM/SIGINT for #4015, ensures hooks are called on shutdown for #2735, and implements close hook across multiple presets including Bun for #2566.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives: new shutdown utility, runtime integration, unit tests, documentation updates, and removal of obsolete env vars are all scope-aligned with fixing the close hook issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@docs/2.deploy/10.runtimes/1.node.md`:
- 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.

In `@src/runtime/internal/shutdown.ts`:
- Around line 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.

In `@test/unit/shutdown.test.ts`:
- Around line 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.
🧹 Nitpick comments (2)
test/unit/shutdown.test.ts (2)

38-48: No test for repeated signal delivery — the close hook could run multiple times.

In practice, a process can receive SIGTERM followed by SIGINT (e.g., Kubernetes sends SIGTERM, then the user hits Ctrl+C). The current implementation will call callHook("close") for each signal. Depending on what plugins do in their close hooks, running them twice could cause errors (double-free, duplicate flushes, etc.).

Consider adding a test that asserts callHook is called only once even when both signals fire, and guard the implementation accordingly (e.g., with a let closing = false flag).


14-15: Minor: prefer NodeJS.SignalsListener[] over Function[] for saved listeners.

Lines 14–15 declare Function[] but the cast on lines 26–27 uses NodeJS.SignalsListener. Using the correct type from the start avoids the cast:

-  let savedSIGTERM: Function[];
-  let savedSIGINT: Function[];
+  let savedSIGTERM: NodeJS.SignalsListener[];
+  let savedSIGINT: NodeJS.SignalsListener[];

Then lines 26-27 won't need the as cast.

- `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.

Comment on lines +3 to +9
export function setupShutdownHooks() {
const handler = () => {
useNitroApp().hooks?.callHook("close");
};
for (const sig of ["SIGTERM", "SIGINT"] as const) {
process.on(sig, handler);
}
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.

Comment on lines +38 to +48
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");
});
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.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nitrojs/nitro@4016

commit: ea4235a

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 <cursoragent@cursor.com>
@wadefletch
Copy link
Contributor Author

Superseded by #4017 which combines this with #4013 into a single comprehensive graceful shutdown PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Node server preset never calls nitro.hooks.callHook("close") on shutdown Close Hook not awaited Close hook not executed with 'bun' build preset

1 participant