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).
This commit is contained in:
SSU-WEI HUANG
2026-08-15 11:16:19 +08:00
committed by GitHub
parent 235d6dd6fd
commit a794be0811
5 changed files with 202 additions and 8 deletions
@@ -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')
}
+152 -2
View File
@@ -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<string>) => 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<string>)
}
}
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> = {}): SessionSummary {
return {
id: 'session-1',
+40 -6
View File
@@ -81,6 +81,16 @@ type VisibilityState = 'visible' | 'hidden'
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
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)
+2
View File
@@ -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',
+2
View File
@@ -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': '重新加载',