-
-
Notifications
You must be signed in to change notification settings - Fork 779
Description
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
closehook:
// 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:
- Close Hook not awaited #2735 -- "Close Hook not awaited" (open, Sep 2024). Same root cause.
- Close hook not executed with 'bun' build preset #2566 -- "Close hook not executed with 'bun' build preset" (open, Jun 2024). Same family of issues.
- feat(node,deno,bun): implement env vars, remove srvx-deprecated ones, fix handler docs #4013 -- PR wiring
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:
// 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)