aboutsummaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-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
-rw-r--r--packages/meshbay-hub/tests/harness/cast_session_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/group_tab_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/music_grid_probe.py1
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/music_untagged_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/network_change_harness.mjs70
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py1
-rwxr-xr-xpackages/meshbay-hub/tests/harness/sticky_header_probe.py1
-rw-r--r--packages/meshbay-hub/tests/harness/video_series_probe.py1
-rw-r--r--packages/meshbay-hub/tests/test_network_change.py33
12 files changed, 201 insertions, 21 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.
}
diff --git a/packages/meshbay-hub/tests/harness/cast_session_probe.py b/packages/meshbay-hub/tests/harness/cast_session_probe.py
index 573dd950..901d27cd 100644
--- a/packages/meshbay-hub/tests/harness/cast_session_probe.py
+++ b/packages/meshbay-hub/tests/harness/cast_session_probe.py
@@ -99,6 +99,7 @@ window.MeshBayTransport = function () {
// One chunk per photo: every photo is the same picture.
async fetchChunk() { return { nonce: 'n', ct: await photoBytes() }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py
index e23a9462..a430c625 100644
--- a/packages/meshbay-hub/tests/harness/group_tab_probe.py
+++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py
@@ -73,6 +73,7 @@ window.MeshBayTransport = class {
// unmount; a stub without this throws inside connect() and the page
// renders its error state instead of a tab bar.
addReconnectListener() { return () => {}; }
+ addReconnectFailureListener() { return () => {}; }
// What the node should hold of our identity, settled after connecting.
async settleNodeBundle() {}
close() {}
diff --git a/packages/meshbay-hub/tests/harness/music_grid_probe.py b/packages/meshbay-hub/tests/harness/music_grid_probe.py
index 4f5b0ce7..1995b2a1 100644
--- a/packages/meshbay-hub/tests/harness/music_grid_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_grid_probe.py
@@ -81,6 +81,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py
index 9be440b3..83c2972e 100755
--- a/packages/meshbay-hub/tests/harness/music_queue_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py
@@ -80,6 +80,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/music_untagged_probe.py b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
index f4c484c3..7af1efc7 100644
--- a/packages/meshbay-hub/tests/harness/music_untagged_probe.py
+++ b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
@@ -110,6 +110,7 @@ window.MeshBayTransport = function () {
async fetchChatHistory() { return { messages: [], hasMore: false }; },
async fetchLinkPreview() { return { ok: false }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/network_change_harness.mjs b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
index e0201125..a8bd0809 100644
--- a/packages/meshbay-hub/tests/harness/network_change_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
@@ -12,7 +12,8 @@
//
// Usage: node network_change_harness.mjs <transport sources> <json>
// {"scenario": "change-dead" | "change-alive" | "backoff" | "ice-dead" | "ice-alive"
-// | "events" | "closed"}
+// | "events" | "closed" | "refused"
+// | "refused-during"}
import { readFileSync } from 'fs';
import { delimiter } from 'path';
@@ -98,12 +99,21 @@ window.MeshBayCrypto = {
};
const SDP = 'v=0\r\na=fingerprint:sha-256 AA:BB\r\n';
+const candidate = (address, kind) => `a=candidate:1 1 udp 2130706431 ${address} 50000 typ ${kind}\r\n`;
+// On the node's Wi-Fi without the permission (test_local_network_refused.py):
+// the phone's offer and the node's answer as measured, and the node's address
+// on the phone's subnet missing from what the browser paired.
+const WIFI_OFFER = SDP + candidate('192.168.1.13', 'host') + candidate('90.112.206.172', 'srflx');
+const NODE_ANSWER = SDP + candidate('192.168.1.22', 'host') + candidate('90.112.206.172', 'srflx');
+const KEPT_WHEN_REFUSED = ['90.112.206.172'];
let offline = false;
+let lanRefused = false;
globalThis.fetch = async (url) => {
if (offline) throw new TypeError('Failed to fetch');
return {
ok: true, status: 200, headers: { get: () => null },
- json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: SDP }),
+ json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' }
+ : { sdp: lanRefused ? NODE_ANSWER : SDP }),
};
};
@@ -167,10 +177,30 @@ class FakePC {
pcs.push(this);
}
createDataChannel() { this.channel = new FakeChannel(); return this.channel; }
- async createOffer() { return { type: 'offer', sdp: SDP }; }
+ async createOffer() { return { type: 'offer', sdp: lanRefused ? WIFI_OFFER : SDP }; }
async setLocalDescription(d) { this.localDescription = d; }
- async getStats() { return new Map(); }
+ async getStats() {
+ const reports = new Map();
+ if (this.refused) {
+ KEPT_WHEN_REFUSED.forEach((address, i) => {
+ reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address });
+ });
+ }
+ return reports;
+ }
async setRemoteDescription() {
+ if (lanRefused) {
+ // ICE gives up, and the channel goes with it, as Chrome reports it:
+ // there after thirty seconds, here after fifteen.
+ this.refused = true;
+ setTimeout(() => {
+ if (this.connectionState === 'closed') return;
+ this.iceConnectionState = 'failed';
+ this.connectionState = 'failed';
+ this.channel.close();
+ }, 15000);
+ return;
+ }
setTimeout(() => {
if (this.connectionState === 'closed') return;
this.channel.session = new NodeSession(this.channel);
@@ -218,6 +248,8 @@ if (scenario === 'events') {
const main = new Transport('https://hub.invalid', 'token');
let reconnectedAt = null;
main.addReconnectListener(() => { if (reconnectedAt === null) reconnectedAt = now; });
+ const failures = [];
+ main.addReconnectFailureListener((err) => { failures.push(err.reason || null); });
let connected = false;
const identity = { pkEdB64: 'ed', signAs: async () => 'sig', native: false };
main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid',
@@ -252,6 +284,36 @@ if (scenario === 'events') {
connection.dispatch('change');
out.backAt = now;
await run(80000);
+ } else if (scenario === 'refused-during') {
+ // The same, with the permission granted while an attempt set up before it
+ // is still waiting on ICE: that attempt is given up, the next goes at once.
+ lanRefused = true;
+ first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(40000);
+ const attempts = pcs.length;
+ while (pcs.length === attempts) await run(now + 100);
+ await run(now + 2000);
+ lanRefused = false;
+ out.backAt = now;
+ main.retryNow();
+ await run(now + 40000);
+ } else if (scenario === 'refused') {
+ // Connected on mobile data, then on the node's Wi-Fi without the local
+ // network permission: every attempt is refused, and says so. The
+ // permission is granted after a while, and the page asks to try now.
+ lanRefused = true;
+ first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(60000);
+ out.failures = [...failures];
+ out.stillReconnecting = main.reconnecting;
+ lanRefused = false;
+ out.backAt = now;
+ main.retryNow();
+ await run(80000);
} else if (scenario === 'closed') {
main.close();
connection.type = 'wifi';
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
index 6e3be1e2..0d6e4134 100644
--- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -76,6 +76,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
index 3e75494b..89fb2827 100755
--- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py
+++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
@@ -240,6 +240,7 @@ window.MeshBayTransport = function () {
// Real, not left to the Proxy below: that would hand back a promise
// where an unsubscribe belongs, and the page calls it on unmount.
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
return new Proxy(self, {
diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py
index f161c305..2b9ade30 100644
--- a/packages/meshbay-hub/tests/harness/video_series_probe.py
+++ b/packages/meshbay-hub/tests/harness/video_series_probe.py
@@ -75,6 +75,7 @@ window.MeshBayTransport = function () {
// what TMDB would have said.
async fetchMediaMeta() { return { confidence: 0 }; },
addReconnectListener() { return () => {}; },
+ addReconnectFailureListener() { return () => {}; },
close() {},
};
// Everything else the player asks for never answers: it sits on its
diff --git a/packages/meshbay-hub/tests/test_network_change.py b/packages/meshbay-hub/tests/test_network_change.py
index 2298e8e6..0d270ba7 100644
--- a/packages/meshbay-hub/tests/test_network_change.py
+++ b/packages/meshbay-hub/tests/test_network_change.py
@@ -88,3 +88,36 @@ def test_a_group_left_on_an_error_tries_again_when_the_network_changes():
block = re.search(r"onNetworkChange\(.*?\)", source, re.S)
assert block, "the group page does not listen for another network"
assert "setRetryKey" in source[block.start():block.start() + 200]
+
+
+def test_a_reconnect_refused_the_local_network_says_so_and_goes_on():
+ """Connected on mobile data, then on the node's Wi-Fi without the permission:
+ each attempt used to fail in silence, for ever. Each is now told to the page
+ with its reason, the reconnect goes on, and once the permission is granted
+ the page's "try now" reaches the group without waiting out the backoff."""
+ out = _run("refused")
+ assert out["failures"] and set(out["failures"]) == {"local_network_blocked"}, out
+ assert out["stillReconnecting"], out
+ assert out["reconnectedAt"] - out["backAt"] < 3000, out
+
+
+def test_try_now_gives_up_an_attempt_set_up_before_the_permission():
+ """Measured on the Pixel: granted while an attempt was waiting on ICE, the
+ group came back 35 s later, once that attempt and the next backoff ran out."""
+ out = _run("refused-during")
+ assert out["reconnectedAt"] - out["backAt"] < 2000, out
+
+
+def test_the_group_page_explains_a_refused_reconnect_and_clears_it():
+ source = (STATIC / "group-page.js").read_text(encoding="utf-8")
+ start = source.index("addReconnectFailureListener(")
+ refused = source[start:start + 400]
+ assert "local_network_blocked" in refused and "explainLanRefusal()" in refused
+ start = source.index("addReconnectListener(", start)
+ back = source[start:start + 300]
+ assert "lanLostRef.current" in back and "setError('')" in back
+ # The buttons ask the reconnect under way rather than tear it down.
+ assert "transport.retryNow()" in source
+ for button in ("group.retry", "group.lan_allow", "group.lan_open_settings"):
+ end = source.index(f"t('{button}')")
+ assert "retry()" in source[end - 700:end] or "onClick=${retry}" in source[end - 100:end], button