Skip to content

web: stalled-stream watchdog merged in #47571 was lost in the v2 client refactor — zombie SSE requires hard refresh #51857

Closed

Description

@afonsoft

Description

The web UI still loses live updates whenever the SSE event stream dies silently (tab suspended, network flap, proxy idle timeout) and only recovers after a hard refresh. This was already reported in #39030, #47258 and #45860, but there is an important detail those reports miss:

A fix for this exact failure mode was already merged — and the v2 client refactor removed it.

PR #47571 (fix(client): detect stalled event streams and resync on foreground, merged 2026-09-06) implemented a proper stall watchdog in packages/client/src/solid/connection.ts:

  • every received chunk (including server.heartbeat keepalives) pushed a stall deadline forward;
  • an idle setTimeout aborted the request with "Event stream stalled", triggering reconnect + resync;
  • visibilitychange and online events forced a resync;
  • a connected | connecting | reconnecting status was exposed to consumers.

On current dev (083ed266e, v1.18.33), packages/client/src/solid/ no longer exists, and the app's live event path is packages/app/src/context/server-sdk.tsx, which contains a naive reconnect loop with none of that liveness logic:

// packages/app/src/context/server-sdk.tsx (~L268-309)
while (!abort.signal.aborted && started && generation === active) {
  attempt = new AbortController()
  try {
    const events = kind === "v1"
      ? (await eventSdk.global.event({ signal: attempt.signal })).stream
      : eventApi.event.subscribe({ signal: attempt.signal })
    for await (const event of events) { /* ... */ }
  } catch (error) { /* logs, then falls through */ }
  await wait(RECONNECT_DELAY_MS) // fixed 250ms — see also #48014
}

The server already emits server.heartbeat every 10s and sets X-Accel-Buffering: no (packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts), so the keepalive exists on the wire — the client just never uses it to detect a dead stream. If the TCP connection half-opens (suspended tab, NAT/proxy drop without RST, cloudflared/reverse-proxy restart), for await never resolves and never rejects: the UI is stale forever.

Additionally, pagehide always calls stop(), but pageshow only restarts when event.persisted === true (resumeStreamAfterPageShow) — and an open streaming fetch makes the page ineligible for bfcache, so persisted is almost always false.

Expected behavior

Port the watchdog semantics that #47571 introduced into server-sdk.tsx (or wherever the app's event stream now lives):

// sketch: stall watchdog driven by the existing 10s server heartbeat
const STALL_TIMEOUT_MS = 3 * HEARTBEAT_INTERVAL_MS + 5_000

let watchdog: ReturnType<typeof setTimeout> | undefined
const arm = () => {
  clearTimeout(watchdog)
  watchdog = setTimeout(() => {
    attempt?.abort(new Error("event stream stalled"))
    // then resync: existing server.connected path already re-bootstraps
    // active directories in server-sync.tsx
  }, STALL_TIMEOUT_MS)
}
// inside the for-await loop: arm() on every event (heartbeats count)

plus a visibilitychange listener that aborts/resyncs when the page returns to the foreground after going quiet — restoring the behavior users had before the refactor.

Steps to reproduce

  1. opencode web behind any proxy (or directly), open a session.
  2. Suspend the tab (Chrome: chrome://discards, or switch apps on mobile / sleep the OS long enough for the TCP connection to drop without RST).
  3. Return to the tab.
  4. Trigger a new event server-side → the UI does not update. Ctrl+F5 required.

OpenCode version

v1.18.32–v1.18.33 (dev @ 083ed266e)

Operating System

Observed on Linux server (Nginx + Cloudflare Tunnel in front); reproduction is client-side and platform-independent.

Related

#39030, #47258, #45860, #46733, #49861, #43519 (server-side: heartbeats reset chunkTimeout, so dead streams never expire either), #48014 / PR #48015 (missing reconnect backoff, adjacent), PR #47571 (the merged fix that was lost).


Related work (open issues this would resolve or interact with)

Ref State What it reports / fixes
#39030 open issue Canonical repro: mobile/desktop tab returns from background → stream dead → manual refresh required.
#47258 open issue Same root cause, with precise pointers to server-sdk.tsx pagehide/pageshow + event.persisted.
#45860 open issue Session status goes stale after tab suspension (mobile).
#46733 open issue /global/event + /event deliver only server.connected + heartbeats — zero message events (1.18.25).
#49861 open issue SSE drops all events for worktree sessions (current dev).
#48771 open issue V2 streams stall without an SSE Accept header (AV/proxy interference).
#43519 open issue Server-side counterpart: chunkTimeout resets on heartbeats, so stalled streams never time out either.
#38458 open issue SSE closes mid-turn on opencode serve.
#48014 open issue Reconnect loop uses a fixed 250ms delay, no backoff — reconnect storms, self-inflicted 499s on re-bootstrap.
PR #47571 merged (lost) The original stall watchdog: idle abort, visibilitychange/online resync, connection status. File deleted in the v2 refactor — the regression this issue tracks.
PR #48015 open Backoff for event-stream reconnects (closes #48014) — same fix independently implemented downstream in the alltomatos/opencode fork (commit 6722d7a86).
PR #50375 / #51835 open (dup) Release SSE streams when a Bun client disconnects — server-side stream leak; plausible cause of listener-leak warnings under load.
PRs #44903, #39028, #29987, #26471, #19461, #10723 closed Prior fix attempts for the same failure class — this bug has regressed repeatedly; a watchdog (not another event listener) is the durable fix.

Triage note for maintainers

This is the single highest-impact web reliability defect from the user's perspective: it produces daily "frozen" UIs that only Ctrl+F5 recovers, and it keeps regenerating new reports (#39030 → #47258 → mobile variants). The fix shape is already proven — it existed in packages/client/src/solid/connection.ts — and mostly needs porting to server-sdk.tsx plus coverage to prevent a third regression. Requesting prioritization together with #48014/PR #48015 (backoff) and #43519 (server-side stall reap) so client and server stop trusting half-open connections.

Activity

  1. github-actions commented on Sep 28, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. afonsoft commented on Sep 28, 2026

    @afonsoft
    Author

    cc @thdxr @adamdotdevin — maintainers, requesting triage/prioritization if possible.

    Impact context: this hits real production installs running opencode web behind reverse proxies (nginx + Cloudflare Tunnel) daily — the UI silently stops reflecting session state and only a hard refresh recovers. The failure is invisible to users and keeps regenerating reports (#39030, #47258, #45860, mobile variants).

    Suggested priority batch: this watchdog port + #48014/PR #48015 (reconnect backoff) + #43519 (server-side stall reap) form one coherent fix: stop trusting half-open connections on both ends. The implementation is mostly a port of code that already existed (PR #47571), so effort should be moderate.

    Happy to provide a repro environment, HAR captures, or a PR if that's helpful.

  3. gastonsanchezyepez commented on Sep 29, 2026

    @gastonsanchezyepez

    Independent reproduction + a data point from 1.18.33 (current stable)

    Self-hosted opencode serve 1.18.33 behind a reverse proxy. Found #39352 / #39729 afterwards — this matches what I measured independently.

    Two failure modes, isolated with a headless browser:

    • Clean close (restarted the server): the client reconnects in ~4 s — the reconnect loop works.
    • Mute socket (open, no data arriving): never recovers — UI silently freezes, no error anywhere.

    So what's missing is detection of a stalled stream, not the reconnect path.

    On stable the heartbeat is a delivered event — and it is still ignored. It arrives as a regular SSE data: frame {"type":"server.heartbeat"}, not the : heartbeat comment frame described for v2.

    Interval measured over 150 s: 10.00 s at the socket, 10.72 s as seen by the browser. The shipped bundle has zero references to heartbeat and no inactivity timeout on the event path — the frames are dispatched, then ignored.

    So the missing liveness check looks older than the v2 comment-frame change: v2 made it worse, it didn't introduce it. Worth validating #51871 against stable too.

  4. LeonMueller-OneAndOnly commented on Oct 14, 2026

    @LeonMueller-OneAndOnly
    Contributor

    Possibly relevant when porting the watchdog: in Firefox a reconnect to /api/event can also hang before it is even sent, because the response's Cache-Control: no-cache lets Firefox cache the stream and concurrent requests for the same URL wait on that cache entry (reproducible with two tabs). A watchdog alone would just keep retrying into the same queue; serving the stream with Cache-Control: no-store (and/or fetching with cache: "no-store") avoids it. Details: #55369

  5. neriousy commented on Oct 19, 2026

    @neriousy
    Member

    v2 now detects stalled event streams, reconnects automatically, and resyncs when the page returns to the foreground or the network comes back online. The separate Firefox stream-caching problem mentioned above remains tracked in #55369. You can install v2 with npm i -g @opencode/cli@latest. If you still see this on v2, please open a new issue with the details.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions