mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '重新加载',
|
||||
|
||||
Reference in New Issue
Block a user