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
- 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");
});
});
- Build and run with the Node server preset (
node-server).
- Send SIGTERM (e.g.,
docker stop, kill -TERM, Kubernetes pod eviction, ECS task stop).
- 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)
Environment
Reproduction
closehook:node-server).docker stop,kill -TERM, Kubernetes pod eviction, ECS task stop).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
closehook on the runtimeNitroAppinstance 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:When srvx receives SIGTERM, its
gracefulShutdownPlugincatches the signal, callsserver.close(), and exits. At no point does anything callnitroApp.hooks.callHook("close").The
closehook does exist and work at build time --nitro.close()in the build-time Nitro instance callsnitro.hooks.callHook("close"). But the runtimeuseNitroApp()instance that runs in production has no equivalent bridge.This makes it impossible to:
Observed behavior (Docker)
With a plugin that registers both a
closehook and a directprocess.on("SIGTERM")workaround:Test 1: Relying on the
closehook (no workaround)Test 2: Direct
process.on("SIGTERM")workaroundThe 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:
NITRO_SHUTDOWN_*env vars to srvx options. Notes theprocess.exit(0)problem.process.exit(0)in graceful shutdown plugin prevents application-level async cleanup h3js/srvx#178 -- Filed companion issue for the srvx side (process.exit(0)preventing async cleanup).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:If srvx adds an
onShutdownhook -- Nitro passes it through:Logs