From a794be08111499219986a99bbbb84df68bfd05ab Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sat, 15 Aug 2026 11:16:19 +0800 Subject: [PATCH] fix(web): recover silently-dead SSE connections quickly, stop banner noise (#1562) A suspended mobile tab can lose its SSE connection without the browser ever noticing (no FIN/RST arrives). On resume the client used to wait for the 90s heartbeat watchdog or lean on the native EventSource retry, which can hang on a dead pooled socket - leaving a persistent "Reconnecting... (stream error)" banner while the UI keeps working. - useSSE: on visibility resume, distrust the connection after one missed heartbeat interval (45s) instead of 90s - useSSE: abandon connection attempts that don't open within 10s (hung on a dead pooled socket after resume) instead of waiting for the watchdog; force bypasses the one-shot reconnect guard for the new attempt cycle - useSSE: first reconnect attempt is immediate; exponential backoff starts from the second attempt - ReconnectingBanner: localize connect-timeout and transport-error reasons (transport-error is already emitted by current code but fell through unlabeled) Fixes #1559. Reimplements the reconnect half of #989 (closed unmerged) on top of the current scheduler (hidden-tab deferral, slow backoff, replay cursor). --- web/src/components/ReconnectingBanner.tsx | 6 + web/src/hooks/useSSE.test.ts | 154 +++++++++++++++++++++- web/src/hooks/useSSE.ts | 46 ++++++- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + 5 files changed, 202 insertions(+), 8 deletions(-) diff --git a/web/src/components/ReconnectingBanner.tsx b/web/src/components/ReconnectingBanner.tsx index 926de4f7..6d5c4b4a 100644 --- a/web/src/components/ReconnectingBanner.tsx +++ b/web/src/components/ReconnectingBanner.tsx @@ -7,6 +7,12 @@ function getReasonLabel(reason: string, t: (key: string) => string): string { if (reason === 'visibility-recovery') { return t('reconnecting.reason.visibilityRecovery') } + if (reason === 'connect-timeout') { + return t('reconnecting.reason.connectTimeout') + } + if (reason === 'transport-error') { + return t('reconnecting.reason.transportError') + } if (reason === 'closed') { return t('reconnecting.reason.closed') } diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index ccd97b42..c2259ecf 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { createElement, type ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionSummary } from '@/types/api' import type { Session } from '@/types/api' import { @@ -8,9 +11,156 @@ import { isNewerVersionedPatch, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch, - shouldInvalidateSessionListForEvent + shouldInvalidateSessionListForEvent, + useSSE } from './useSSE' +class FakeEventSource { + static instances: FakeEventSource[] = [] + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + readonly url: string + readyState = FakeEventSource.CONNECTING + onopen: (() => void) | null = null + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((error: unknown) => void) | null = null + + constructor(url: string) { + this.url = url + FakeEventSource.instances.push(this) + } + + close(): void { + this.readyState = FakeEventSource.CLOSED + } + + simulateOpen(): void { + this.readyState = FakeEventSource.OPEN + this.onopen?.() + } + + simulateMessage(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent) + } +} + +function renderUseSSE(options?: { onDisconnect?: (reason: string) => void }) { + const queryClient = new QueryClient() + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + return renderHook(() => useSSE({ + enabled: true, + token: 'test-token', + baseUrl: 'http://hub.test', + subscription: { all: true }, + onEvent: () => {}, + onDisconnect: options?.onDisconnect + }), { wrapper }) +} + +describe('useSSE connection liveness (mobile suspend/resume)', () => { + beforeEach(() => { + vi.useFakeTimers() + FakeEventSource.instances = [] + vi.stubGlobal('EventSource', FakeEventSource) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('reconnects on visibility resume when a heartbeat interval was missed', () => { + const onDisconnect = vi.fn() + const { unmount } = renderUseSSE({ onDisconnect }) + + expect(FakeEventSource.instances).toHaveLength(1) + act(() => { FakeEventSource.instances[0]?.simulateOpen() }) + + // Silence for 70s: more than one 30s heartbeat missed, but below the + // 90s watchdog threshold. A resume from background must not trust + // this connection. + act(() => { vi.advanceTimersByTime(70_000) }) + act(() => { document.dispatchEvent(new Event('visibilitychange')) }) + + expect(onDisconnect).toHaveBeenCalledWith('visibility-recovery') + expect(FakeEventSource.instances[0]?.readyState).toBe(FakeEventSource.CLOSED) + + // First reconnect attempt is immediate (jitter only) + act(() => { vi.advanceTimersByTime(600) }) + expect(FakeEventSource.instances).toHaveLength(2) + + unmount() + }) + + it('keeps a fresh connection on visibility resume', () => { + const onDisconnect = vi.fn() + const { unmount } = renderUseSSE({ onDisconnect }) + + act(() => { FakeEventSource.instances[0]?.simulateOpen() }) + act(() => { vi.advanceTimersByTime(20_000) }) + act(() => { document.dispatchEvent(new Event('visibilitychange')) }) + + expect(onDisconnect).not.toHaveBeenCalled() + expect(FakeEventSource.instances).toHaveLength(1) + + unmount() + }) + + it('abandons a connection attempt that does not open in time', () => { + const onDisconnect = vi.fn() + const { unmount } = renderUseSSE({ onDisconnect }) + + expect(FakeEventSource.instances).toHaveLength(1) + // never opens (e.g. request hung on a dead pooled socket) + act(() => { vi.advanceTimersByTime(10_100) }) + + expect(onDisconnect).toHaveBeenCalledWith('connect-timeout') + expect(FakeEventSource.instances[0]?.readyState).toBe(FakeEventSource.CLOSED) + + act(() => { vi.advanceTimersByTime(600) }) + expect(FakeEventSource.instances).toHaveLength(2) + + unmount() + }) + + it('does not time out a connection that opens promptly', () => { + const onDisconnect = vi.fn() + const { unmount } = renderUseSSE({ onDisconnect }) + + act(() => { FakeEventSource.instances[0]?.simulateOpen() }) + act(() => { vi.advanceTimersByTime(30_000) }) + + expect(onDisconnect).not.toHaveBeenCalled() + expect(FakeEventSource.instances).toHaveLength(1) + + unmount() + }) + + it('abandons a hung reconnect attempt even after a prior reconnect request', () => { + const onDisconnect = vi.fn() + const { unmount } = renderUseSSE({ onDisconnect }) + + act(() => { FakeEventSource.instances[0]?.simulateOpen() }) + // First cycle: transport error while the source is up routes through + // requestReconnect, which is one-shot per EventSource instance. + act(() => { FakeEventSource.instances[0]?.onerror?.({}) }) + expect(onDisconnect).toHaveBeenCalledWith('transport-error') + + act(() => { vi.advanceTimersByTime(600) }) + expect(FakeEventSource.instances).toHaveLength(2) + + // Second cycle never opens — the connect deadline must still fire + // (force path) instead of being swallowed by the one-shot guard. + act(() => { vi.advanceTimersByTime(10_100) }) + expect(onDisconnect).toHaveBeenCalledWith('connect-timeout') + expect(FakeEventSource.instances[1]?.readyState).toBe(FakeEventSource.CLOSED) + + unmount() + }) +}) + function makeSummary(overrides: Partial = {}): SessionSummary { return { id: 'session-1', diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 051a66ca..74312de5 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -81,6 +81,16 @@ type VisibilityState = 'visible' | 'hidden' type ToastEvent = Extract const HEARTBEAT_STALE_MS = 90_000 +// The hub sends a heartbeat every 30s. When a suspended mobile tab returns to +// the foreground, the connection may have been silently killed (no FIN/RST +// ever reaches the browser), so a single missed heartbeat interval is already +// enough to distrust it on resume. The watchdog keeps the longer 90s +// threshold for tabs that stayed visible throughout. +const VISIBILITY_RESUME_STALE_MS = 45_000 +// A new EventSource that hasn't opened within this window is likely hung on a +// dead pooled socket (common right after a suspended tab resumes) — abandon +// it and retry on a fresh connection instead of waiting for the watchdog. +const CONNECT_TIMEOUT_MS = 10_000 const HEARTBEAT_WATCHDOG_INTERVAL_MS = 10_000 const RECONNECT_BASE_DELAY_MS = 1_000 const RECONNECT_MAX_DELAY_MS = 30_000 @@ -452,7 +462,11 @@ export function useSSE(options: { const maxDelay = attempt >= RECONNECT_SLOW_AFTER_ATTEMPTS ? RECONNECT_SLOW_MAX_DELAY_MS : RECONNECT_MAX_DELAY_MS - const exponentialDelay = Math.min(maxDelay, RECONNECT_BASE_DELAY_MS * (2 ** attempt)) + // First attempt reconnects immediately (jitter only) — backoff is + // for repeated failures, not for the initial recovery. + const exponentialDelay = attempt === 0 + ? 0 + : Math.min(maxDelay, RECONNECT_BASE_DELAY_MS * (2 ** (attempt - 1))) const jitter = Math.floor(Math.random() * (RECONNECT_JITTER_MS + 1)) reconnectAttemptRef.current = attempt + 1 if (reconnectTimerRef.current) { @@ -472,8 +486,8 @@ export function useSSE(options: { onDisconnectRef.current?.(reason) } - const requestReconnect = (reason: string) => { - if (reconnectRequested) { + const requestReconnect = (reason: string, force = false) => { + if (reconnectRequested && !force) { return } reconnectRequested = true @@ -882,7 +896,24 @@ export function useSSE(options: { } eventSource.onmessage = handleMessage + // A connection attempt that never reaches OPEN within CONNECT_TIMEOUT_MS + // is likely hung on a dead pooled socket (common right after a + // suspended mobile tab resumes). Abandon it and retry on a fresh + // connection. force bypasses the one-shot reconnectRequested guard: a + // hung attempt is a new attempt cycle, not a duplicate of the previous + // reconnect request. + const connectDeadlineTimer = setTimeout(() => { + if (eventSourceRef.current !== eventSource) { + return + } + if (eventSource.readyState === EventSource.OPEN) { + return + } + requestReconnect('connect-timeout', true) + }, CONNECT_TIMEOUT_MS) + eventSource.onopen = () => { + clearTimeout(connectDeadlineTimer) if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null @@ -923,8 +954,10 @@ export function useSSE(options: { // When the tab becomes visible again, check immediately whether the // SSE connection went stale while hidden (the watchdog skips checks - // for hidden tabs). This avoids the user having to wait up to - // HEARTBEAT_WATCHDOG_INTERVAL_MS after switching back. + // for hidden tabs). Uses the tighter VISIBILITY_RESUME_STALE_MS + // threshold: a device suspend can kill the connection without the + // browser ever noticing, so a missed heartbeat interval at resume + // already warrants a proactive reconnect. const onVisibilityChange = () => { if (getVisibilityState() !== 'visible') return // A retry fell due while the tab was hidden and was deliberately @@ -936,7 +969,7 @@ export function useSSE(options: { return } if (eventSourceRef.current !== eventSource) return - if (Date.now() - lastActivityAtRef.current >= HEARTBEAT_STALE_MS) { + if (Date.now() - lastActivityAtRef.current >= VISIBILITY_RESUME_STALE_MS) { requestReconnect('visibility-recovery') } } @@ -944,6 +977,7 @@ export function useSSE(options: { return () => { clearInterval(watchdogTimer) + clearTimeout(connectDeadlineTimer) document.removeEventListener('visibilitychange', onVisibilityChange) if (invalidationTimerRef.current) { clearTimeout(invalidationTimerRef.current) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 07eb2e47..cbab3092 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -699,6 +699,8 @@ export default { 'reconnecting.reason.closed': 'stream closed', 'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout', 'reconnecting.reason.visibilityRecovery': 'resuming after background', + 'reconnecting.reason.connectTimeout': 'connect timeout', + 'reconnecting.reason.transportError': 'stream error', 'pwa.update.title': 'New version available', 'pwa.update.body': 'Reload to get the latest HAPI', 'pwa.update.reload': 'Reload', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 954a687d..be53916e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -698,6 +698,8 @@ export default { 'reconnecting.reason.closed': '流连接已关闭', 'reconnecting.reason.heartbeatTimeout': '心跳超时', 'reconnecting.reason.visibilityRecovery': '后台恢复中', + 'reconnecting.reason.connectTimeout': '连接超时', + 'reconnecting.reason.transportError': '流连接错误', 'pwa.update.title': '新版本可用', 'pwa.update.body': '重新加载以获取最新版 HAPI', 'pwa.update.reload': '重新加载',