Skip to content

[2.x] fix(realtime): recover desktop tabs after silent socket loss and catch up missed events#4718

Draft
ekumanov wants to merge 1 commit into
flarum:2.xfrom
ekumanov:fix/realtime-desktop-reconnect-catchup
Draft

[2.x] fix(realtime): recover desktop tabs after silent socket loss and catch up missed events#4718
ekumanov wants to merge 1 commit into
flarum:2.xfrom
ekumanov:fix/realtime-desktop-reconnect-catchup

Conversation

@ekumanov

Copy link
Copy Markdown
Contributor

Fixes #4717.

Full analysis: see the issue. Short version: desktop Safari suspends hidden pages exactly like iOS WebKit, so the WebSocket dies silently while the tab is backgrounded — but #4662 gated the #4590/#4654 recovery to isIOS(), pusher-js's own reconnects perform no catch-up on any platform, and a ≥2-post gap permanently disables live-append in an open discussion because PostStreamState.update() is guarded by viewingEnd()'s 1-post drift tolerance. Net effect observed in production: a missed reply never appears (quiet discussion), or appears ~50 minutes late only because newer replies happened to force a range load.

Changes

extensions/realtimeextend/Application.ts

  • The visibilitychange recovery now fires for isIOS() (unchanged) or whenever the connection is demonstrably unhealthy: connection.state !== 'connected', or no protocol frame received within the activity window (65 s — server-advertised activity_timeout 30 s + pong wait), tracked via a bind_global last-activity timestamp. Healthy desktop tabs keep receiving pusher:pong every ≤30 s even while hidden (verified in Chrome with a 15-minute hidden-tab soak), so plain tab switches still cause no spurious refetch — the [2.x] fix(realtime): only force-reconnect on iOS visibilitychange #4662 complaint stays fixed; the zombie case (state says connected, socket long dead) now reconnects.
  • New watchConnection() applied to the initial Pusher instance and to every fresh instance forceReconnect() builds: on any transition into connected after a previously established connection — pusher-js-internal recovery as well as forced rebuilds — it runs catchUp(). This replaces the one-shot onReconnected refresh that only the iOS path used to get.
  • catchUp() refreshes the discussion list (as before) and re-syncs an open DiscussionPage: it captures stream.viewingEnd() before the refetch (reliable at that point — the missed posts haven't reached the store yet), then store.find('discussions', id) and, if the user was at the end, loads through to the new count.

extensions/realtimeextend/Discussion/NewActivity.ts

  • Both websocket handlers now capture viewingEnd() before app.store.pushPayload(...) updates postIds, and call stream.syncEnd() when the user was at the end. Previously, one missed event put the drift at ≥2 and every subsequent update() returned early — live-append was permanently dead for that page view even though events were arriving fine.

framework/corestates/PostStreamState.ts

  • New syncEnd(): load through to the latest post, expanding the visible window — i.e. update() without the drift guard, for callers that have already established end-ness. update() is refactored to delegate to it and documents the caveat. No behavior change for existing callers.

Testing

  • framework/core/js/tests/unit/forum/states/PostStreamState.test.ts: three new cases — update() appends a single new post when viewing the end; update() refuses at ≥2-post drift (locks the guard semantics the fix relies on); syncEnd() loads through regardless of drift. All 10 cases in the file pass.
  • The realtime extension has no JS test harness, so the reconnect logic is covered by manual verification: foreground delivery, hidden-tab delivery, and reconnect catch-up exercised against a production rc.3 forum (self-hosted websocket server behind Cloudflare) with two accounts in a private discussion.
  • yarn build and yarn check-typings pass for the extension; yarn check-typings passes for core.

Per convention, no js/dist/dist-typings are included.

…h up missed events

WebKit suspends hidden pages on desktop Safari just like on iOS, so the
WebSocket dies silently (often without `close`) while pusher-js's
foreground-only activity timers cannot notice. The visibilitychange
recovery from flarum#4590/flarum#4654 was gated to isIOS() by flarum#4662 and never runs
there, and pusher-js's own reconnects perform no catch-up — so posts
that fired while the socket was down never appear, and the open
discussion silently stops live-updating (a >=2-post gap makes
PostStreamState.update()'s viewingEnd() guard refuse forever).

- Reconnect on visibility-restore when the connection is demonstrably
  unhealthy (state not 'connected', or no protocol frame within the
  activity window), in addition to the unconditional iOS path. Healthy
  desktop tabs keep receiving pongs while hidden, so plain tab switches
  still trigger no refetch (flarum#4662 intact).
- Catch up after every effective reconnect (pusher-internal or forced):
  refresh the discussion list and re-sync an open DiscussionPage,
  capturing viewingEnd() before the refetch grows postIds.
- Add PostStreamState.syncEnd() — update() without the 1-post drift
  bound — and capture end-ness in NewActivity before pushing event
  payloads, so a gap no longer permanently disables live-append.

Fixes flarum#4717.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ekumanov ekumanov force-pushed the fix/realtime-desktop-reconnect-catchup branch from 003a0ab to 5b7ad93 Compare June 12, 2026 18:09
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.x] Realtime: backgrounded desktop Safari tabs silently miss events, and a missed event permanently breaks live-append for the open discussion

1 participant