From 4a1b6a8d94a7e89e7622e0b4979740a7a1240e90 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Sun, 21 Jun 2026 19:36:30 +0530 Subject: [PATCH 1/2] fix(#1138): fix SSE EventSource leak and polling race condition - Add version/generation counter in useTaskSubscription to prevent stale EventSource callbacks from triggering after reconnection - Check versionRef.current against expected version in all SSE event handlers, onerror, and onopen to ignore callbacks from previous connections - Replace setInterval in Scans.tsx with chained setTimeout that awaits loadTasks() before scheduling the next poll - Use AbortController signal to gate polling continuation - Rename intervalRef to pollingTimerRef for clarity --- frontend/src/hooks/useTaskSubscription.ts | 39 +++++++++++++++-------- frontend/src/pages/Scans.tsx | 23 ++++++++----- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/frontend/src/hooks/useTaskSubscription.ts b/frontend/src/hooks/useTaskSubscription.ts index 18652fdca..be2c9c9e4 100644 --- a/frontend/src/hooks/useTaskSubscription.ts +++ b/frontend/src/hooks/useTaskSubscription.ts @@ -34,11 +34,12 @@ export function useTaskSubscription({ const onPhaseRef = useRef(onPhase) const onOutputRef = useRef(onOutput) const esRef = useRef(null) - const pollIntervalRef = useRef | null>(null) + const pollTimerRef = useRef | null>(null) const reconnectAttemptRef = useRef(0) const reconnectTimerRef = useRef | null>(null) const lastStatusRef = useRef(null) const cleanupRef = useRef(false) + const versionRef = useRef(0) onStatusRef.current = onStatus onPhaseRef.current = onPhase @@ -46,13 +47,14 @@ export function useTaskSubscription({ const cleanupAll = useCallback(() => { cleanupRef.current = true + versionRef.current += 1 if (esRef.current) { esRef.current.close() esRef.current = null } - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current) - pollIntervalRef.current = null + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current) + pollTimerRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) @@ -62,13 +64,16 @@ export function useTaskSubscription({ const startPolling = useCallback(() => { if (cleanupRef.current) return + const version = versionRef.current + 1 + versionRef.current = version setIsPolling(true) setIsConnected(false) - pollIntervalRef.current = setInterval(async () => { - if (cleanupRef.current) return + + const poll = async () => { + if (cleanupRef.current || versionRef.current !== version) return try { const data = await getTaskStatus(taskId) as { status?: string } - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return if (data.status && data.status !== lastStatusRef.current) { lastStatusRef.current = data.status onStatusRef.current?.(data.status) @@ -76,14 +81,22 @@ export function useTaskSubscription({ if (data.status && ['completed', 'failed', 'cancelled'].includes(data.status)) { cleanupAll() setIsPolling(false) + return } } catch { } - }, pollingInterval) + if (!cleanupRef.current && versionRef.current === version) { + pollTimerRef.current = setTimeout(poll, pollingInterval) + } + } + + poll() }, [taskId, pollingInterval, cleanupAll]) const connectSSE = useCallback(() => { if (cleanupRef.current) return + const version = versionRef.current + 1 + versionRef.current = version if (esRef.current) { esRef.current.close() esRef.current = null @@ -94,7 +107,7 @@ export function useTaskSubscription({ esRef.current = es es.addEventListener('status', (e: MessageEvent) => { - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return try { const data = JSON.parse(e.data) as { status: string; scan_phase?: string } if (data.scan_phase) { @@ -114,7 +127,7 @@ export function useTaskSubscription({ }) es.addEventListener('phase', (e: MessageEvent) => { - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return try { const data = JSON.parse(e.data) as { scan_phase: string } if (data.scan_phase) { @@ -125,7 +138,7 @@ export function useTaskSubscription({ }) es.addEventListener('output', (e: MessageEvent) => { - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return try { const data = JSON.parse(e.data) as { chunk: string } if (data.chunk) { @@ -136,7 +149,7 @@ export function useTaskSubscription({ }) es.onerror = () => { - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return es.close() esRef.current = null setIsConnected(false) @@ -154,7 +167,7 @@ export function useTaskSubscription({ } es.onopen = () => { - if (cleanupRef.current) return + if (cleanupRef.current || versionRef.current !== version) return reconnectAttemptRef.current = 0 setIsConnected(true) setIsPolling(false) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 5cf1d8458..646f9b54e 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -82,22 +82,29 @@ export default function Scans() { type: "warning", }); - // Ref so the visibilitychange handler always sees the current interval id - const intervalRef = useRef | null>(null); + // Ref so the visibilitychange handler always sees the current timer id + const pollingTimerRef = useRef | null>(null); const requestSeqRef = useRef(0); const abortRef = useRef(null); + function scheduleNextPoll() { + pollingTimerRef.current = setTimeout(async () => { + await loadTasks(); + if (!abortRef.current?.signal.aborted) { + scheduleNextPoll(); + } + }, 5000); + } + function startPolling() { stopPolling(); - intervalRef.current = setInterval(() => { - loadTasks(); - }, 5000); + scheduleNextPoll(); } function stopPolling() { - if (intervalRef.current !== null) { - clearInterval(intervalRef.current); - intervalRef.current = null; + if (pollingTimerRef.current !== null) { + clearTimeout(pollingTimerRef.current); + pollingTimerRef.current = null; } } From 13c0f1b83dd2b9f6af02b2001a4e7c4ecd43d8c7 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Sun, 21 Jun 2026 20:02:06 +0530 Subject: [PATCH 2/2] fix(#1138): update polling test expectations for chained setTimeout The chained setTimeout pattern calls poll() once immediately on start, unlike setInterval which waits for the first interval. Update the test assertions to reflect the correct call counts. --- frontend/testing/unit/hooks/useTaskSubscription.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/testing/unit/hooks/useTaskSubscription.test.ts b/frontend/testing/unit/hooks/useTaskSubscription.test.ts index 1fe0fc5ae..81103b172 100644 --- a/frontend/testing/unit/hooks/useTaskSubscription.test.ts +++ b/frontend/testing/unit/hooks/useTaskSubscription.test.ts @@ -136,12 +136,13 @@ describe('useTaskSubscription', () => { const es = getES()! await act(() => { es.triggerError() }) - + // startPolling calls poll() immediately (chained setTimeout), so one call + // happens synchronously before the first interval elapses. await tickTime(50) - expect(getTaskStatus).toHaveBeenCalledTimes(1) + expect(getTaskStatus).toHaveBeenCalledTimes(2) // initial (direct) + first timer await tickTime(50) - expect(getTaskStatus).toHaveBeenCalledTimes(2) + expect(getTaskStatus).toHaveBeenCalledTimes(3) // initial + first + second timer }) it('stops polling on terminal status', async () => {