fix: memory leak is sse events cleanup

This commit is contained in:
Nicolas Meienberger 2026-02-11 17:38:20 +01:00
parent a1ac62bce2
commit b5c8f71d88
2 changed files with 59 additions and 10 deletions

View file

@ -1,5 +1,6 @@
import { test, describe, expect } from "bun:test";
import { createApp } from "~/server/app";
import { serverEvents } from "~/server/core/events";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
const app = createApp();
@ -30,6 +31,32 @@ describe("events security", () => {
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
await res.body?.cancel();
});
test("should cleanup SSE listeners when client disconnects", async () => {
const { token } = await createTestSession();
const initialCount = serverEvents.listenerCount("doctor:cancelled");
const res = await app.request("/api/v1/events", {
headers: getAuthHeaders(token),
});
expect(res.status).toBe(200);
for (let i = 0; i < 20 && serverEvents.listenerCount("doctor:cancelled") < initialCount + 1; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(serverEvents.listenerCount("doctor:cancelled")).toBe(initialCount + 1);
await res.body?.cancel();
for (let i = 0; i < 20 && serverEvents.listenerCount("doctor:cancelled") > initialCount; i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(serverEvents.listenerCount("doctor:cancelled")).toBe(initialCount);
});
describe("unauthenticated access", () => {

View file

@ -162,10 +162,13 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
serverEvents.on("doctor:cancelled", onDoctorCancelled);
let keepAlive = true;
let cleanedUp = false;
stream.onAbort(() => {
logger.info("Client disconnected from SSE endpoint");
keepAlive = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
serverEvents.off("backup:started", onBackupStarted);
serverEvents.off("backup:progress", onBackupProgress);
serverEvents.off("backup:completed", onBackupCompleted);
@ -177,14 +180,33 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
serverEvents.off("doctor:started", onDoctorStarted);
serverEvents.off("doctor:completed", onDoctorCompleted);
serverEvents.off("doctor:cancelled", onDoctorCancelled);
});
}
while (keepAlive) {
await stream.writeSSE({
data: JSON.stringify({ timestamp: Date.now() }),
event: "heartbeat",
});
await stream.sleep(5000);
function handleDisconnect() {
if (!keepAlive) return;
logger.info("Client disconnected from SSE endpoint");
keepAlive = false;
cleanup();
}
function onRequestAbort() {
handleDisconnect();
stream.abort();
}
stream.onAbort(handleDisconnect);
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
try {
while (keepAlive && !c.req.raw.signal.aborted && !stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ timestamp: Date.now() }),
event: "heartbeat",
});
await stream.sleep(5000);
}
} finally {
cleanup();
}
});
});