fix: survive SSE client disconnects on Bun 1.4 (#465) (#466)

On Bun 1.4 the node adapter's reader cancel closes the stream before the
request abort fires, so the abort listener's controller.close() threw
ERR_INVALID_STATE as an uncaught exception and the process shut itself
down whenever a dashboard tab disconnected. Both SSE routes now guard the
close and clear their timers on cancel too.

A shutdown forced by an uncaught exception or unhandled rejection now
exits with status 1 instead of 0, so a unit with Restart=on-failure
(the Proxmox community script) starts the service again.
This commit is contained in:
ARUNAVO RAY authored and GitHub committed 2026-10-15 18:46:56 +05:30
1 parent 41799154fc
commit 4f96e7dfbf
5 files changed
+146 -9

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { shutdownExitCode } from "./shutdown-manager";
describe("shutdownExitCode (#465)", () => {
test("a crash-driven shutdown exits non-zero so Restart=on-failure restarts the service", () => {
expect(shutdownExitCode("UNCAUGHT_EXCEPTION")).toBe(1);
expect(shutdownExitCode("UNHANDLED_REJECTION")).toBe(1);
});
test("a signal-driven stop is a clean exit", () => {
expect(shutdownExitCode("SIGTERM")).toBe(0);
expect(shutdownExitCode("SIGINT")).toBe(0);
expect(shutdownExitCode("SIGHUP")).toBe(0);
});
});
+9 -2
View File
@@ -147,6 +147,11 @@ async function executeShutdownCallbacks(): Promise<void> {
/**
* Perform graceful shutdown of the application
*/
/** Exit status for a completed shutdown: 1 after a crash, 0 after a signal. */
export function shutdownExitCode(signal: string): number {
return signal === 'UNCAUGHT_EXCEPTION' || signal === 'UNHANDLED_REJECTION' ? 1 : 0;
}
export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void> {
if (shutdownInProgress) {
console.log('⚠️ Shutdown already in progress, ignoring additional signal');
@@ -187,8 +192,10 @@ export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void
shutdownTimeout = null;
}
// Exit with success code
process.exit(0);
// A shutdown forced by a crash exits non-zero so a supervisor with
// Restart=on-failure (the Proxmox script's unit) starts the service
// again; a signal-driven stop is a clean exit (#465).
process.exit(shutdownExitCode(signal));
} catch (error) {
console.error('\n❌ Error during graceful shutdown:', error);
+85
View File
@@ -0,0 +1,85 @@
/**
* Regression test for #465: a client disconnect took the whole server down.
*
* The node adapter cancels the stream reader when the socket closes and then
* fires the request's abort signal. On Bun 1.4 the cancel has already closed
* the stream, so an unguarded controller.close() in the abort listener throws
* ERR_INVALID_STATE as an uncaught exception, and the uncaught-exception
* handler shut the process down. Both SSE routes are covered.
*
* The routes import @/lib/auth-guards and @/lib/events, replaced here with
* mock.module, which is process wide in bun, so the suite runs in an isolated
* child process (same harness as the other route tests).
*/
import { describe, expect, mock, test } from "bun:test";
const CHILD_FLAG = "GM_SSE_ROUTES_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("SSE routes - isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
if (isChild) {
mock.module("@/lib/auth-guards", () => ({
requireAuthenticatedUserId: async () => ({ userId: "user-1" }),
}));
mock.module("@/lib/events", () => ({
getNewEvents: async () => [],
}));
}
const routes = isChild
? {
events: (await import("./index")).GET,
sse: (await import("../sse/index")).GET,
}
: ({} as Record<string, any>);
/**
* Opens the stream, then closes it the way the node adapter does on a
* disconnect: the reader is cancelled first, the abort signal fires second.
*/
async function disconnect(route: any) {
const abort = new AbortController();
const request = new Request("http://localhost/api/x", { signal: abort.signal });
const response = await route({ request, locals: {} });
const reader = response.body.getReader();
const first = await reader.read();
expect(new TextDecoder().decode(first.value)).toContain(": connected");
const uncaught: unknown[] = [];
const record = (error: unknown) => uncaught.push(error);
process.on("uncaughtException", record);
try {
await reader.cancel();
abort.abort();
await new Promise((resolve) => setTimeout(resolve, 20));
} finally {
process.off("uncaughtException", record);
}
return uncaught;
}
describe.skipIf(!isChild)("SSE routes survive a client disconnect (#465)", () => {
test("/api/events", async () => {
expect(await disconnect(routes.events)).toEqual([]);
});
test("/api/sse", async () => {
expect(await disconnect(routes.sse)).toEqual([]);
});
});
+22 -3
View File
@@ -8,6 +8,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
// Create a new ReadableStream for SSE
let cancelStream: (() => void) | undefined;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
@@ -47,12 +48,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
}
}, 30000);
// Cleanup on close
request.signal.addEventListener("abort", () => {
// Cleanup on close. When the client disconnects, the node adapter
// cancels the stream reader and then fires this abort; on Bun 1.4 the
// cancel has already closed the stream, so close() throws
// ERR_INVALID_STATE inside an event listener, an uncaught exception
// that took the whole process down (#465).
let closed = false;
const stop = () => {
if (closed) return;
closed = true;
clearInterval(pollInterval);
clearInterval(heartbeatInterval);
controller.close();
};
request.signal.addEventListener("abort", () => {
stop();
try {
controller.close();
} catch {
// Already closed by the reader cancel.
}
});
cancelStream = stop;
},
cancel() {
cancelStream?.();
},
});
+15 -4
View File
@@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
let isClosed = false;
const POLL_INTERVAL = 5000; // Poll every 5 seconds (reduced from 2 seconds for low-traffic usage)
let cancelStream: (() => void) | undefined;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
@@ -73,20 +74,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
}
}, 30000);
// Handle client disconnection
// Handle client disconnection. The reader cancel runs first on Bun 1.4
// and closes the stream, so close() can throw ERR_INVALID_STATE here;
// inside an event listener that is an uncaught exception (#465).
request.signal?.addEventListener("abort", () => {
if (!isClosed) {
isClosed = true;
if (pollIntervalId) {
clearInterval(pollIntervalId);
}
controller.close();
try {
controller.close();
} catch {
// Already closed by the reader cancel.
}
}
});
cancelStream = () => {
isClosed = true;
if (pollIntervalId) clearInterval(pollIntervalId);
clearInterval(heartbeatInterval);
};
},
cancel() {
// Extra safety in case cancel is triggered
isClosed = true;
cancelStream?.();
},
});