Skip to content

fix(app): recover stale event streams — stall watchdog, foreground resync, reconnect backoff - #51871

Open
afonsoft wants to merge 2 commits into
anomalyco:devfrom
afonsoft:sse-stall-watchdog
Open

afonsoft wants to merge 2 commits into
anomalyco:devfrom
afonsoft:sse-stall-watchdog

Conversation

@afonsoft

Copy link
Copy Markdown

Issue for this PR

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The web UI's event stream client (packages/app/src/context/server-sdk.tsx) only recovered when the streaming fetch rejected. When the underlying TCP connection dies silently — tab suspended by the OS/browser, proxy or NAT drop without RST, tunnel restart — for await never resolves and never throws, so the UI goes stale until Ctrl+F5. The server already emits server.heartbeat every 10s (packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts); the client just never used it.

This restores the liveness semantics that PR #47571 had in packages/client/src/solid/connection.ts (deleted in the v2 client refactor) inside the current event path:

  1. Stall watchdog — a timer re-armed on every received event (heartbeats count). If nothing arrives for STREAM_STALL_TIMEOUT_MS (45s = >3× the 10s heartbeat), the attempt is aborted and the loop resyncs. A stalled stream logs "event stream stalled" once instead of silently hanging.
  2. Foreground resync — visibilitychange → document.visibilityState === "visible" and window.online now force resync(), which aborts the current attempt so the loop resubscribes immediately; the server's server.connected then drives the existing full re-bootstrap in server-sync.tsx.
  3. pageshow always restarts — pagehide calls stop(), but resumeStreamAfterPageShow only restarted when event.persisted was true. An open streaming fetch makes the page ineligible for bfcache, so persisted is almost always false and the stream stayed dead after ordinary tab returns. resumeStreamAfterPageShow is removed; pageshow now calls start() directly (idempotent).
  4. Reconnect backoff — consecutive attempts that drop in under STABLE_CONNECTION_MS (3s) back off exponentially from 250ms to 10s plus jitter, instead of a fixed 250ms reconnect loop. Each reconnect emits server.connected → full re-bootstrap; without backoff, a flapping connection self-inflicted aborted bootstraps (visible as repeated HTTP 499s).
// watchdog, per connection attempt
const armStallWatchdog = () => {
  clearTimeout(stallTimer)
  stallTimer = setTimeout(() => {
    stalled = true
    attempt?.abort()
  }, STREAM_STALL_TIMEOUT_MS)
}
// armed once after subscribe, then on every event in the for-await loop
// backoff
const backoff = Math.min(RECONNECT_DELAY_MS * 2 ** consecutiveFastDrops, RECONNECT_DELAY_MAX_MS)
await wait(backoff + jitterSeed * Math.min(backoff, RECONNECT_DELAY_MS))

How did you verify your code works?

Screenshots / screen recording

N/A — recovery logic, no visual change. (A connection-health surface to show these states is proposed separately in #51860.)

Checklist

  • I have read the contributing docs
  • Tests added/updated for the new recovery behavior (reconnectDelay, stall timeout invariant)
  • Existing coalesceServerEvents/enqueueServerEvent/adaptServerEvent tests still pass — event ordering and delta coalescing unchanged
  • No contract changes: consumes existing server.connected/server.heartbeat events only

… resync, and reconnect backoff

The event stream client only reconnected when the streaming fetch
rejected. A half-open connection (suspended tab, proxy/NAT drop
without RST) delivers no events and no error, so the UI stayed stale
until a hard refresh — the failure class reported across anomalyco#39030,
anomalyco#47258, anomalyco#45860 and anomalyco#51857.

- Add a stall watchdog armed per event: the server emits a heartbeat
  every 10s, so if nothing arrives within 45s the connection is dead
  in practice — abort and resync instead of waiting forever.
- Force a resync on visibilitychange->visible and window online
  events, matching the foreground-recovery behavior that anomalyco#47571 had
  before the v2 client refactor removed it.
- Always restart the stream on pageshow: an open streaming fetch
  makes the page ineligible for bfcache, so the previous
  persisted-only check left the stream stopped after ordinary tab
  returns.
- Back off reconnects exponentially (250ms -> 10s + jitter) when
  attempts keep dropping fast, instead of a fixed 250ms loop that
  aborted each re-bootstrap with the next reconnect (anomalyco#48014).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@afonsoft

Copy link
Copy Markdown
Author

cc @thdxr @adamdotdevin @kitlangton — requesting review when you have a moment.

Why this PR: the web UI's SSE client had no liveness detection after the v2 refactor removed the stall watchdog that #47571 introduced — a half-open connection leaves the app stale until a hard refresh (daily pain behind reverse proxies; tracked in #39030, #47258, #45860, #51857). This restores that watchdog in server-sdk.tsx, plus foreground resync (visibilitychange/online), an always-restart on pageshow, and reconnect backoff (#48014).

Scope is deliberately narrow: one file's recovery loop + its test file; no server changes, no contract changes — it consumes the existing server.heartbeat.

Verified locally: 14/14 unit tests pass, tsgo typecheck clean, no new lint warnings.

Happy to split the backoff into a separate PR if you'd rather land #48015 first — the two changes touch the same loop, so whichever merges second will need a small rebase either way.

@afonsoft

Copy link
Copy Markdown
Author

Why this fix matters

This is currently the single largest source of "dead" web UI reports.

The failure class this PR closes — an SSE stream that dies silently and never recovers — accounts for a long chain of user-visible reports, and it keeps recurring because every previous fix addressed only one symptom instead of stream liveness itself:

Impact it removes:

  1. Silent data staleness — users see a UI that looks live but is not. Messages arrive server-side, the page just never shows them. That is the worst kind of bug: it erodes trust in correctness, not just responsiveness.
  2. Reconnect storms — the fixed 250ms retry loop meant each reconnect re-bootstraps every open project directory, and a flapping connection aborts each bootstrap with the next reconnect (self-inflicted HTTP 499s). Exponential backoff converts a sustained storm into a handful of attempts.
  3. Lost multi-session reliability — behind reverse proxies/tunnels (nginx, Cloudflare Tunnel), idle connections are dropped without TCP RST routinely. Every such deployment is disproportionately affected.

Cost/risk of the change is minimal: ~75 lines in one file, no server or API contract changes, driven entirely by events already on the wire. The unit tests cover the new invariants (reconnectDelay growth/cap/jitter bounds, stall timeout > 3 heartbeats). In exchange it retires the most-reported web reliability complaint in the tracker.

If it helps review: the watchdog + backoff + resync are separable — happy to split if that gets parts merged faster.

@afonsoft

afonsoft commented Sep 28, 2026 •

Copy link
Copy Markdown
Author

cc @thdxr @adamdotdevin — adding implementation detail for reviewers.

Exact behavior changes in server-sdk.tsx:

  1. armStallWatchdog() is created per attempt and re-armed inside the for await loop on every event — heartbeats included. After STREAM_STALL_TIMEOUT_MS (45s = >3× the 10s server heartbeat) with zero events, it sets stalled and aborts the attempt's AbortController. The catch branch logs event stream stalled once per incident (same once-flag pattern as the existing streamErrorLogged) and the loop proceeds to a fresh connect — this is the watchdog half of what fix(client): detect stalled event streams and resync on foreground #47571 did in packages/client/src/solid/connection.ts.
  2. resync(reason) aborts the in-flight attempt (no-op if none is in flight — e.g. between reconnects, the loop is already on its way). Triggered by visibilitychange→visible and window.online — the two browser signals that fire exactly when a half-open SSE fetch is most likely.
  3. pageshow now calls start() unconditionally. The previous persisted-only check was effectively dead code for this app: an open streaming fetch makes the page ineligible for bfcache, so persisted is almost always false when pagehide had killed the stream. start() is idempotent (if (started) return run), so double-fires are safe.
  4. Reconnect delay: reconnectDelay(consecutiveFastDrops) — 250ms base doubling per fast drop (<3s connected), capped at 10s, plus jitter bounded by min(backoff, 250ms). A connection that survives ≥ STABLE_CONNECTION_MS resets the counter, so a single hiccup on a healthy link still reconnects fast.

Why heartbeat-based detection is safe: the server emits server.heartbeat unconditionally every 10s on /event — see handlers/event.ts. 45s of silence therefore means the connection is dead with very high confidence; abort+reconnect is strictly better than the previous behavior (wait forever).

14/14 unit tests pass (new: delay growth 250→500→1000→2000, 10s cap, jitter bounds, stall>3 heartbeats); typecheck and lint clean.

@afonsoft

afonsoft commented Oct 3, 2026

Copy link
Copy Markdown
Author

Reviewed the diff in detail — the recovery semantics are right and the scope discipline (one file, no contract changes, reuses the existing 10s server.heartbeat) is exactly what makes this landable. A few findings:

Consider — visibilitychange/online resync is unconditional. Every tab foreground now aborts the attempt → reconnect → server.connected → full re-bootstrap of every open project directory. A user who alt-tabs frequently generates the same self-inflicted bootstrap churn the backoff is meant to dampen. Suggest gating on staleness so a healthy stream isn't torn down on every focus:

let lastEventAt = 0
// inside the for-await loop, next to armStallWatchdog():
lastEventAt = Date.now()

makeEventListener(document, "visibilitychange", () => {
  if (document.visibilityState !== "visible") return
  if (Date.now() - lastEventAt < STREAM_STALL_TIMEOUT_MS) return
  resync("Page returned to the foreground")
})

online can keep the same gate — window.online fires on any network flap, not just recovery from a dead connection, so a healthy stream shouldn't pay a re-bootstrap for it either. The watchdog remains the backstop for the genuinely half-open case; this just avoids paying a full re-bootstrap on every foreground when the stream is fine.

Nit — only the pure reconnectDelay is tested. The interesting invariants (watchdog aborts after STREAM_STALL_TIMEOUT_MS of silence, resync on visible, pageshow restart) are untested — understandable since they live inside the closure, but a fake-timer test around the loop would catch a future regression where someone accidentally drops the re-arm inside for await. Non-blocking.

FYI — stalled reset ordering is correct. stalled = false at attempt start plus clearTimeout(stallTimer) in finally means a stale watchdog can't leak across attempts; single-threaded timers can't race the synchronous sections. Just confirming I traced it.

Verdict: approve modulo the resync gating — that's the only behavioral surprise I'd want addressed or explicitly justified.

cc @Hona @Brendonovich — could you review and approve when you get a chance? This closes the largest remaining "dead UI" failure class (SSE half-open after tab suspend / proxy drop), restoring the watchdog semantics #47571 had before the v2 refactor.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant