fix: improve event stream reconnect reliability

Recover stalled event streams without dropping the session
Wait briefly for reconnection before showing connection lost errors
Persist Electron server logs for easier disconnect debugging
This commit is contained in:
Bohdan Triapitsyn
2026-04-22 21:03:02 +03:00
parent 17dd526731
commit f24e6de21b
10 changed files with 493 additions and 139 deletions
+59 -23
View File
@@ -21,10 +21,10 @@ export type FlushHandler = (events: QueuedEvent[]) => void
const FLUSH_FRAME_MS = 33
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
const HEARTBEAT_TIMEOUT_MS = 15_000
const DEFAULT_RECONNECT_DELAY_MS = 250
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30_000
const WS_FALLBACK_WINDOW_MS = 60_000
const WS_READY_TIMEOUT_MS = 2_000
const DEFAULT_WS_READY_TIMEOUT_MS = 2_000
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//
export type EventPipelineInput = {
@@ -38,6 +38,9 @@ export type EventPipelineInput = {
/** Called when transport switches (e.g. WS timeout → SSE fallback) without actual disconnection. */
onTransportSwitch?: () => void
transport?: "auto" | "ws" | "sse"
heartbeatTimeoutMs?: number
reconnectDelayMs?: number
wsReadyTimeoutMs?: number
}
type MessageStreamWsFrame = {
@@ -145,8 +148,25 @@ type DirectoryQueue = {
last: number
}
type AttemptAbortReason =
| "pipeline_stopped"
| "ws_heartbeat_timeout"
| "sse_heartbeat_timeout"
| null
export function createEventPipeline(input: EventPipelineInput) {
const { sdk, onEvent, onReconnect, onDisconnect, onTransportSwitch, routeDirectory, transport = "auto" } = input
const {
sdk,
onEvent,
onReconnect,
onDisconnect,
onTransportSwitch,
routeDirectory,
transport = "auto",
heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS,
wsReadyTimeoutMs = DEFAULT_WS_READY_TIMEOUT_MS,
} = input
const abort = new AbortController()
let disconnected = false
let lastEventId: string | undefined
@@ -244,6 +264,16 @@ export function createEventPipeline(input: EventPipelineInput) {
let attempt: AbortController | undefined
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
let activeTransport: "ws" | "sse" = transport === "ws" ? "ws" : "sse"
let attemptAbortReason: AttemptAbortReason = null
const notifyDisconnected = (reason: string) => {
if (disconnected) {
return
}
disconnected = true
onDisconnect?.(reason)
}
const markConnected = () => {
disconnected = false
@@ -295,8 +325,9 @@ export function createEventPipeline(input: EventPipelineInput) {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attemptAbortReason = `${activeTransport}_heartbeat_timeout`
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}, heartbeatTimeoutMs)
}
const clearHeartbeat = () => {
@@ -359,7 +390,7 @@ export function createEventPipeline(input: EventPipelineInput) {
} catch {
// ignore
}
}, WS_READY_TIMEOUT_MS)
}, wsReadyTimeoutMs)
const cleanup = () => {
if (readyTimer) {
@@ -499,9 +530,12 @@ export function createEventPipeline(input: EventPipelineInput) {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
let retryDelayMs = RECONNECT_DELAY_MS
attemptAbortReason = null
let retryDelayMs = reconnectDelayMs
const currentTransport = resolveTransport()
activeTransport = currentTransport
const onAbort = () => {
attemptAbortReason = "pipeline_stopped"
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
@@ -531,21 +565,18 @@ export function createEventPipeline(input: EventPipelineInput) {
// update connection state (e.g. set isConnected = false).
// Guard: only fire once per disconnection cycle to avoid repeated
// setState calls on every failed retry attempt.
if (!disconnected) {
disconnected = true
const taggedReason = typeof error === "object" && error !== null
? (error as { reason?: unknown }).reason
: undefined
const message = typeof error === "object" && error !== null
? (error as { message?: unknown }).message
: undefined
const reason = typeof taggedReason === "string" && taggedReason.length > 0
? taggedReason
: typeof message === "string" && message.length > 0
? `${currentTransport}_error:${message.slice(0, 80)}`
: `${currentTransport}_error:unknown`
onDisconnect?.(reason)
}
const taggedReason = typeof error === "object" && error !== null
? (error as { reason?: unknown }).reason
: undefined
const message = typeof error === "object" && error !== null
? (error as { message?: unknown }).message
: undefined
const reason = typeof taggedReason === "string" && taggedReason.length > 0
? taggedReason
: typeof message === "string" && message.length > 0
? `${currentTransport}_error:${message.slice(0, 80)}`
: `${currentTransport}_error:unknown`
notifyDisconnected(reason)
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
@@ -554,6 +585,11 @@ export function createEventPipeline(input: EventPipelineInput) {
}
if (abort.signal.aborted) return
if (attemptAbortReason && attemptAbortReason !== "pipeline_stopped") {
notifyDisconnected(attemptAbortReason)
retryDelayMs = 0
attemptAbortReason = null
}
if (retryDelayMs > 0) {
await wait(retryDelayMs)
}
@@ -563,7 +599,7 @@ export function createEventPipeline(input: EventPipelineInput) {
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
if (Date.now() - lastEventAt < heartbeatTimeoutMs) return
attempt?.abort()
}