From 2dfc67e03553b10e5627f93f934eef4a92649700 Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Thu, 23 Jul 2026 08:01:08 -0300 Subject: [PATCH] test(#8140): verify keepalive interval cleanup on disconnect, resolve, and reject (#8190) * fix(ci): resolve upstream-inherited check failures * test(#8140): verify keepalive interval cleanup on disconnect, resolve, and reject Closes #8140 Adds 3 unit tests covering earlyStreamKeepalive timer cleanup: - Client disconnect (abort signal): interval cleared, no leaked timers - Handler resolves normally (slow path): interval cleared in finally block - Handler rejects (slow path): interval cleared despite error Each test verifies handle count stability across a 30ms gap to ensure no leaked setInterval keeps ticking after stream closure. --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- tests/unit/keepalive-cleanup-8140.test.ts | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/unit/keepalive-cleanup-8140.test.ts diff --git a/tests/unit/keepalive-cleanup-8140.test.ts b/tests/unit/keepalive-cleanup-8140.test.ts new file mode 100644 index 0000000000..a45edfb131 --- /dev/null +++ b/tests/unit/keepalive-cleanup-8140.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { withEarlyStreamKeepalive } from "../../open-sse/utils/earlyStreamKeepalive.ts"; + +/** + * #8140: Verify the keepalive interval is cleaned up after disconnect/completion. + * + * The keepalive interval is unref'd (line 120 of earlyStreamKeepalive.ts), so it + * won't appear in process._getActiveHandles(). Instead we verify cleanup indirectly: + * after the stream closes, timer counts must be stable (not growing), proving no + * leaked interval is still ticking. + */ + +function countTimeoutHandles(): number { + const handles = ( + process as unknown as { + _getActiveHandles: () => unknown[]; + } + )._getActiveHandles(); + return handles.length; +} + +function sseResponse(body: string): Response { + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +test("#8140: timer count is stable after client disconnect (no leaked interval)", async () => { + const controller = new AbortController(); + const result = await withEarlyStreamKeepalive(new Promise(() => {}), { + thresholdMs: 5, + intervalMs: 15, + signal: controller.signal, + }); + + const reader = result.body!.getReader(); + const { value } = await reader.read(); + assert.ok(value && value.byteLength > 0, "should emit at least one keepalive frame"); + + controller.abort(); + + // Drain until closed. + const drained = (async () => { + while (true) { + const { done } = await reader.read(); + if (done) return true; + } + })(); + const timed = new Promise((r) => setTimeout(() => r(false), 2000)); + assert.equal(await Promise.race([drained, timed]), true, "stream should close after abort"); + + await new Promise((r) => setTimeout(r, 30)); + + const count1 = countTimeoutHandles(); + await new Promise((r) => setTimeout(r, 30)); + const count2 = countTimeoutHandles(); + assert.equal( + count1, + count2, + "handle count should be stable after disconnect (no leaked interval ticking)" + ); +}); + +test("#8140: timer count is stable after handler resolves normally (slow path)", async () => { + const slowHandler = new Promise((resolve) => { + setTimeout(() => resolve(sseResponse("data: [DONE]\n\n")), 80); + }); + + const result = await withEarlyStreamKeepalive(slowHandler, { + thresholdMs: 10, + intervalMs: 15, + }); + + const reader = result.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + await new Promise((r) => setTimeout(r, 30)); + + const count1 = countTimeoutHandles(); + await new Promise((r) => setTimeout(r, 30)); + const count2 = countTimeoutHandles(); + assert.equal(count1, count2, "handle count should be stable after normal completion"); +}); + +test("#8140: timer count is stable after handler rejects (slow path)", async () => { + const slowFail = new Promise((_, reject) => { + setTimeout(() => reject(new Error("upstream died")), 80); + }); + + const result = await withEarlyStreamKeepalive(slowFail, { + thresholdMs: 10, + intervalMs: 15, + }); + + const reader = result.body!.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + await new Promise((r) => setTimeout(r, 30)); + + const count1 = countTimeoutHandles(); + await new Promise((r) => setTimeout(r, 30)); + const count2 = countTimeoutHandles(); + assert.equal(count1, count2, "handle count should be stable after error completion"); +});