Skip to content

Commit 029032a

Browse files
authored
fix(sse): update readyState to CLOSED on owner disposal; document silent-drop limitation (#837)
2 parents af34b83 + f507436 commit 029032a

File tree

4 files changed

+166
-18
lines changed

4 files changed

+166
-18
lines changed
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/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,58 @@ SSEReadyState.CLOSED; // 2
154154

155155
`EventSource` has native browser-level reconnection built in. For transient network drops the browser automatically retries. The `reconnect` option in `createSSE` is for _application-level_ reconnection — it fires only when `readyState` becomes `SSEReadyState.CLOSED`, meaning the browser has given up entirely. You generally do not need `reconnect: true` for normal usage.
156156

157+
### A note on server disconnection detection
158+
159+
`EventSource` **does not reliably detect when a server silently stops responding**. If the server process crashes or the network path is severed without a proper TCP close handshake, the browser never fires an `error` event and `readyState` stays `OPEN` indefinitely — the connection looks healthy even though no messages will ever arrive.
160+
161+
The only robust workaround is **application-level heartbeats**: the server sends a lightweight event at a fixed interval, and the client starts a timer that triggers a reconnect if no heartbeat is received within the expected window.
162+
163+
```ts
164+
import { createSSE } from "@solid-primitives/sse";
165+
import { onCleanup } from "solid-js";
166+
167+
const HEARTBEAT_TIMEOUT_MS = 15_000; // reconnect if silent for 15 s
168+
169+
function createSSEWithHeartbeat(url: string) {
170+
let timer: ReturnType<typeof setTimeout> | undefined;
171+
172+
const { reconnect, ...rest } = createSSE(url, {
173+
// The server emits `event: heartbeat\ndata: \n\n` every ~10 s.
174+
// Any regular message also resets the timer.
175+
events: { heartbeat: resetTimer },
176+
onMessage: resetTimer,
177+
reconnect: true,
178+
});
179+
180+
function resetTimer() {
181+
clearTimeout(timer);
182+
timer = setTimeout(() => {
183+
// No heartbeat received — assume the server is gone.
184+
reconnect();
185+
}, HEARTBEAT_TIMEOUT_MS);
186+
}
187+
188+
onCleanup(() => {
189+
clearTimeout(timer);
190+
timer = undefined;
191+
});
192+
resetTimer(); // arm the first timeout immediately
193+
194+
return { reconnect, ...rest };
195+
}
196+
```
197+
198+
On the server, emit a periodic heartbeat event well within the client timeout:
199+
200+
```js
201+
// Express / Node.js example
202+
setInterval(() => {
203+
res.write("event: heartbeat\ndata: \n\n");
204+
}, 10_000); // every 10 s, safely below the 15 s client timeout
205+
```
206+
207+
> **Why SSE comment lines are not enough** — SSE comment lines (e.g. `: keep-alive`) reset the browser's internal TCP idle timer but are _not_ exposed to JavaScript listeners. Use a named `event: heartbeat` or a plain `data:` event if you need the client to observe the heartbeat.
208+
157209
## Integration with `@solid-primitives/event-bus`
158210

159211
Because `bus.emit` matches the `(event: MessageEvent) => void` shape of `onMessage`, you can wire them directly:

packages/sse/src/sse.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export type CreateSSEOptions<T> = SSEOptions & {
8383
* - `true`: reconnect with defaults (Infinity retries, 3000ms delay)
8484
* - object: custom `{ retries?, delay? }`
8585
*
86-
* Note: `EventSource` already reconnects natively for transient network
87-
* drops. This option handles cases where the browser gives up entirely.
86+
* The `retries` budget is shared across both browser-level retries
87+
* (readyState stays CONNECTING) and app-level reconnects (readyState →
88+
* CLOSED). Once the budget is exhausted the connection is fully torn down,
89+
* stopping any further browser-driven retry loops.
8890
*/
8991
reconnect?: boolean | SSEReconnectOptions;
9092
/**
@@ -227,6 +229,13 @@ export const createSSE = <T = string>(
227229
// ── Connection management ─────────────────────────────────────────────────
228230
let currentCleanup: VoidFunction | undefined;
229231

232+
/** Tears down the current source without scheduling a reconnect. */
233+
const teardown = () => {
234+
currentCleanup?.();
235+
currentCleanup = undefined;
236+
setSource(undefined);
237+
};
238+
230239
/** Open a fresh connection, resetting the retry counter. */
231240
const connect = (resolvedUrl: string) => {
232241
retriesLeft = reconnectConfig.retries ?? 0;
@@ -255,11 +264,26 @@ export const createSSE = <T = string>(
255264
setError(() => e);
256265
options.onError?.(e);
257266

258-
// Only app-level reconnect when the browser has given up (CLOSED).
259-
// When readyState is still CONNECTING the browser is handling retries.
267+
// When the browser has given up (CLOSED), perform app-level reconnects
268+
// against the configured budget.
269+
// When the browser is still retrying (CONNECTING) and a reconnect budget
270+
// is configured, count those attempts too so the config is always honoured
271+
// and the browser can never loop infinitely beyond the configured limit.
260272
if (es.readyState === SSEReadyState.CLOSED && retriesLeft > 0) {
261273
retriesLeft--;
262274
reconnectTimer = setTimeout(() => _open(resolvedUrl), reconnectConfig.delay ?? 3000);
275+
} else if (es.readyState === SSEReadyState.CLOSED) {
276+
// Retries exhausted — clean up fully to avoid memory/listener leaks.
277+
teardown();
278+
} else if (es.readyState === SSEReadyState.CONNECTING && options.reconnect) {
279+
// Browser is retrying. Consume the budget; when it's gone, abort so
280+
// we don't loop forever against the user's configured retry limit.
281+
if (retriesLeft > 0) {
282+
retriesLeft--;
283+
} else {
284+
teardown();
285+
setReadyState(SSEReadyState.CLOSED);
286+
}
263287
}
264288
};
265289

@@ -280,9 +304,7 @@ export const createSSE = <T = string>(
280304
const disconnect = () => {
281305
clearReconnectTimer();
282306
retriesLeft = 0;
283-
currentCleanup?.();
284-
currentCleanup = undefined;
285-
setSource(undefined);
307+
teardown();
286308
setReadyState(SSEReadyState.CLOSED);
287309
};
288310

@@ -309,10 +331,7 @@ export const createSSE = <T = string>(
309331
const resolvedUrl = url();
310332
if (resolvedUrl !== prevUrl) {
311333
prevUrl = resolvedUrl;
312-
untrack(() => {
313-
currentCleanup?.();
314-
currentCleanup = undefined;
315-
});
334+
untrack(() => teardown());
316335
connect(resolvedUrl);
317336
}
318337
});
@@ -321,8 +340,8 @@ export const createSSE = <T = string>(
321340
// ── Lifecycle cleanup ─────────────────────────────────────────────────────
322341
onCleanup(() => {
323342
clearReconnectTimer();
324-
currentCleanup?.();
325-
currentCleanup = undefined;
343+
teardown();
344+
setReadyState(SSEReadyState.CLOSED);
326345
});
327346

328347
return { source, data, error, readyState, close: disconnect, reconnect: manualReconnect };

packages/sse/test/index.test.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe("createSSE", () => {
144144
dispose();
145145
}));
146146

147-
it("does not app-reconnect on transient errors (browser handles those)", () =>
147+
it("does not open a new connection on transient errors (browser retries natively)", () =>
148148
createRoot(dispose => {
149149
const initialCount = SSEInstances.length;
150150
const { source } = createSSE("https://example.com/events", {
@@ -153,7 +153,41 @@ describe("createSSE", () => {
153153
vi.advanceTimersByTime(20);
154154
(source() as unknown as MockEventSource).simulateTransientError();
155155
vi.advanceTimersByTime(300);
156-
// readyState stayed CONNECTING → no new EventSource was created
156+
// readyState stayed CONNECTING → no new EventSource was created, but
157+
// the retry budget was decremented by 1 (from 5 to 4).
158+
expect(SSEInstances.length).toBe(initialCount + 1);
159+
dispose();
160+
}));
161+
162+
it("stops browser retry loop when reconnect budget is exhausted via transient errors", () =>
163+
createRoot(dispose => {
164+
const { source, readyState } = createSSE("https://example.com/events", {
165+
reconnect: { retries: 2, delay: 50 },
166+
});
167+
vi.advanceTimersByTime(20);
168+
const es = source() as unknown as MockEventSource;
169+
const closeSpy = vi.spyOn(es, "close");
170+
// Two transient errors consume the full budget (2→1→0).
171+
es.simulateTransientError(); // retries: 2→1
172+
es.simulateTransientError(); // retries: 1→0
173+
// A third transient error exhausts the budget → connection must be stopped.
174+
es.simulateTransientError();
175+
expect(closeSpy).toHaveBeenCalledOnce();
176+
expect(source()).toBeUndefined();
177+
expect(readyState()).toBe(SSEReadyState.CLOSED);
178+
dispose();
179+
}));
180+
181+
it("does not affect transient errors when reconnect is not configured", () =>
182+
createRoot(dispose => {
183+
const initialCount = SSEInstances.length;
184+
const { source } = createSSE("https://example.com/events");
185+
vi.advanceTimersByTime(20);
186+
const es = source() as unknown as MockEventSource;
187+
// Transient errors with no reconnect config should not kill the connection.
188+
es.simulateTransientError();
189+
es.simulateTransientError();
190+
expect(source()).toBe(es);
157191
expect(SSEInstances.length).toBe(initialCount + 1);
158192
dispose();
159193
}));
@@ -181,12 +215,31 @@ describe("createSSE", () => {
181215
const first = source();
182216
(first as unknown as MockEventSource).simulateError();
183217
vi.advanceTimersByTime(100); // first retry
184-
const second = source();
218+
const second = source() as unknown as MockEventSource;
185219
expect(second).not.toBe(first);
186220
vi.advanceTimersByTime(20); // second opens
187-
(second as unknown as MockEventSource).simulateError();
221+
const closeSpy = vi.spyOn(second, "close");
222+
second.simulateError();
188223
vi.advanceTimersByTime(200); // no more retries
189-
expect(source()).toBe(second); // still the same source
224+
// retries exhausted: close() was called and source signal is cleared
225+
expect(closeSpy).toHaveBeenCalledOnce();
226+
expect(source()).toBeUndefined();
227+
dispose();
228+
}));
229+
230+
it("cleans up source and listeners when retries are exhausted", () =>
231+
createRoot(dispose => {
232+
const { source, readyState } = createSSE("https://example.com/events", {
233+
reconnect: { retries: 0, delay: 50 },
234+
});
235+
vi.advanceTimersByTime(20);
236+
const es = source() as unknown as MockEventSource;
237+
const closeSpy = vi.spyOn(es, "close");
238+
es.simulateError();
239+
// retries exhausted immediately — cleanup must have run
240+
expect(closeSpy).toHaveBeenCalledOnce();
241+
expect(source()).toBeUndefined();
242+
expect(readyState()).toBe(SSEReadyState.CLOSED);
190243
dispose();
191244
}));
192245

@@ -233,4 +286,23 @@ describe("createSSE", () => {
233286
dispose();
234287
}),
235288
));
289+
290+
it("readyState is CLOSED after owner disposal", () =>
291+
createRoot(dispose => {
292+
const { readyState } = createSSE("https://example.com/events");
293+
vi.advanceTimersByTime(20);
294+
expect(readyState()).toBe(SSEReadyState.OPEN);
295+
dispose();
296+
expect(readyState()).toBe(SSEReadyState.CLOSED);
297+
}));
298+
299+
it("readyState updates to CONNECTING when server drops connection", () =>
300+
createRoot(dispose => {
301+
const { readyState, source } = createSSE("https://example.com/events");
302+
vi.advanceTimersByTime(20);
303+
expect(readyState()).toBe(SSEReadyState.OPEN);
304+
(source() as unknown as MockEventSource).simulateTransientError();
305+
expect(readyState()).toBe(SSEReadyState.CONNECTING);
306+
dispose();
307+
}));
236308
});

0 commit comments

Comments
 (0)