Skip to content

Reconnect SSE stream after fatal EventSource errors - #918

Closed
selfcontained wants to merge 1 commit into
mainfrom
fix/sse-eventsource-reconnect
Closed

Reconnect SSE stream after fatal EventSource errors#918
selfcontained wants to merge 1 commit into
mainfrom
fix/sse-eventsource-reconnect

Conversation

@selfcontained

Copy link
Copy Markdown
Owner

Problem

EventSource only auto-reconnects after transient failures. A fatal one — a non-200 response or a wrong content-type, e.g. loading the page while the server is mid-restart — puts it in CLOSED permanently, per spec.

use-sse.ts didn't handle that case:

  • onerror only called recordSSEReconnect().
  • The dead instance stayed in eventSourceRef, so openSSE() early-returned forever.
  • Only a tab hide/show or an authState change cleared the ref — a visible desktop tab never self-healed.

Impact: silent loss of all realtime updates (agent status, terminal-state banner, media/review refreshes, injection-hold badge) while the app looks perfectly healthy.

Fix

In onerror, branch on readyState:

  • CONNECTING — the browser is retrying on its own, leave it alone.
  • CLOSED — the instance is dead: drop it from the ref and reopen on a capped backoff (1s, doubling to 30s).

The backoff resets when the stream delivers an event (the server sends a snapshot on every connect, so that's a clean success signal) and when the tab is foregrounded.

No heartbeat watchdog. The idea suggested one as optional; it isn't implementable client-side here. The server's keepalives are SSE comments (: keepalive), which EventSource never surfaces to onmessage — a liveness timer couldn't tell a hung proxy from a genuinely idle stream. That would need a real data-event heartbeat from the server; out of scope for this fix.

Validation

Unit — 5 new tests in use-sse.test.ts drive a fake EventSource with fake timers: fatal error reopens, transient error doesn't, backoff doubles, delivery resets the backoff, unmount stops retrying. 3 of the 5 fail against the pre-fix hook (verified by stashing the change).

Live, in a browser — intercepted /api/v1/events with a 500 to force the fatal path:

  • Retry timeline while the endpoint was down: +0.3s, +1.4s, +3.5s, +7.7s, +15.8s — the capped doubling, reconnecting on its own with no tab switch.
  • End-to-end: renamed an agent server-side while the stream was dead → UI did not update (the bug's symptom); un-blocked the endpoint → UI picked up the new name 2s later, no reload, no tab switch.

Checkspnpm run check 0 errors · finalize:web builds · web unit 710 passed · server unit 2503 passed · E2E 178 passed / 12 skipped (terminal-live, needs tmux).

🤖 Generated with Claude Code

EventSource only auto-reconnects after transient failures. A fatal one —
non-200 or a wrong content-type, e.g. hitting the server mid-restart —
moves it to CLOSED permanently. `onerror` only recorded a metric, and the
dead instance stayed in `eventSourceRef`, so `openSSE` early-returned
forever. Only a tab hide/show or an authState change cleared the ref, so a
visible desktop tab silently lost every realtime update — agent status,
terminal-state banner, media/review refreshes, injection-hold badge —
while the app looked healthy.
On a CLOSED readyState we now drop the dead instance and reopen on a
capped backoff (1s doubling to 30s), reset when the stream delivers again
(the server sends a snapshot on every connect) and when the tab is
foregrounded. CONNECTING is left alone — that is the browser's own retry.
No heartbeat watchdog: the server's keepalives are SSE comments, which
EventSource never surfaces to `onmessage`, so a client-side liveness timer
can't distinguish a hung proxy from an idle stream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@selfcontained
selfcontained deleted the fix/sse-eventsource-reconnect branch August 9, 2026 21:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@selfcontained