Skip to content

Commit 702c765

Browse files
authored
Merge pull request #1170 from Hollujay/fix-850-clean
fix(#850): EventSource reconnect storm + backoff timing
2 parents 9107757 + 42b1c31 commit 702c765

2 files changed

Lines changed: 94 additions & 19 deletions

File tree

frontend/src/__tests__/useStreamEvents.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type ErrorHandler = () => void;
99

1010
class MockEventSource {
1111
static instance: MockEventSource | null = null;
12+
static instanceCount = 0;
1213

1314
url: string;
1415
onopen: (() => void) | null = null;
@@ -21,6 +22,7 @@ class MockEventSource {
2122
constructor(url: string) {
2223
this.url = url;
2324
MockEventSource.instance = this;
25+
MockEventSource.instanceCount += 1;
2426
}
2527

2628
addEventListener(type: string, handler: EventHandler) {
@@ -66,6 +68,7 @@ class MockEventSource {
6668
describe('useStreamEvents', () => {
6769
beforeEach(() => {
6870
MockEventSource.instance = null;
71+
MockEventSource.instanceCount = 0;
6972
vi.useFakeTimers();
7073
});
7174

@@ -280,4 +283,53 @@ describe('useStreamEvents', () => {
280283

281284
expect(result.current.events).toHaveLength(types.length);
282285
});
286+
287+
it('creates only one EventSource across multiple re-renders and incoming events', () => {
288+
const { result, rerender } = renderHook(
289+
(opts: { streamIds: string[] } = { streamIds: ['1'] }) =>
290+
useStreamEvents({ ...opts, autoReconnect: false }),
291+
);
292+
293+
const firstInstance = MockEventSource.instance;
294+
295+
act(() => { firstInstance?.open(); });
296+
297+
// Simulate multiple re-renders with the same subscription (inline array)
298+
rerender({ streamIds: ['1'] });
299+
rerender({ streamIds: ['1'] });
300+
rerender({ streamIds: ['1'] });
301+
302+
// Simulate incoming events causing re-renders of the consumer
303+
act(() => {
304+
MockEventSource.instance?.emit('stream.created', { i: 1 });
305+
MockEventSource.instance?.emit('stream.created', { i: 2 });
306+
MockEventSource.instance?.emit('stream.created', { i: 3 });
307+
});
308+
309+
expect(result.current.events).toHaveLength(3);
310+
311+
// Re-render again after events
312+
rerender({ streamIds: ['1'] });
313+
rerender({ streamIds: ['1'] });
314+
315+
expect(MockEventSource.instanceCount).toBe(1);
316+
expect(MockEventSource.instance).toBe(firstInstance);
317+
});
318+
319+
it('stops reconnecting after reaching the cap', () => {
320+
renderHook(() =>
321+
useStreamEvents({ streamIds: ['1'], autoReconnect: true, maxRetryDelay: 1000 }),
322+
);
323+
324+
// Trigger errors repeatedly to consume reconnect attempts.
325+
// The reconnect delay stays at 1000ms (capped by maxRetryDelay).
326+
for (let i = 0; i < 25; i++) {
327+
act(() => { MockEventSource.instance?.triggerError(); });
328+
act(() => { vi.advanceTimersByTime(2000); });
329+
}
330+
331+
// 1 initial + 20 reconnect attempts = 21 instances max.
332+
// After the 20th reconnect attempt, no more timers should fire.
333+
expect(MockEventSource.instanceCount).toBeLessThanOrEqual(21);
334+
});
283335
});

frontend/src/hooks/useStreamEvents.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useCallback, useRef } from 'react';
1+
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
22

33
interface StreamEvent {
44
type: 'created' | 'topped_up' | 'withdrawn' | 'cancelled' | 'completed' | 'paused' | 'resumed';
@@ -23,12 +23,13 @@ interface UseStreamEventsReturn {
2323
clearEvents: () => void;
2424
}
2525

26+
const MAX_RECONNECT_ATTEMPTS = 20;
27+
2628
export function useStreamEvents(
2729
options: UseStreamEventsOptions = {}
2830
): UseStreamEventsReturn {
2931
const {
30-
streamIds = [],
31-
// userPublicKeys = [],
32+
streamIds: rawStreamIds = [],
3233
subscribeToAll = false,
3334
autoReconnect = true,
3435
maxRetryDelay = 30000,
@@ -43,32 +44,43 @@ export function useStreamEvents(
4344
const eventSourceRef = useRef<EventSource | null>(null);
4445
const retryDelayRef = useRef(1000);
4546
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47+
const reconnectAttemptsRef = useRef(0);
4648
const connectRef = useRef<() => void>(() => undefined);
4749

50+
const subscriptionKey = useMemo(() => {
51+
const streams = [...rawStreamIds].sort().join(',');
52+
return `${subscribeToAll ? 'all' : streams}|${jwtToken || ''}`;
53+
}, [rawStreamIds, subscribeToAll, jwtToken]);
54+
4855
const buildUrl = useCallback(() => {
4956
const params = new URLSearchParams();
5057

5158
if (subscribeToAll) {
5259
params.append('all', 'true');
5360
} else {
54-
streamIds.forEach(id => params.append('streams', id));
61+
rawStreamIds.forEach(id => params.append('streams', id));
5562
}
5663

57-
// Add JWT token to query string for authentication
58-
// (EventSource doesn't support custom headers in browser)
5964
if (jwtToken) {
6065
params.append('token', jwtToken);
6166
}
6267

6368
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
6469
return `${baseUrl}/v1/events/subscribe?${params}`;
65-
}, [streamIds, subscribeToAll, jwtToken]);
70+
// subscriptionKey captures all subscription parameters as a stable string
71+
// eslint-disable-next-line react-hooks/exhaustive-deps
72+
}, [subscriptionKey]);
6673

6774
const clearEvents = useCallback(() => {
6875
setEvents([]);
6976
}, []);
7077

7178
const connect = useCallback(() => {
79+
if (reconnectTimeoutRef.current !== null) {
80+
clearTimeout(reconnectTimeoutRef.current);
81+
reconnectTimeoutRef.current = null;
82+
}
83+
7284
const url = buildUrl();
7385
const eventSource = new EventSource(url);
7486
eventSourceRef.current = eventSource;
@@ -77,16 +89,16 @@ export function useStreamEvents(
7789
setConnected(true);
7890
setReconnecting(false);
7991
setError(null);
80-
retryDelayRef.current = 1000; // Reset retry delay
92+
retryDelayRef.current = 1000;
93+
reconnectAttemptsRef.current = 0;
8194
};
8295

83-
8496
const handleEvent = (type: StreamEvent['type']) => (e: MessageEvent) => {
8597
try {
8698
const data = JSON.parse(e.data);
8799
setEvents((prev: StreamEvent[]) => [
88100
{ type, data, timestamp: Date.now() },
89-
...prev.slice(0, 99), // Keep last 100 events
101+
...prev.slice(0, 99),
90102
]);
91103
} catch {
92104
// Silently ignore malformed event messages
@@ -108,14 +120,24 @@ export function useStreamEvents(
108120

109121
if (autoReconnect) {
110122
setReconnecting(true);
111-
// Cap the delay we're about to wait on, and precompute the next
112-
// (doubled, capped) delay up front so consecutive failures keep
113-
// growing the backoff even if the next attempt fails immediately.
114-
const delay = Math.min(retryDelayRef.current, maxRetryDelay);
115-
retryDelayRef.current = Math.min(retryDelayRef.current * 2, maxRetryDelay);
116-
reconnectTimeoutRef.current = setTimeout(() => {
117-
connectRef.current();
118-
}, delay);
123+
124+
if (reconnectTimeoutRef.current !== null) {
125+
clearTimeout(reconnectTimeoutRef.current);
126+
reconnectTimeoutRef.current = null;
127+
}
128+
129+
reconnectAttemptsRef.current += 1;
130+
131+
if (reconnectAttemptsRef.current <= MAX_RECONNECT_ATTEMPTS) {
132+
// Cap the delay we're about to wait on, and precompute the next
133+
// (doubled, capped) delay up front so consecutive failures keep
134+
// growing the backoff even if the next attempt fails immediately.
135+
const delay = Math.min(retryDelayRef.current, maxRetryDelay);
136+
retryDelayRef.current = Math.min(retryDelayRef.current * 2, maxRetryDelay);
137+
reconnectTimeoutRef.current = setTimeout(() => {
138+
connectRef.current();
139+
}, delay);
140+
}
119141
}
120142
};
121143
}, [buildUrl, autoReconnect, maxRetryDelay]);
@@ -132,8 +154,9 @@ export function useStreamEvents(
132154
eventSourceRef.current.close();
133155
eventSourceRef.current = null;
134156
}
135-
if (reconnectTimeoutRef.current) {
157+
if (reconnectTimeoutRef.current !== null) {
136158
clearTimeout(reconnectTimeoutRef.current);
159+
reconnectTimeoutRef.current = null;
137160
}
138161
};
139162
}, [connect]);

0 commit comments

Comments
 (0)