Skip to content

Commit 853f67f

Browse files
fix: call runtime close hook on shutdown signals
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 #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 #4015 Resolves #2735 Resolves #2566 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e102ff6 commit 853f67f

8 files changed

Lines changed: 84 additions & 8 deletions

File tree

docs/1.docs/50.plugins.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ export default definePlugin((nitro) => {
5454

5555
### Available hooks
5656

57-
- `"request", (event) => {}`
58-
- `"error", (error, { event? }) => {}`
59-
- `"response", (event, { body }) => {}`
57+
- `"request", (event) => {}` - Called when a request is received.
58+
- `"error", (error, { event? }) => {}` - Called when an error is captured.
59+
- `"response", (response, event) => {}` - Called when a response is sent.
60+
- `"close", () => {}` - Called when the server receives a shutdown signal (`SIGTERM` or `SIGINT`).
6061

6162
## Examples
6263

@@ -76,7 +77,18 @@ export default definePlugin((nitro) => {
7677

7778
### Graceful shutdown
7879

79-
Server will gracefully shutdown and wait for any background pending tasks initiated by event.waitUntil
80+
When the server receives a shutdown signal (`SIGTERM` or `SIGINT`), the `close` hook is called, allowing plugins to run async cleanup before the process exits. This is useful for flushing telemetry, draining database connections, stopping job queues, and other teardown tasks.
81+
82+
```ts
83+
import { definePlugin } from "nitro";
84+
85+
export default definePlugin((nitro) => {
86+
nitro.hooks.hook("close", async () => {
87+
await flushTelemetry();
88+
await db.close();
89+
});
90+
})
91+
```
8092

8193
### Request and response lifecycle
8294

docs/2.deploy/10.runtimes/1.node.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@ You can customize server behavior using following environment variables:
3333
- `NITRO_HOST` or `HOST`
3434
- `NITRO_UNIX_SOCKET` - if provided (a path to the desired socket file) the service will be served over the provided UNIX socket.
3535
- `NITRO_SSL_CERT` and `NITRO_SSL_KEY` - if both are present, this will launch the server in HTTPS mode. In the vast majority of cases, this should not be used other than for testing, and the Nitro server should be run behind a reverse proxy like nginx or Cloudflare which terminates SSL.
36-
- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. If it's set to `'true'`, the graceful shutdown is bypassed to speed up the development process. Defaults to `'false'`.
37-
- `NITRO_SHUTDOWN_SIGNALS` - Allows you to specify which signals should be handled. Each signal should be separated with a space. Defaults to `'SIGINT SIGTERM'`.
38-
- `NITRO_SHUTDOWN_TIMEOUT` - Sets the amount of time (in milliseconds) before a forced shutdown occurs. Defaults to `'30000'` milliseconds.
39-
- `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'`.
36+
- `NITRO_SHUTDOWN_DISABLED` - Disables the graceful shutdown feature when set to `'true'`. Defaults to `'false'`.
4037

4138
## Cluster mode
4239

src/presets/bun/runtime/bun.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/bun";
66
import { useNitroApp } from "nitro/app";
77
import { startScheduleRunner } from "#nitro/runtime/task";
88
import { trapUnhandledErrors } from "#nitro/runtime/error/hooks";
9+
import { setupShutdownHooks } from "#nitro/runtime/shutdown";
910
import { resolveWebsocketHooks } from "#nitro/runtime/app";
1011

1112
const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? "");
@@ -41,6 +42,7 @@ serve({
4142
});
4243

4344
trapUnhandledErrors();
45+
setupShutdownHooks();
4446

4547
// Scheduled tasks
4648
if (import.meta._tasks) {

src/presets/deno/runtime/deno-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/deno";
66
import { useNitroApp } from "nitro/app";
77
import { startScheduleRunner } from "#nitro/runtime/task";
88
import { trapUnhandledErrors } from "#nitro/runtime/error/hooks";
9+
import { setupShutdownHooks } from "#nitro/runtime/shutdown";
910
import { resolveWebsocketHooks } from "#nitro/runtime/app";
1011

1112
const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? "");
@@ -38,6 +39,7 @@ serve({
3839
});
3940

4041
trapUnhandledErrors();
42+
setupShutdownHooks();
4143

4244
// Scheduled tasks
4345
if (import.meta._tasks) {

src/presets/node/runtime/node-cluster.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import wsAdapter from "crossws/adapters/node";
66
import { useNitroApp } from "nitro/app";
77
import { startScheduleRunner } from "#nitro/runtime/task";
88
import { trapUnhandledErrors } from "#nitro/runtime/error/hooks";
9+
import { setupShutdownHooks } from "#nitro/runtime/shutdown";
910
import { resolveWebsocketHooks } from "#nitro/runtime/app";
1011

1112
const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? "");
@@ -46,6 +47,7 @@ if (import.meta._websocket) {
4647
}
4748

4849
trapUnhandledErrors();
50+
setupShutdownHooks();
4951

5052
// Scheduled tasks
5153
if (import.meta._tasks) {

src/presets/node/runtime/node-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import wsAdapter from "crossws/adapters/node";
55
import { useNitroApp } from "nitro/app";
66
import { startScheduleRunner } from "#nitro/runtime/task";
77
import { trapUnhandledErrors } from "#nitro/runtime/error/hooks";
8+
import { setupShutdownHooks } from "#nitro/runtime/shutdown";
89
import { resolveWebsocketHooks } from "#nitro/runtime/app";
910

1011
const _parsedPort = Number.parseInt(process.env.NITRO_PORT ?? process.env.PORT ?? "");
@@ -38,6 +39,7 @@ if (import.meta._websocket) {
3839
}
3940

4041
trapUnhandledErrors();
42+
setupShutdownHooks();
4143

4244
// Scheduled tasks
4345
if (import.meta._tasks) {

src/runtime/internal/shutdown.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useNitroApp } from "../app.ts";
2+
3+
export function setupShutdownHooks() {
4+
const handler = () => {
5+
useNitroApp().hooks?.callHook("close");
6+
};
7+
for (const sig of ["SIGTERM", "SIGINT"] as const) {
8+
process.on(sig, handler);
9+
}
10+
}

test/unit/shutdown.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const callHook = vi.fn();
4+
5+
vi.mock("../../src/runtime/internal/app.ts", () => ({
6+
useNitroApp: () => ({
7+
hooks: { callHook },
8+
}),
9+
}));
10+
11+
import { setupShutdownHooks } from "../../src/runtime/internal/shutdown.ts";
12+
13+
describe("setupShutdownHooks", () => {
14+
let savedSIGTERM: Function[];
15+
let savedSIGINT: Function[];
16+
17+
beforeEach(() => {
18+
savedSIGTERM = process.listeners("SIGTERM").slice();
19+
savedSIGINT = process.listeners("SIGINT").slice();
20+
callHook.mockClear();
21+
});
22+
23+
afterEach(() => {
24+
process.removeAllListeners("SIGTERM");
25+
process.removeAllListeners("SIGINT");
26+
for (const fn of savedSIGTERM) process.on("SIGTERM", fn as NodeJS.SignalsListener);
27+
for (const fn of savedSIGINT) process.on("SIGINT", fn as NodeJS.SignalsListener);
28+
});
29+
30+
it("registers SIGTERM and SIGINT handlers", () => {
31+
const beforeTERM = process.listenerCount("SIGTERM");
32+
const beforeINT = process.listenerCount("SIGINT");
33+
setupShutdownHooks();
34+
expect(process.listenerCount("SIGTERM")).toBe(beforeTERM + 1);
35+
expect(process.listenerCount("SIGINT")).toBe(beforeINT + 1);
36+
});
37+
38+
it("calls close hook on SIGTERM", () => {
39+
setupShutdownHooks();
40+
process.emit("SIGTERM", "SIGTERM");
41+
expect(callHook).toHaveBeenCalledWith("close");
42+
});
43+
44+
it("calls close hook on SIGINT", () => {
45+
setupShutdownHooks();
46+
process.emit("SIGINT", "SIGINT");
47+
expect(callHook).toHaveBeenCalledWith("close");
48+
});
49+
});

0 commit comments

Comments
 (0)