Skip to content

[2.x] fix(realtime): reconstruct Pusher on iOS reconnect to bypass lives:2 budget - #4654

Merged
imorland merged 2 commits into
flarum:2.xfrom
ekumanov:fix/realtime-ios-reconnect-cycle2
May 12, 2026
Merged

imorland merged 2 commits into
flarum:2.xfrom
ekumanov:fix/realtime-ios-reconnect-cycle2

Conversation

@ekumanov

Copy link
Copy Markdown
Contributor

Background

#4588 + #4590 covered iOS Safari backgrounding by forcing a WebSocket reconnect via visibilitychange/pageshow. That fix handles the first backgrounding cycle, but as raised during the #4590 review and tracked in #4597, the second (and subsequent) cycle silently dies: events stop arriving, the connection looks alive but reports 'failed' and never recovers without a hard reload.

This PR closes #4597.

Root cause

pusher-js 7.6's default_strategy.ts wraps the WebSocket transport in a CachedStrategy with lives: 2. The budget is enforced via TransportManager.reportDeath() — every "unclean" close (code 1006) decrements livesLeft. iOS Safari issues a 1006 on every backgrounding over ~30 s regardless of network health, so after two cycles livesLeft hits 0, isAlive() flips false, the strategy reports unsupported, and from that point every connect() transitions straight to 'failed'. The only recovery is a brand-new Pusher instance with a fresh strategy tree.

Fix

forceReconnect now constructs a fresh Pusher instead of calling disconnect() + connect() on the existing one:

const forceReconnect = (): void => {
  const previous = app.websocket;
  previous?.disconnect();

  const fresh = new Pusher(wsKey, pusherOptions);
  app.websocket = fresh;

  const onReconnected = (): void => {
    fresh.connection.unbind('connected', onReconnected);
    (app as any).discussions?.refresh?.();
  };
  fresh.connection.bind('connected', onReconnected);

  setupChannels(fresh);
  RealtimeState.notifyChannelsReconnected();
};

A fresh strategy tree comes with full livesLeft, so the same recovery path survives arbitrarily many backgrounding cycles.

Supporting refactor

Channel subscription and the inline notification binding move into a shared setupChannels(websocket) so they can run again against the new instance. The previous code path was straight-line inside Application.mount and not reusable.

RealtimeState now retains channel-ready callbacks across calls instead of clearing them after the first fire:

  • notifyUserChannelReady / notifyPublicChannelReady re-invoke every registered subscriber against the new channel. Because the previous channels are GC'd with the previous Pusher instance, re-firing produces no duplicate bindings.
  • A new onChannelsReconnected(callback): () => void hook serves lifecycle-bound consumers that bind handlers in a Mithril oncreate (Discussion/NewActivity, DiscussionList/NewActivity). They register a rebind callback and release it in onremove.

Verification

Deployed to a production Flarum 2.0 forum running this exact bundle on vendor/flarum/realtime/js/dist/. Tested on iPhone Safari against a separate desktop browser session, both logged in to a Byobu private discussion (so push events fire on the user private channel without polluting public visibility).

The visible signal is the realtime notification toast that appears on iPhone whenever the desktop user replies. Procedure:

  1. Cycle 0 (sanity, no backgrounding): desktop replies → iPhone toast appears. ✅
  2. Cycle 1 (one backgrounding ≥ 6 s, then foreground, then desktop replies): toast appears. ✅
  3. Cycle 2 (second backgrounding cycle, then desktop replies): on the rc.2-equivalent baseline, no toast ❌ — confirms Realtime: iOS Safari reconnect fails on second+ backgrounding — pusher-js lives: 2 kill-switch #4597 is reproducible on the current 2.x HEAD. With this PR applied, toast appears. ✅
  4. Cycles 3–N (durability): each subsequent backgrounding + desktop reply consistently delivers the toast. No drift, no double-toasts on any cycle.

Independent regression checks:

  • Initial channel-ready callbacks fire exactly once for new subscribers (the retained-callback change does not produce a spurious second fire at mount).
  • DiscussionPage / IndexPage onremove cleanly releases the reconnect callback (verified by inspecting RealtimeState's internal reconnectCallbacks list before and after page navigation).
  • Both logged-out (public-channel) and logged-in (user-channel) paths reconnect across cycles.
  • pageshow(persisted=true) path (Safari tab-switch rather than app-switch) also produces a working reconnect across multiple cycles, mirroring the visibilitychange path.

Files

  • extensions/realtime/js/src/forum/RealtimeState.ts — retain callbacks, add onChannelsReconnected + notifyChannelsReconnected.
  • extensions/realtime/js/src/forum/extend/Application.ts — extract setupChannels, rewrite forceReconnect to construct a fresh Pusher.
  • extensions/realtime/js/src/forum/extend/Discussion/NewActivity.ts — rebind on reconnect.
  • extensions/realtime/js/src/forum/extend/DiscussionList/NewActivity.ts — rebind on reconnect.

Closes #4597.

ekumanov added 2 commits May 11, 2026 20:06
…s lives:2 kill-switch

PR flarum#4590 fixed the first iOS Safari backgrounding cycle, but the second
cycle silently dies because pusher-js 7.6's default strategy enforces a
`lives: 2` budget on its WebSocket transport. Each iOS-initiated 1006
close decrements the budget; after two cycles the strategy reports
unsupported and every subsequent `connect()` transitions straight to
`'failed'`. Realtime stops working until the page is reloaded.

Replace the disconnect+connect cycle in `forceReconnect` with constructing
a fresh `Pusher` instance. Its strategy tree starts with full `livesLeft`,
so the same recovery survives arbitrarily many backgrounding cycles.

Supporting refactor:

- Channel subscription and the inline `notification` binding are extracted
  into `setupChannels(websocket)` so they can be re-invoked against the new
  instance.
- `RealtimeState` retains channel-ready callbacks across calls so that the
  second `notifyUserChannelReady`/`notifyPublicChannelReady` (fired after
  reconstruction) re-runs every registered subscriber against the new
  channel. The previous channels are discarded with the previous Pusher,
  so re-firing produces no duplicate bindings.
- Lifecycle-bound consumers (`Discussion/NewActivity`,
  `DiscussionList/NewActivity`) register a rebind callback via the new
  `RealtimeState.onChannelsReconnected(...)` hook and release it in
  `onremove`.

Closes flarum#4597.
Includes transpiled JS/TS.
@ekumanov
ekumanov requested a review from a team as a code owner May 11, 2026 17:59
@imorland
imorland merged commit 0c41643 into flarum:2.x May 12, 2026
19 checks passed
@imorland imorland added this to the 2.0.0-rc.2 milestone May 12, 2026
imorland added a commit that referenced this pull request May 13, 2026
)

PR #4654 added a `visibilitychange` listener that forces a fresh
Pusher instance and refreshes the visible discussion list whenever
the tab has been hidden for >5s. That was needed on iOS Safari,
where backgrounding the page silently drops the WebSocket without
firing `close`. On every other platform the WebSocket survives tab
backgrounding fine — but the visibilitychange handler still fired,
causing an unnecessary `GET /api/discussions` request and full list
re-render every time the user switched away and back.

Gate the visibilitychange-triggered `forceReconnect()` on `isIOS()`
so the workaround only runs on the platform that actually needs it.
The `pageshow(persisted=true)` path stays unconditional — bfcache
restoration only fires on browsers that bfcached the page, and the
WebSocket was definitely torn down by then regardless of platform.

`isIOS()` is broader than the existing `isSafariMobile()` core
utility because all iOS browsers use WebKit and share the same
backgrounding pathology — iOS Chrome (`CriOS`) and iOS Firefox
(`FxiOS`) are excluded by `isSafariMobile()` but still need this
workaround.

Regression from #4654.
ekumanov added a commit to ekumanov/framework that referenced this pull request Jun 12, 2026
…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 <[email protected]>
ekumanov added a commit to ekumanov/framework that referenced this pull request Jun 18, 2026
…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 Opus 4.8 <[email protected]>
imorland pushed a commit that referenced this pull request Jul 7, 2026
…h up missed events (#4718)

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 #4590/#4654 was gated to isIOS() by #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 (#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 #4717.

Co-authored-by: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Realtime: iOS Safari reconnect fails on second+ backgrounding — pusher-js lives: 2 kill-switch

2 participants