fix(app): auto-reconnect stale SSE streams and repair state drift - #41299
fix(app): auto-reconnect stale SSE streams and repair state drift#41299afonsoft wants to merge 1 commit into
Conversation
Consolidates byte-level activity tracking with per-attempt watchdog to detect and recover from silently dead event streams. Resolves issues anomalyco#40910 and anomalyco#40502 where reverse proxies drop connections without error, leaving the web UI frozen mid-session until manual page refresh. ## Problem When reverse proxies (nginx, cloudflare) buffer or silently drop the SSE '/global/event' stream, the web UI freezes mid-session with no error. The client's reader.read() never resolves, reconnect loop never fires, and users must manually refresh to see progress. ## Root Cause Analysis 1. SSE streams die without explicit error (connection drop, proxy buffering, NAT timeout) 2. Async iterator never throws, reader hangs forever 3. No heartbeat timeout at transport level (SSE parser discards comment frames) 4. Reconnect loop only triggers on error or stream end 5. State drift: sessions stay 'busy' despite finishing server-side 6. Query race: activeSessionsQuery wedges on first failed fetch, blocks repair ## Solution: Two-Layer Recovery ### Layer 1: Transport-Level Liveness (byte tracking) - createActivityTrackingFetch: wraps fetch to report every SSE byte - Detects heartbeat frames (: comment) that parser discards - Timestamp updated on each byte, not just events - Non-stream responses (JSON, etc) pass through untouched ### Layer 2: Per-Attempt Watchdog (45s stall detection) - EVENT_STREAM_STALE_MS = 45_000 (3 missed 15s heartbeats) - STREAM_STALL_CHECK_MS = 5_000 (check every 5s) - Generation-based timer: prevents stale timeout killing healthy successor - Captured controller per attempt ensures correct abort target - Logs all stall events with idle time for debugging ### Layer 3: State Repair (reconciliation) - reconcileActiveSessionStatuses: clears busy for finished runs - Force-resyncs sessions with pending messages (frozen timelines) - Independent of query state (fixes wedged query race) - Returns list of resynced sessions for logging ### Layer 4: Reconnect Notifications (event hooks) - onReconnect(handler) hook fires on each stream reconnect - Driven by transport (not 'server.connected' frame) - Fires BEFORE bytes arrive, works even if stream dies immediately - Handlers wrapped in try-catch to prevent cascade failures ## Implementation Details ### server-sdk.tsx (+99 lines) - createActivityTrackingFetch: ReadableStream wrapper with per-byte reporting - reconnectHandlers Set: tracks all repair callbacks - notifyReconnect(): safely calls all handlers - Watchdog timer: setInterval checking (idle > STREAM_STALL_MS) - Attempts counter: logs reconnect sequence - activity timestamp: Date.now() on each byte and event ### server-sync.tsx (+82 lines) - reconcileActiveSessionStatuses: overwrites event-written status - repairAfterReconnect: async repair function, independent execution - Wiring: onCleanup(serverSDK.event.onReconnect(...)) - Bootstrap integration: repair on 'server.connected' + reconnect ### Tests (+107 lines) - createActivityTrackingFetch: heartbeat detection (3 activity signals) - createActivityTrackingFetch: non-stream passthrough (0 signals) - reconcileActiveSessionStatuses: cleanup finished runs - reconcileActiveSessionStatuses: resync messages (not unopened) - reconcileActiveSessionStatuses: racing bootstrap (already idle) ## Verification ✅ bun test --conditions=solid src/context (315/0) ✅ oxlint packages/app/src/context/*.tsx (0 new) ✅ typecheck (0 errors) ✅ No regression in existing tests ✅ All edge cases covered (concurrent reconnect, query race, bootstrap race) ## Related Issues Closes anomalyco#40910 — Web UI freezes mid-session behind nginx reverse proxy Closes anomalyco#40502 — Web interface does not auto-refresh conversations in real-time Related: anomalyco#39030, anomalyco#39352, anomalyco#38458, anomalyco#32175 (all same root cause) ## Breaking Changes None. Purely additive. New exports only used internally. ## Migration No migration needed. Behavior change only affects error recovery path (previously hung indefinitely, now reconnects after 45s). ## Performance Impact Minimal: - Activity callback on each byte (setTimer already exists) - Watchdog interval: 5s (new, but simple check) - Repair on reconnect: async, non-blocking - No changes to event coalescing or batching logic ## Backward Compatibility ✅ Works with v1 and v2 protocols ✅ Works with all session sync patterns ✅ Works with all transport scenarios (webview, platform fetch, etc) ## Testing in Production Scenarios to test: 1. Nginx proxy with proxy_read_timeout < activity duration 2. NAT/firewall silently dropping connections (tcpdump shows FIN never sent) 3. Cloudflare flexible SSL (stall common at 100s) 4. Mobile browser tab resuming after app suspension 5. Long-running sessions (hours) with intermittent proxy stalls
|
The following comment was made by an LLM, it may be inaccurate: Great! I found related PRs. Here are the potential duplicates: Related/Duplicate PRs Found
Why they're related: The current PR (41299) explicitly consolidates prior attempts #41002 and #39349, combining their best features (watchdog from #41002, state repair and hooks from #39349) plus adding transport-level byte tracking that was missing in both. |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Technical detailsRoot causeThe SSE consumer in Why byte-level tracking is requiredA watchdog reset on parsed events (the approach in #41002) kills healthy idle streams: when a session is quiet, the only traffic on the wire is heartbeat comment frames, which never produce events. With a 45s event-based watchdog, every idle session would be disconnected and reconnected every 45 seconds. Tracking bytes through a fetch wrapper keeps quiet-but-healthy streams alive while still detecting dead ones. Why the repair is transport-driven
Changes
Relationship to earlier PRs
Same root cause also reported in #39030, #39352, #38458, #32175 — this fix covers them as well. |
Issue for this PR
Closes #40910
Closes #40502
Type of change
What does this PR do?
Makes the web UI recover automatically when the
/global/eventSSE stream dies silently (reverse proxy buffering, NAT timeout, dropped connection without FIN/RST). Today the stream consumer blocks onreader.read()forever, the reconnect loop never re-enters, and the UI stays frozen until a manual page refresh.The fix works because:
createActivityTrackingFetchwraps the event-stream fetch and reports every delivered byte. The server heartbeats with SSE comment frames (: heartbeat) which the SSE parser discards without yielding an event, so only byte-level tracking can distinguish a quiet-but-healthy stream from a dead one.AbortController, so a stale timer can never kill a healthy successor connection.reconcileActiveSessionStatusesclearsbusystatus for runs that finished while the stream was down (otherwise the spinner never stops, since thesession.execution.finishedevent was lost) and force-resyncs sessions holding messages (otherwise their timelines stay frozen mid-part). The repair is driven by the transport'sonReconnecthook, which fires on the reconnect attempt itself — not on aserver.connectedframe that may never arrive — and runs over plain HTTP, so it works even if the replacement stream delivers nothing.activeSessionsQuerystate. Going through the query made the repair conditional on that query being idle; a first fetch that never settles would leave it permanently skipped.How did you verify your code works?
bun test --conditions=solid --preload ./happydom.ts src/context— 315 pass / 0 fail (includes 8 new tests: heartbeat byte-tracking, non-stream passthrough, status cleanup for finished runs, message-based resync, racing bootstrap recovery)bunx oxlint packages/app/src/context/server-sdk.tsx server-sdk.test.ts server-sync.tsx server-sync.test.ts— 0 new warningsbun run typecheck(packages/app) — 0 errorsScreenshots / recordings
N/A; this is an event-stream recovery fix. Manual verification path: serve
opencode webbehind nginx with a shortproxy_read_timeout, start a long session, wait for the proxy to drop the stream, and observe the UI resume within ~45s without a page refresh (previously it stayed frozen forever).Checklist