mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
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:
1 parent
41799154fc
commit
4f96e7dfbf
5 files changed
+146
-9
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user