diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-26 12:44:50 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-26 12:44:50 +0200 |
| commit | 5dcf3066a2be83d5422ea176e39b413c4769bcf8 (patch) | |
| tree | ef0d8f649f0873d6b788f9dacceb8d470a0f0603 /packages/meshbay-hub/src/meshbay_hub/static/transport.js | |
| parent | 03c2c0deaebee645bd61dfb8d4e7bd9d942e6408 (diff) | |
| download | meshbay-5dcf3066a2be83d5422ea176e39b413c4769bcf8.tar.gz | |
fix(transport): wake a backing-off reconnect on visibilitychange, fix listener leak
Confirmed live by trace: during a screen lock, every reconnect attempt
failed with "Failed to fetch" (the browser grants no network access to a
locked/backgrounded tab, no code can change that) — expected. But the
backoff timer itself was also throttled while locked: an attempt scheduled
30s out took ~3 minutes of wall clock to fire, because a backgrounded tab's
timers run only when the OS lets them. Recovery after unlocking was
correspondingly delayed rather than prompt.
Fix: an always-on visibilitychange listener (separate from the diagnostic
one, and unlike it not gated on trace mode) resolves the current backoff
wait immediately once the page is visible again, instead of waiting out
whatever of it is left. The actual reconnect this enables is fast (~1.2s in
the trace that showed the "Failed to fetch" run) — the wait was the
throttled part.
Also fixes a real bug the same trace exposed: connect() re-arms the
diagnostic visibility listener and health-ping interval on every attempt
without ever removing the previous instance's — 8 failed attempts during
one lock left 8 duplicate `visibility` trace lines per real event, and (more
than a cosmetic issue) 8 concurrent health-ping intervals once reconnected.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 40 |
1 files changed, 39 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 4eb7ac2..35f729e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -237,6 +237,27 @@ class MeshBayTransport { this._inReconnectAttempt = false; this._onReconnected = null; this._onNeedToken = null; + // Cuts the backoff wait short the moment the page is foregrounded again — + // found live to matter: a screen lock throttles the tab's own timers + // along with everything else, so a backoff already counting down when the + // phone locked can run for minutes of *wall clock* past its nominal delay + // before it next gets to run at all. Set once, here, rather than inside + // connect() like the diagnostic listener above it — this one has to + // survive every reconnect attempt, not restart with each one. + this._reconnectWakeResolve = null; + this._onVisibilityWake = () => { + if (document.visibilityState === 'visible') this._wakeReconnect(); + }; + document.addEventListener('visibilitychange', this._onVisibilityWake); + } + + /** Cuts short a reconnect currently backing off (see _reconnectLoop). A + * no-op when nothing is waiting, so this is safe to call unconditionally. */ + _wakeReconnect() { + if (this._reconnectWakeResolve) { + this._reconnectWakeResolve(); + this._reconnectWakeResolve = null; + } } get connected() { return this._connected; } @@ -367,6 +388,14 @@ class MeshBayTransport { // a trace captures exactly what state the connection was in right as the // page comes back from being backgrounded/locked — never active unless // trace mode is on (see TRACE_KEY above). + // + // connect() runs again on every reconnect attempt (see _reconnectLoop), + // and each run used to add its own listener/interval on top of the + // previous one without ever removing it — confirmed live: 8 failed + // attempts during one screen lock left 8 duplicate `visibility` trace + // lines firing off the same real event. Disposing of the prior instance + // first is what keeps this to one. + if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (traceEnabled()) { const healthPing = async (reason) => { const before = { @@ -673,7 +702,14 @@ class MeshBayTransport { this._reconnectAttempts += 1; const delayMs = Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1)); trace('reconnect_wait', { attempt: this._reconnectAttempts, delay_ms: delayMs }); - await new Promise((r) => setTimeout(r, delayMs)); + // Interruptible: _wakeReconnect (fired on visibilitychange → visible) + // resolves this immediately instead of waiting out the rest of a + // backoff that was mostly spent while nothing could succeed anyway. + await new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + this._reconnectWakeResolve = () => { clearTimeout(timer); resolve(); }; + }); + this._reconnectWakeResolve = null; if (this._closed) return; try { // Best-effort: these are already unusable, but leaving them wired up @@ -1757,6 +1793,8 @@ class MeshBayTransport { // only skips reconnecting because of this flag, not because "closed" is // absent from its own trigger condition. this._closed = true; + document.removeEventListener('visibilitychange', this._onVisibilityWake); + this._wakeReconnect(); if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); |