fix: recover chat replies and organize setup navigation [skip ci]

Targeted source-only patch from reviewed local e370485. Adds bounded realtime/catch-up recovery and moves the setup wizard under System. Static checks only; regression tests, full build and runtime verification have not been run. No deployment. Separate hardening-review branch is not merged. Expected tree: 6235478b9a.
This commit is contained in:
2026-10-16 13:43:41 +00:00
parent e57610a27c
commit 299ea50fb5
8 changed files with 843 additions and 72 deletions

View File

@@ -1,3 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
"use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
@@ -14,6 +15,7 @@ type RealtimeConnectionManagerOptions = {
onError?: (event: Event, socket: WebSocket) => void
onConnectError?: (error: unknown) => void
buildPingMessage?: () => string
connectTimeoutMs?: number
pingIntervalMs?: number
reconnectBaseDelayMs?: number
reconnectMaxDelayMs?: number
@@ -24,6 +26,7 @@ type DisconnectOptions = {
updateStatus?: boolean
}
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
const DEFAULT_PING_INTERVAL_MS = 20000
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
@@ -32,12 +35,20 @@ export function createRealtimeConnectionManager(
options: RealtimeConnectionManagerOptions
) {
let socket: WebSocket | null = null
let connectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempt = 0
let reconnectEnabled = false
let connectEpoch = 0
const clearConnectTimer = () => {
if (connectTimer !== null) {
globalThis.clearTimeout(connectTimer)
connectTimer = null
}
}
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
globalThis.clearTimeout(reconnectTimer)
@@ -53,6 +64,7 @@ export function createRealtimeConnectionManager(
}
const clearTimers = () => {
clearConnectTimer()
clearReconnectTimer()
clearPingTimer()
}
@@ -97,6 +109,16 @@ export function createRealtimeConnectionManager(
options.onStatusChange?.("connecting")
const currentEpoch = ++connectEpoch
connectTimer = globalThis.setTimeout(() => {
if (currentEpoch !== connectEpoch) {
return
}
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected")
// Callers already localize their fallback for non-Error failures.
options.onConnectError?.({ code: "realtime_connect_timeout" })
scheduleReconnect()
}, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
if (options.beforeConnect) {
try {
@@ -105,6 +127,7 @@ export function createRealtimeConnectionManager(
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
clearConnectTimer()
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
@@ -112,11 +135,12 @@ export function createRealtimeConnectionManager(
}
}
if (
currentEpoch !== connectEpoch ||
!reconnectEnabled ||
!(options.canReconnect?.() ?? true)
) {
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
if (!(options.canReconnect?.() ?? true)) {
clearConnectTimer()
options.onStatusChange?.("disconnected")
return
}
@@ -124,6 +148,7 @@ export function createRealtimeConnectionManager(
try {
nextSocket = options.createSocket()
} catch (error) {
clearConnectTimer()
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
@@ -136,6 +161,7 @@ export function createRealtimeConnectionManager(
if (socket !== nextSocket) {
return
}
clearConnectTimer()
clearReconnectTimer()
clearPingTimer()
reconnectAttempt = 0
@@ -157,15 +183,15 @@ export function createRealtimeConnectionManager(
})
nextSocket.addEventListener("close", (event) => {
clearPingTimer()
const isCurrentSocket = socket === nextSocket
if (isCurrentSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) {
// A replaced socket may finish closing after the next one has opened.
// It must not clear that connection's heartbeat or subscription state.
if (socket !== nextSocket) {
return
}
clearConnectTimer()
clearPingTimer()
setSocket(null)
options.onClose?.(event, nextSocket)
if (canReconnect()) {
scheduleReconnect()
} else {
@@ -178,12 +204,17 @@ export function createRealtimeConnectionManager(
return
}
options.onError?.(event, nextSocket)
// An error need not be followed promptly by close. Detach it now so
// callers cannot keep reusing a failed OPEN/CONNECTING socket.
disconnect({ reconnect: true, updateStatus: false })
options.onStatusChange?.("disconnected")
scheduleReconnect()
})
}
const disconnect = (disconnectOptions?: DisconnectOptions) => {
// A timed-out or abandoned authentication wait must not open a late socket.
connectEpoch += 1
reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers()
if (!reconnectEnabled) {