From 8125df6d8149368ed8a84c9db6c5e37bfccb5a15 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 17 Oct 2026 18:37:35 +0200 Subject: 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 --- .../tests/harness/cast_session_probe.py | 1 + .../meshbay-hub/tests/harness/group_tab_probe.py | 1 + .../meshbay-hub/tests/harness/music_grid_probe.py | 1 + .../meshbay-hub/tests/harness/music_queue_probe.py | 1 + .../tests/harness/music_untagged_probe.py | 1 + .../tests/harness/network_change_harness.mjs | 70 ++++++++++++++++++++-- .../meshbay-hub/tests/harness/playlist_ui_probe.py | 1 + .../tests/harness/sticky_header_probe.py | 1 + .../tests/harness/video_series_probe.py | 1 + packages/meshbay-hub/tests/test_network_change.py | 33 ++++++++++ 10 files changed, 107 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/tests') 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 // {"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 -- cgit v1.2.3