aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 18:37:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 18:37:35 +0200
commit8125df6d8149368ed8a84c9db6c5e37bfccb5a15 (patch)
tree6eba4f399c7a9b6e785f4d28bc197fb31923d0a3 /packages/meshbay-hub/src/meshbay_hub
parentd5ba9552e27bac71168178f73a4c977a4d36dc1c (diff)
downloadmeshbay-8125df6d8149368ed8a84c9db6c5e37bfccb5a15.tar.gz
fix(spa): a reconnect refused the local network says so
Each failed attempt reaches the group page, which shows the same message and buttons as at opening while the reconnect goes on. Try now gives up an attempt still waiting on ICE and starts the next at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js43
2 files changed, 94 insertions, 17 deletions
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 45049a16..304a28fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -247,6 +247,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// In the Android application, when the local network was refused: whether
// Android will still ask ("prompt") or only its settings can change it.
const [lanBlocked, setLanBlocked] = useState('');
+ // Set while the message is a reconnect refused the local network: the
+ // reconnect that gets through takes it away.
+ const lanLostRef = useRef(false);
const transportRef = useRef(null);
const gekRef = useRef(null);
// One refresh per group: if a fresh token still says we are not a member, we
@@ -394,6 +397,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Dropped by the teardown below, so a transport handed on to a running
// download (`releaseWhenIdle`) stops driving a page that is gone.
let offReconnect = null;
+ let offRefused = null;
let deviceRetry = null;
// The cache is written here and read only by the search page. It used to
@@ -402,10 +406,27 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// cache landing afterwards. Files shows what the node says, or says it
// cannot reach the node.
+ // This device dropped the node's local addresses (transport.js
+ // localNetworkRefused): a setting to change here, not a node to wait for.
+ // Android 17 gates it per application, Chrome from 155. In the application,
+ // whether Android will still ask or only its settings can change it.
+ const explainLanRefusal = async () => {
+ if (!platform.localNetwork.available) {
+ return t(/Android/i.test(navigator.userAgent)
+ ? 'group.lan_blocked_android' : 'group.lan_blocked_browser');
+ }
+ try {
+ const state = await platform.localNetwork.state();
+ if (!cancelled) setLanBlocked(state === 'granted' ? '' : state);
+ } catch { /* the message stands alone */ }
+ return t('group.lan_blocked_app');
+ };
+
const connect = async () => {
setStatus('discovering');
setError('');
setLanBlocked('');
+ lanLostRef.current = false;
// Belongs to the connection about to be made, not the group just left.
setDeviceReady(false);
gekRef.current = null;
@@ -632,8 +653,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// full fetch rather than a delta: this session was never told what it
// missed, and a delta is computed against a snapshot only the node
// has.
+ // Connected on mobile data, then on the node's Wi-Fi without the
+ // permission: every attempt is refused and the reconnect goes on, so a
+ // permission granted or mobile data again reaches the group by itself.
+ // Said while it lasts, over the view the group had.
+ offRefused = transport.addReconnectFailureListener(async (e) => {
+ if (cancelled || e.reason !== 'local_network_blocked') return;
+ const message = await explainLanRefusal();
+ if (cancelled) return;
+ lanLostRef.current = true;
+ setError(message);
+ });
offReconnect = transport.addReconnectListener((reack) => {
if (cancelled) return;
+ if (lanLostRef.current) {
+ lanLostRef.current = false;
+ setError('');
+ setLanBlocked('');
+ }
(async () => {
try {
applyAck(reack);
@@ -721,19 +758,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (err.reason === 'no_keys') setNeedsPass(true);
// An identity sealed before the pepper: only the operator can clear it.
if (err.reason === 'bundle_format_retired') err.message = t('group.bundle_format_retired');
- // This device dropped the node's local addresses (transport.js
- // localNetworkRefused): a setting to change here, not a node to wait
- // for. Android 17 gates it per application, Chrome from 155.
- if (err.reason === 'local_network_blocked' && platform.localNetwork.available) {
- err.message = t('group.lan_blocked_app');
- try {
- const state = await platform.localNetwork.state();
- if (cancelled) return;
- setLanBlocked(state === 'granted' ? '' : state);
- } catch { /* the message stands alone */ }
- } else if (err.reason === 'local_network_blocked') {
- err.message = t(/Android/i.test(navigator.userAgent)
- ? 'group.lan_blocked_android' : 'group.lan_blocked_browser');
+ if (err.reason === 'local_network_blocked') {
+ err.message = await explainLanRefusal();
+ if (cancelled) return;
}
// A key this node has never pinned, for an account it knows. The way in
// is a device already trusted here, not an operator — which is the
@@ -787,6 +814,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
return () => {
cancelled = true;
if (offReconnect) { offReconnect(); offReconnect = null; }
+ if (offRefused) { offRefused(); offRefused = null; }
clearTimeout(deviceRetry);
// Nothing will update this group's dock row once the page lets go of it.
reportIndexPush(groupId, null);
@@ -814,6 +842,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// it; nothing in between should disturb a working connection.
}, [groupId, Boolean(token), retryKey]);
+ // A connection still reconnecting is asked to try now rather than torn down:
+ // what it carries (a download, a film) goes on once it gets through.
+ const retry = useCallback(() => {
+ const transport = transportRef.current;
+ if (transport && transport.reconnecting) transport.retryNow();
+ else setRetryKey((k) => k + 1);
+ }, []);
+
// 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
@@ -1061,12 +1097,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<p class="settings-hint" style="margin-bottom:12px">${t('group.browser_access_off')}</p>`}
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '}
<button class="admin-btn" style="margin-left:8px;font-size:0.9em"
- onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
+ onClick=${retry}>${t('group.retry')}</button>
</div>`}
${lanBlocked === 'prompt' && html`
<button class="admin-btn" style="margin-bottom:12px" onClick=${async () => {
try { await platform.localNetwork.request(); } catch { /* retried either way */ }
- setRetryKey(k => k + 1);
+ retry();
}}>${t('group.lan_allow')}</button>`}
${lanBlocked === 'denied' && html`
<p class="settings-hint">${t('group.lan_settings_hint')}</p>
@@ -1075,7 +1111,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const back = () => {
if (document.visibilityState !== 'visible') return;
document.removeEventListener('visibilitychange', back);
- setRetryKey(k => k + 1);
+ retry();
};
document.addEventListener('visibilitychange', back);
platform.localNetwork.openSettings().catch(() => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 0b796cab..11088adc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -747,6 +747,9 @@ class MeshBayTransport {
// and a single setter meant the second to arrive silently replaced
// the first, then cleared it on the way out.
this._reconnectListeners = new Set();
+ // Told of each attempt that failed, with its error: a reconnect refused
+ // the local network tries again for ever and says nothing otherwise.
+ this._reconnectFailureListeners = new Set();
this._onNeedToken = null;
// Which device key THIS connection has identified itself to the node with.
// Empty means "not identified": nothing can be sealed, so nothing can be
@@ -762,6 +765,9 @@ class MeshBayTransport {
// connect() like the diagnostic listener above it — this one has to
// survive every reconnect attempt, not restart with each one.
this._reconnectWakeResolve = null;
+ // Set by retryNow for the attempt it gave up: the next waits for nothing.
+ this._retryAtOnce = false;
+ this._abandonAttempt = null;
this._onVisibilityWake = () => {
if (document.visibilityState === 'visible') this._wakeReconnect();
};
@@ -823,6 +829,21 @@ class MeshBayTransport {
}
}
+ /**
+ * Tries again now: the person has just changed what made it fail. An
+ * attempt under way set up its connection before that change (a permission
+ * granted), and Chrome keeps refusing the addresses it dropped then until
+ * ICE gives up, half a minute: it is given up here, and the next starts at
+ * once.
+ */
+ retryNow() {
+ if (this._inReconnectAttempt && this._abandonAttempt) {
+ this._retryAtOnce = true;
+ this._abandonAttempt();
+ }
+ this._wakeReconnect();
+ }
+
get connected() { return this._connected; }
/** True while _reconnectLoop is running, backoff included. */
get reconnecting() { return !!this._reconnectPromise; }
@@ -864,6 +885,17 @@ class MeshBayTransport {
}
/**
+ * Told of each automatic reconnect attempt that failed, with its error, while
+ * the reconnect goes on. A phone that leaves mobile data for the node's Wi-Fi
+ * without the local network permission is refused on every attempt
+ * (`reason` local_network_blocked), and only the page can say why.
+ */
+ addReconnectFailureListener(fn) {
+ this._reconnectFailureListeners.add(fn);
+ return () => this._reconnectFailureListeners.delete(fn);
+ }
+
+ /**
* Told whenever this connection's device identity changes — including to
* *nothing*, which is the case that mattered.
*
@@ -1007,6 +1039,8 @@ class MeshBayTransport {
// DataChannel closed" on the console. Every failed reconnect attempt
// printed one, which is noise in exactly the log a freeze gets read from.
channelReady.catch(() => { /* the awaiter below reports it */ });
+ // retryNow; a no-op once the channel is open.
+ this._abandonAttempt = () => channelReject(new Error('Tried again at once'));
this._channel.onmessage = (event) => this._onMessage(event.data);
const channel = this._channel;
@@ -1698,7 +1732,9 @@ class MeshBayTransport {
this._reconnectAttempts = 0;
while (!this._closed) {
this._reconnectAttempts += 1;
- const delayMs = Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1));
+ const delayMs = this._retryAtOnce ? 0
+ : Math.min(30000, 1000 * 2 ** (this._reconnectAttempts - 1));
+ this._retryAtOnce = false;
trace('reconnect_wait', { attempt: this._reconnectAttempts, delay_ms: delayMs });
// Interruptible: _wakeReconnect (fired on visibilitychange → visible)
// resolves this immediately instead of waiting out the rest of a
@@ -1752,6 +1788,11 @@ class MeshBayTransport {
});
console.warn('[MeshBay] Reconnect attempt', this._reconnectAttempts,
'failed:', e.message);
+ for (const fn of [...this._reconnectFailureListeners]) {
+ try { fn(e); } catch (err) {
+ console.error('[MeshBay] reconnect failure listener threw:', err);
+ }
+ }
// Loop again with a longer backoff — closing over `args`/`token`
// freshly next time, in case the token was the actual problem.
}