Skip to content

Commit 326d05f

Browse files
CopilotJavanPoirier
andcommitted
fix(sse): cleanup when app-level retries are exhausted to prevent memory leak
Co-authored-by: JavanPoirier <42590458+JavanPoirier@users.noreply.github.com>
1 parent 9cb2884 commit 326d05f

3 files changed

Lines changed: 32 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solid-primitives/sse": patch
3+
---
4+
5+
Fix memory leak when app-level retries are exhausted in `createSSE`. Previously, when all reconnect attempts were used up and the `EventSource` was permanently closed, `currentCleanup` was never called — leaving the `EventSource` instance and its event listeners alive in memory, and the `source` signal pointing to a stale handle. Now an `else if` branch explicitly calls `currentCleanup()`, clears the reference, and sets the `source` signal to `undefined`.

packages/sse/src/sse.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,11 @@ export const createSSE = <T = string>(
260260
if (es.readyState === SSEReadyState.CLOSED && retriesLeft > 0) {
261261
retriesLeft--;
262262
reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000);
263+
} else if (es.readyState === SSEReadyState.CLOSED) {
264+
// Retries exhausted — clean up fully to avoid memory/listener leaks.
265+
currentCleanup?.();
266+
currentCleanup = undefined;
267+
setSource(undefined);
263268
}
264269
};
265270

packages/sse/test/index.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,31 @@ describe("createSSE", () => {
181181
const first = source();
182182
(first as unknown as MockEventSource).simulateError();
183183
vi.advanceTimersByTime(100); // first retry
184-
const second = source();
184+
const second = source() as unknown as MockEventSource;
185185
expect(second).not.toBe(first);
186186
vi.advanceTimersByTime(20); // second opens
187-
(second as unknown as MockEventSource).simulateError();
187+
const closeSpy = vi.spyOn(second, "close");
188+
second.simulateError();
188189
vi.advanceTimersByTime(200); // no more retries
189-
expect(source()).toBe(second); // still the same source
190+
// retries exhausted: close() was called and source signal is cleared
191+
expect(closeSpy).toHaveBeenCalledOnce();
192+
expect(source()).toBeUndefined();
193+
dispose();
194+
}));
195+
196+
it("cleans up source and listeners when retries are exhausted", () =>
197+
createRoot(dispose => {
198+
const { source, readyState } = createSSE("https://example.com/events", {
199+
reconnect: { retries: 0, delay: 50 },
200+
});
201+
vi.advanceTimersByTime(20);
202+
const es = source() as unknown as MockEventSource;
203+
const closeSpy = vi.spyOn(es, "close");
204+
es.simulateError();
205+
// retries exhausted immediately — cleanup must have run
206+
expect(closeSpy).toHaveBeenCalledOnce();
207+
expect(source()).toBeUndefined();
208+
expect(readyState()).toBe(SSEReadyState.CLOSED);
190209
dispose();
191210
}));
192211

0 commit comments

Comments
 (0)