Skip to content

Commit 39cdfea

Browse files
committed
fix(bun): call close hooks on server shutdown
The bun preset delegates shutdown to srvx, which closes the server on SIGINT/SIGTERM without calling Nitro's runtime `close` hook. Cleanup handlers registered via the `close` hook were silently skipped in production. Wrap `server.close()` to run the `close` hooks after the server closes, mirroring the node preset fix in #4522. `deno_server` has the same gap.
1 parent 16ff280 commit 39cdfea

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/presets/bun/runtime/bun.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ServerRequest } from "srvx";
33
import { serve } from "srvx/bun";
44
import wsAdapter from "crossws/adapters/bun";
55

6-
import { useNitroApp } from "nitro/app";
6+
import { useNitroApp, useNitroHooks } from "nitro/app";
77
import { startScheduleRunner } from "#nitro/runtime/task";
88
import { trapUnhandledErrors } from "#nitro/runtime/error/hooks";
99
import { resolveWebsocketHooks } from "#nitro/runtime/app";
@@ -41,6 +41,22 @@ const server = serve({
4141
plugins: [...tracingSrvxPlugins],
4242
});
4343

44+
// Run `close` hooks on server shutdown (srvx closes the server on `SIGINT`/`SIGTERM`)
45+
const closeServer = server.close.bind(server);
46+
let closeHooksCalled = false;
47+
server.close = async (closeActiveConnections?: boolean) => {
48+
try {
49+
await closeServer(closeActiveConnections);
50+
} finally {
51+
if (!closeHooksCalled) {
52+
closeHooksCalled = true;
53+
await useNitroHooks()
54+
.callHook("close")
55+
?.catch((error) => console.error("[close]", error));
56+
}
57+
}
58+
};
59+
4460
trapUnhandledErrors();
4561

4662
// Scheduled tasks
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { definePlugin } from "nitro";
2+
3+
export default definePlugin((nitroApp) => {
4+
nitroApp.hooks.hook("close", () => {
5+
if (globalThis.process?.env?.NITRO_TEST_CLOSE_HOOK) {
6+
console.log("[fixture] close hook called");
7+
}
8+
});
9+
});

test/presets/bun.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { execa, execaCommandSync } from "execa";
2+
import { isWindows } from "std-env";
23
import { getRandomPort, waitForPort } from "get-port-please";
34
import { resolve } from "pathe";
4-
import { describe } from "vitest";
5+
import { describe, it, expect } from "vitest";
56
import { setupTest, testNitro } from "../tests.ts";
67

78
const hasBun = execaCommandSync("bun --version", { stdio: "ignore", reject: false }).exitCode === 0;
@@ -26,4 +27,52 @@ describe.runIf(hasBun)("nitro:preset:bun", async () => {
2627
return res;
2728
};
2829
});
30+
it.skipIf(isWindows)(
31+
"calls the `close` hook on shutdown",
32+
async () => {
33+
const port = await getRandomPort();
34+
const entryPath = resolve(ctx.outDir, "server/index.mjs");
35+
// srvx graceful shutdown is disabled when the CI/TEST env vars are set,
36+
// so drop them for this child to exercise the real SIGTERM path.
37+
const env: Record<string, string | undefined> = {
38+
...process.env,
39+
PORT: String(port),
40+
NITRO_HOST: "127.0.0.1",
41+
NITRO_TEST_CLOSE_HOOK: "true",
42+
};
43+
delete env.CI;
44+
delete env.TEST;
45+
const child = execa("bun", [entryPath], {
46+
env,
47+
extendEnv: false,
48+
reject: false,
49+
});
50+
51+
let output = "";
52+
child.stdout!.on("data", (data) => (output += data));
53+
child.stderr!.on("data", (data) => (output += data));
54+
55+
await waitForPort(port, { delay: 1000, retries: 20, host: "127.0.0.1" });
56+
57+
child.kill("SIGTERM");
58+
await new Promise<void>((r) => {
59+
const timeout = setTimeout(r, 10_000);
60+
child.on("close", () => {
61+
clearTimeout(timeout);
62+
r();
63+
});
64+
child.stdout!.on("data", (data) => {
65+
if (String(data).includes("[fixture] close hook called")) {
66+
clearTimeout(timeout);
67+
r();
68+
}
69+
});
70+
});
71+
child.kill("SIGKILL");
72+
73+
expect(output).toContain("[fixture] close hook called");
74+
expect(output).not.toContain("unhandledRejection");
75+
},
76+
40_000
77+
);
2978
});

0 commit comments

Comments
 (0)