Skip to content

fix(client): detect stalled event streams and resync on foreground - #47571

Merged
Hona merged 1 commit into
anomalyco:v2from
Hona:stream-idle-watchdog
Sep 6, 2026
Merged

Hona merged 1 commit into
anomalyco:v2from
Hona:stream-idle-watchdog

Conversation

@Hona

@Hona Hona commented Sep 6, 2026

Copy link
Copy Markdown
Member

Lock a phone with beta.opencode.ai open, unlock it ten minutes later, and the session is frozen at its pre-lock state until you reload. The socket died while the page was suspended, but the browser never reported that on the hung reader.read(), so connection.status() stayed "connected" and none of the resync paths (which key off a status transition or server.connected) ever fired.

Why the app could not notice

  • The only timeout was the 2 s connect timeout on the first event. After server.connected, iterator.next() could hang forever.
  • The server writes : heartbeat every 15 s, but the SSE parser drops comment lines, so nothing in the client knew the stream had gone quiet.
  • pageLifecycle only wired pagehide/pageshow. Locking a phone or switching apps fires visibilitychange, not pagehide.

What changes

sequenceDiagram
  participant S as Server
  participant SSE as SSE reader
  participant SE as SharedEvents
  participant C as createClientConnection
  S-->>SSE: ": heartbeat" (every 15 s)
  SSE->>SE: onActivity()
  SE->>C: onActivity()
  C->>C: reset 45 s watchdog
  Note over C: device sleeps, socket dies silently
  C->>C: watchdog fires (or visibilitychange → visible after >20 s idle)
  C->>SSE: abort("Event stream stalled")
  C->>C: status = reconnecting → sync.invalidate()
  C->>S: new /api/event
  S-->>C: server.connected → full resync
Loading
  • RequestOptions.onActivity (codegen + regenerated client): the SSE reader reports every chunk, including keepalive comments. SharedEvents.subscribe({ onActivity }) fans it out per subscriber.
  • Idle watchdog in createClientConnection: any received bytes push a 45 s deadline out. A timer whose deadline passed while the page was suspended fires as soon as it resumes, so a dead socket is replaced within milliseconds of unlock.
  • Foreground hooks: visibilitychange → visible forces an immediate reconnect only if the stream has been silent for more than one heartbeat interval (20 s); a live desktop tab that kept receiving heartbeats is left alone. online always forces one. Forced resyncs skip the 1 s backoff.
  • stop() now marks the connection as not connected, and pageshow always calls start(). Previously a pagehide without a persisted pageshow left a dead stream reporting "connected" forever.
  • start() runs at creation instead of in onMount; the first event is always delivered asynchronously through the flush timer, so consumers see no ordering change.
const iterator = api.event.subscribe({ signal: request.signal, onActivity: touch })[Symbol.asyncIterator]()

const touch = () => {
  lastActivity = Date.now()
  if (connectedAt === undefined) return
  clearTimeout(watchdog)
  watchdog = setTimeout(() => request.abort(new Error("Event stream stalled")), idleTimeout)
}

Copilot AI lite review requested due to automatic review settings September 6, 2026 03:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona
Hona enabled auto-merge (squash) September 6, 2026 04:30
@Hona
Hona merged commit 1be3b32 into anomalyco:v2 Sep 6, 2026
14 of 15 checks passed
@Hona
Hona deleted the stream-idle-watchdog branch September 6, 2026 05:15
Sign up for free to 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.

2 participants