aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 12:44:50 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 12:44:50 +0200
commit5dcf3066a2be83d5422ea176e39b413c4769bcf8 (patch)
treeef0d8f649f0873d6b788f9dacceb8d470a0f0603 /packages
parent03c2c0deaebee645bd61dfb8d4e7bd9d942e6408 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js40
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();