Skip to content

fix(node): call close hooks on server shutdown - #4522

Open
tarikermis wants to merge 1 commit into
nitrojs:mainfrom
tarikermis:fix/node-close-hook-shutdown
Open

fix(node): call close hooks on server shutdown#4522
tarikermis wants to merge 1 commit into
nitrojs:mainfrom
tarikermis:fix/node-close-hook-shutdown

Conversation

@tarikermis

Copy link
Copy Markdown

🔗 Linked issue

Resolves #4502

❓ Type of change

  • 🐞 Bug fix (a non-breaking change that fixes an issue)

📚 Description

Problem: With the node_server preset, the runtime close hook never fires on SIGTERM/SIGINT in production, so plugin cleanup handlers (flush, checkpoint, resource disposal) are silently skipped. This is a regression from v2, where the node-server entry called setupGracefulShutdown(listener, nitroApp) whose onShutdown ran nitroApp.hooks.callHook("close"). In v3 the entry only calls srvx serve(), and srvx's gracefulShutdownPlugin closes the server without touching Nitro hooks.

Fix: In the node runtime entries (node-server.ts, node-cluster.ts), wrap server.close() so that runtime close hooks run (once, awaited) whenever the server is closed — including via srvx's graceful shutdown on SIGINT/SIGTERM, which calls server.close() on the instance. Hooks run after the underlying close completes (matching v2, where http-graceful-shutdown ran onShutdown after connections drained), and also run if the underlying close rejects (try/finally). node_cluster had the same regression (v2 cluster workers ran the node-server entry). Bun/Deno presets are intentionally untouched: they never called close hooks in v2 either, so there is no regression to restore there.

callHook returns Promise | void (void when all hooks are synchronous), hence ?.catch, matching the existing runtime pattern.

Reproduction (from the issue): fixture plugin registers app.hooks.hook("close", ...), build with node_server preset, run .output/server/index.mjs, send SIGTERM. Before: hook never runs (Server closed successfully. with no hook output). After: hook runs before shutdown completes.

Verification:

  • New regression test nitro:preset:node-server > calls the close hook on shutdown builds the fixture, spawns the built entry, waits for the port, sends SIGTERM and asserts the hook ran and no unhandledRejection occurred. It fails without the fix and passes with it (verified both ways).
  • pnpm vitest run test/presets/node.test.ts — 66 passed with both rolldown and rollup builders (NITRO_BUILDER=rollup). This also adds the first node_server preset coverage.
  • Manual check: node .output/server/index.mjs + SIGTERM prints the close-hook marker followed by Server closed successfully.
  • pnpm lint, pnpm fmt, pnpm typecheck — clean.
  • The diff was reviewed by an automated reviewer (kiro-cli, claude-opus-5); two rounds, findings addressed (hook/close ordering vs. v2, error-log tag style, execa conventions, try/finally).

Notes / limitations:

  • Hook execution time counts against srvx's graceful shutdown budget (default 5s, SERVER_SHUTDOWN_TIMEOUT); a second SIGINT/SIGTERM still force-closes connections while slow hooks run (guarded to run once).
  • The fixture task scheduler can keep the event loop alive after the server closes, so the test process is SIGKILLed after asserting — that behavior is pre-existing srvx semantics, not changed here.
  • v2's NITRO_SHUTDOWN_* env vars remain unwired (srvx owns shutdown now); restoring those is out of scope, though docs/2.deploy/10.runtimes/1.node.md still documents them.
  • test/presets/nitro-dev.test.ts fails identically on clean main in my environment (Invalid URL from the dev server harness) — pre-existing and unrelated to this change.
  • The same wrapper is duplicated in both node entries, mirroring the existing near-identical duplication of those entry files; node_cluster has no dedicated test yet.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly. (no user-facing behavior change beyond restoring documented close hook semantics)

Restore v2 behavior for the `node_server` and `node_cluster` presets:
run runtime `close` hooks when srvx closes the server on SIGINT/SIGTERM.

Resolves nitrojs#4502
@tarikermis
tarikermis requested a review from pi0 as a code owner August 9, 2026 07:10
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Node shutdown hooks

Layer / File(s) Summary
Runtime close-hook wiring
src/presets/node/runtime/node-server.ts, src/presets/node/runtime/node-cluster.ts
Both Node runtimes wrap server.close, invoke Nitro’s close hook once, and log hook errors while preserving shutdown behavior.
Shutdown hook integration validation
test/fixture/server/plugins/close.ts, test/presets/node.test.ts
A fixture plugin registers the hook. The integration test verifies hook execution during graceful Node server shutdown.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • nitrojs/nitro#4502 — Addresses the Node server shutdown regression by invoking Nitro’s close hook.
  • nitrojs/nitro#4015 — Wires Nitro’s close hook into Node server and cluster shutdown paths.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits format and clearly describes the server shutdown hook fix.
Description check ✅ Passed The description clearly explains the bug, implementation, testing, scope, and limitations related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/presets/node/runtime/node-cluster.ts (1)

35-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicate implementation comments.

  • src/presets/node/runtime/node-cluster.ts#L35-L35: Remove the comment.
  • src/presets/node/runtime/node-server.ts#L29-L29: Remove the comment.

As per coding guidelines, “Do not add comments explaining what the line does unless prompted.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/presets/node/runtime/node-cluster.ts` at line 35, Remove the duplicate
implementation comments in src/presets/node/runtime/node-cluster.ts lines 35-35
and src/presets/node/runtime/node-server.ts lines 29-29, leaving the surrounding
shutdown and close-hook logic unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/presets/node.test.ts`:
- Around line 61-85: The spawned server in the test flow must always be
terminated, including when waitForPort, the close wait, or an assertion fails.
Wrap the startup, waiting, assertions, and close-hook handling around the child
created by execa in a try block, and move the SIGKILL cleanup into finally while
preserving the existing graceful SIGTERM and close-marker behavior.

---

Nitpick comments:
In `@src/presets/node/runtime/node-cluster.ts`:
- Line 35: Remove the duplicate implementation comments in
src/presets/node/runtime/node-cluster.ts lines 35-35 and
src/presets/node/runtime/node-server.ts lines 29-29, leaving the surrounding
shutdown and close-hook logic unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bccb7ec-22c1-45b5-961b-e142b9c870ba

📥 Commits

Reviewing files that changed from the base of the PR and between 52abde8 and 22835fe.

📒 Files selected for processing (4)
  • src/presets/node/runtime/node-cluster.ts
  • src/presets/node/runtime/node-server.ts
  • test/fixture/server/plugins/close.ts
  • test/presets/node.test.ts

Comment thread test/presets/node.test.ts
Comment on lines +61 to +85
const child = execa(process.execPath, [entryPath], { env, extendEnv: false, reject: false });

let output = "";
child.stdout!.on("data", (data) => (output += data));
child.stderr!.on("data", (data) => (output += data));

await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" });

child.kill("SIGTERM");
// Wait for the close hook marker or process exit (the fixture task scheduler
// can keep the event loop alive after the server closed)
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 10_000);
child.on("close", () => {
clearTimeout(timeout);
resolve();
});
child.stdout!.on("data", (data) => {
if (String(data).includes("[fixture] close hook called")) {
clearTimeout(timeout);
resolve();
}
});
});
child.kill("SIGKILL");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files | rg '(^|/)test/presets/node\.test\.ts$|package\.json$|vitest|tsconfig' || true

echo "== file excerpt =="
if [ -f test/presets/node.test.ts ]; then
  cat -n test/presets/node.test.ts | sed -n '1,140p'
fi

echo "== package scripts/config =="
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log(JSON.stringify({scripts:p.scripts,devDependencies:p.devDependencies,dependencies:p.dependencies}, null, 2))"
fi

Repository: nitrojs/nitro

Length of output: 11216


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== executable/kill availability =="
command -v node || true
command -v kill || true
node --version || true

echo "== Node child.spawn behavior probe =="
node - <<'JS'
const { spawn } = require('node:child_process');
const script = __filename;
let closed = false;
let exited = false;

const child = spawn(process.execPath, [script]);
child.stdout.on('data', () => {
  console.log('READY');
  // Stop accepting signals after first READY to keep behavior deterministic.
  child.kill = () => {};
});
child.on('close', () => { closed = true; });
child.on('exit', () => { exited = true; });

setTimeout(() => {
  // Simulate the first SIGTERM path taking no effect before async exit handling;
  // this mirrors a server that simply ignores SIGTERM or does not yet attach the close handler.
  child.kill('SIGTERM');
  // Force kill only after the process has had time enough to report READY.
  setTimeout(() => {
    child.kill('SIGKILL');
    setTimeout(() => {
      console.log(String({ closed, exited, nodeVersion: process.version }));
      process.exit(0);
    }, 500);
  }, 400);
}, 100);
JS

echo "== Vitest lifecycle/error context =="
cat -n vitest.config.ts | sed -n '1,220p'
rg -n "setupFiles|globalTeardown|testTimeout|pool|threads|forks|isolated" test vitest.config.ts package.json

Repository: nitrojs/nitro

Length of output: 769


🌐 Web query:

Vitest test timeout skip throw skipped test lifecycle hooks after skip

💡 Result:

In Vitest, when a test is marked with.skip (or test.skip) or skipped dynamically via the skip function from the test context, the test body itself is not executed [1][2]. Regarding the lifecycle hooks, the behavior depends on whether the test is skipped statically or dynamically [1][3]: 1. Statically Skipped Tests (.skip): When a test is defined with.skip (e.g., test.skip('name',...)), it is marked as skipped during the test collection phase. Consequently, no lifecycle hooks associated with that specific test (such as beforeEach, afterEach, or test-scoped fixtures) are executed for that test [4][2]. 2. Dynamically Skipped Tests (context.skip): If you call the skip function from within the test context (e.g., inside the test body or a beforeEach hook), Vitest treats this as an instruction to abort the execution of that specific test [3]. When skip is called, it throws an internal "PendingError" to halt execution [3]. Because this occurs after the test lifecycle has already begun, any hooks that ran prior to the call (like beforeEach) will have already executed [4][5]. However, because the test is aborted, subsequent lifecycle steps for that specific test—such as the test body itself, afterEach hooks, and onTestFinished/onTestFailed callbacks—will typically be skipped or behave according to the runner's internal state management for cancelled/skipped tests [4][3][5]. Summary of Behavior: - Static Skipping (.skip): No test-specific lifecycle hooks run. - Dynamic Skipping (context.skip): Lifecycle hooks that precede the skip call will run; hooks scheduled to run after the skip call (including the test body) will not execute [4][5]. Suite-level hooks (beforeAll, afterAll) are generally independent of individual test skips, as they are scoped to the entire suite or file rather than the individual test [4][6]. If a beforeAll hook fails, Vitest marks the remaining tests in that suite as skipped [7].

Citations:


Always terminate the spawned server.

If waitForPort, the close wait, or an assertion throws, Line 85 is skipped and the generated server can remain running. Put the shutdown sequence in a try block and force termination in finally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/presets/node.test.ts` around lines 61 - 85, The spawned server in the
test flow must always be terminated, including when waitForPort, the close wait,
or an assertion fails. Wrap the startup, waiting, assertions, and close-hook
handling around the child created by execa in a try block, and move the SIGKILL
cleanup into finally while preserving the existing graceful SIGTERM and
close-marker behavior.

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 does not call the close hook on shutdown (regression from v2)

1 participant