Skip to content

Node server preset never calls nitro.hooks.callHook("close") on shutdown #4015

@wadefletch

Description

@wadefletch

Environment

  • Nitro (nightly): 3.0.1-20260202-124820-1954b824
  • srvx: 0.10.1
  • Node.js: 22
  • Runtime: Docker on ECS/Fargate (receives SIGTERM from ECS task stop / rolling deployment)

Reproduction

  1. Create a Nitro plugin that registers a close hook:
// server/plugins/shutdown.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook("close", async () => {
    console.log("[shutdown] Starting cleanup...");
    await flushTelemetry();
    await stopJobQueue();
    console.log("[shutdown] Cleanup complete");
  });
});
  1. Build and run with the Node server preset (node-server).
  2. Send SIGTERM (e.g., docker stop, kill -TERM, Kubernetes pod eviction, ECS task stop).
  3. Observe that neither "[shutdown] Starting cleanup..." nor "[shutdown] Cleanup complete" appear in logs.

Describe the bug

When a Nitro application receives SIGTERM/SIGINT in production, there is no way for Nitro plugins to run async cleanup before the process exits. The close hook on the runtime NitroApp instance is never triggered during shutdown.

The node server entry point (src/presets/node/runtime/node-server.ts) creates a srvx server and passes Nitro's fetch handler, but establishes no lifecycle connection between srvx's shutdown and Nitro's hook system:

const server = serve({
  port,
  hostname: host,
  fetch: nitroApp.fetch,  // <-- only connection between srvx and Nitro
});

When srvx receives SIGTERM, its gracefulShutdownPlugin catches the signal, calls server.close(), and exits. At no point does anything call nitroApp.hooks.callHook("close").

The close hook does exist and work at build time -- nitro.close() in the build-time Nitro instance calls nitro.hooks.callHook("close"). But the runtime useNitroApp() instance that runs in production has no equivalent bridge.

This makes it impossible to:

  • Flush telemetry/tracing pipelines (OpenTelemetry, Datadog dd-trace)
  • Stop background job processors (pg-boss, BullMQ)
  • Drain database connection pools
  • Flush buffered log transports (pino, winston)

Observed behavior (Docker)

With a plugin that registers both a close hook and a direct process.on("SIGTERM") workaround:

Test 1: Relying on the close hook (no workaround)

$ docker stop app
# srvx output: "Shutting down server in 25s... Server closed."
# ExitCode=0
# Plugin shutdown logs: NONE -- close hook never called

Test 2: Direct process.on("SIGTERM") workaround

$ docker stop app
# srvx output: "Shutting down server in 25s... Server closed."
# ExitCode=0
# Plugin logs: "[shutdown] Shutdown initiated" -- appears
# Plugin logs: "[shutdown] Shutdown complete" -- MISSING

The workaround fires the handler, but srvx's process.exit(0) (h3js/srvx#178) kills the process before async cleanup completes. Both issues must be fixed for graceful shutdown to work end-to-end.

Additional context

Related issues:

Proposed fix:

The node server entry point needs to bridge srvx's shutdown to Nitro's close hook. Depending on how h3js/srvx#178 is resolved:

If srvx removes process.exit(0) -- Nitro registers its own signal handlers:

// In node-server entry
for (const sig of ["SIGTERM", "SIGINT"]) {
  process.on(sig, () => {
    nitroApp.hooks.callHook("close");
  });
}

If srvx adds an onShutdown hook -- Nitro passes it through:

const server = serve({
  fetch: nitroApp.fetch,
  onShutdown: () => nitroApp.hooks.callHook("close"),
});

Logs

# Expected (close hook fires and completes):
[shutdown] Starting cleanup...
[shutdown] Cleanup complete

# Actual (close hook never called):
# (nothing)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions