From 7e61d16f0ca6aeca6adb1dd909a7e7b0376719f9 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 17 Oct 2026 10:11:07 +0200 Subject: fix(spa): follow the device to another network Moving between mobile data and the node's Wi-Fi left the connection on the network it had left until ICE gave up, and a group that had failed was never tried again: the Android application had to be killed. A change of network or an ICE disconnection now pings the session and reconnects one that does not answer, wakes a reconnect backing off, and retries a group page left on an error. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/group-page.js | 12 +++ .../src/meshbay_hub/static/transport.js | 109 +++++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 5972e000..728ffc37 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -789,6 +789,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // it; nothing in between should disturb a working connection. }, [groupId, Boolean(token), retryKey]); + // A group that could not be reached is tried again when the device joins + // another network: from the Wi-Fi to mobile data, or back, is what decides + // whether the node can be reached, and in the Android application there is + // no reload to do it. Not while a code, a passphrase or a device link is + // asked for: those wait for the person, not for a network. + useEffect(() => { + if (status !== 'error' || needsPass || needsCode || needsDevice) return undefined; + const T = window.MeshBayTransport; + if (!T || !T.onNetworkChange) return undefined; + return T.onNetworkChange(() => setRetryKey((k) => k + 1)); + }, [status, needsPass, needsCode, needsDevice]); + const downloadFileForModal = useCallback(async (entry) => { // The video/preview modals' own download button — the table's row and // toolbar actions call the same shared helper from files-app.js, since diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 0d987d9a..0b796cab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -168,6 +168,53 @@ async function _localNetworkRefusedBy(pc, offerSdp, answerSdp) { } } +// ── Another network ────────────────────────────────────────────────────────── +// +// A phone moving between mobile data and the Wi-Fi the node is on keeps a +// connection whose candidate pair runs over the network it left. ICE says so +// only once its consent checks lapse, about thirty seconds, and on Android not +// before the old network has lingered; a connection that had failed was not +// tried again at all, and the application had to be killed to reach the group. + +// How long after the change before anything is tried: the new network's route +// and addresses take a moment, and a burst of events is one change. +const NETWORK_SETTLE_MS = 1500; +// How long a connection that may be on the old network has to answer a ping. +const NETWORK_PROBE_MS = 5000; + +/** + * Calls `fn` once the device has moved to another network, and returns the way + * to stop. `online` is the end of no network at all; the connection's `type` + * (Chrome on Android, and its WebView) is Wi-Fi against mobile data, and its + * other changes, a bandwidth estimate, are not news; `meshbay-default-network` + * is the Android application's own word for it (MainActivity.watchNetwork). + */ +function onNetworkChange(fn) { + const connection = (typeof navigator !== 'undefined' && navigator.connection) || null; + let type = connection ? connection.type : undefined; + let timer = null; + const changed = () => { + clearTimeout(timer); + timer = setTimeout(fn, NETWORK_SETTLE_MS); + }; + const onConnection = () => { + if (connection.type === undefined || connection.type === type) return; + type = connection.type; + changed(); + }; + window.addEventListener('online', changed); + window.addEventListener('meshbay-default-network', changed); + if (connection && connection.addEventListener) connection.addEventListener('change', onConnection); + return () => { + clearTimeout(timer); + window.removeEventListener('online', changed); + window.removeEventListener('meshbay-default-network', changed); + if (connection && connection.removeEventListener) { + connection.removeEventListener('change', onConnection); + } + }; +} + // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -719,6 +766,52 @@ class MeshBayTransport { if (document.visibilityState === 'visible') this._wakeReconnect(); }; document.addEventListener('visibilitychange', this._onVisibilityWake); + this._networkProbe = null; + this._offNetwork = onNetworkChange(() => this._networkChanged()); + } + + /** + * The device moved to another network. A reconnect that is backing off tries + * again at once, on the network just joined; an open session is asked + * whether it still answers. + */ + _networkChanged() { + if (this._closed) return; + trace('network_change', { + pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, + session: this._sessionUp, reconnecting: !!this._reconnectPromise, + }); + if (this._reconnectPromise) { this._wakeReconnect(); return; } + this._probeSoon('network_change', 0); + } + + /** A ping a moment from now; no answer is a lost connection, reconnected. */ + _probeSoon(why, delayMs = NETWORK_SETTLE_MS) { + if (this._closed || !this._sessionUp) return; + clearTimeout(this._networkProbe); + this._networkProbe = setTimeout(async () => { + const pc = this._pc; + if (this._closed || !this._sessionUp || this._reconnectPromise) return; + try { + await this.ping(NETWORK_PROBE_MS); + } catch { + if (pc !== this._pc || this._closed || this._reconnectPromise) return; + trace('probe_lost', { why }); + console.warn('[MeshBay] No answer after', why, '- reconnecting'); + this._connectionLost(); + } + }, delayMs); + } + + /** The connection is gone: reconnect, and fail what was waiting on it. */ + _connectionLost() { + this._connected = false; + this._sessionUp = false; + this._reconnect(); + const err = new Error('WebRTC connection lost'); + err.name = 'TransportLostError'; + for (const [, p] of this._pending) p.reject(err); + this._pending.clear(); } /** Cuts short a reconnect currently backing off (see _reconnectLoop). A @@ -966,13 +1059,7 @@ class MeshBayTransport { // staying "open" throughout, so every send from here on would simply // sit out its own timeout instead of failing fast. if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) { - this._connected = false; - this._sessionUp = false; - this._reconnect(); - const err = new Error('WebRTC connection lost'); - err.name = 'TransportLostError'; - for (const [, p] of this._pending) p.reject(err); - this._pending.clear(); + this._connectionLost(); } }; pc.oniceconnectionstatechange = () => { @@ -985,6 +1072,11 @@ class MeshBayTransport { iceReached = true; if (pc === this._pc) this._noteConnectProgress('ice_connected'); } + // Often a moment's loss that clears itself, and as often the network + // under the pair gone: a ping tells which long before "failed" does. + if (pc.iceConnectionState === 'disconnected' && pc === this._pc) { + this._probeSoon('ice_disconnected'); + } }; // Diagnostic-only: a periodic health ping and a resume-triggered one, so @@ -1855,6 +1947,8 @@ class MeshBayTransport { this._closed = true; this._closeLanes(); document.removeEventListener('visibilitychange', this._onVisibilityWake); + this._offNetwork(); + clearTimeout(this._networkProbe); this._wakeReconnect(); if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; } if (this._channel) this._channel.close(); @@ -2660,4 +2754,5 @@ function extendTransport(part) { // Export MeshBayTransport.localNetworkRefused = localNetworkRefused; +MeshBayTransport.onNetworkChange = onNetworkChange; window.MeshBayTransport = MeshBayTransport; -- cgit v1.2.3