diff options
Diffstat (limited to 'packages')
8 files changed, 95 insertions, 74 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js index 8a616c2a..2961420b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js @@ -61,11 +61,15 @@ const SEARCH_MAX_MS = 30000; * * So does a node this device refuses to reach on the local network (Android * 17, Chrome 155: transport.js localNetworkRefused): another node of the group - * may be elsewhere. Search gives up long before connect() would name it, so it - * is read from the connection when giving up, and it is what is thrown when - * no node answered: the page says what to change rather than "unreachable". + * may be elsewhere. connect() names it only once ICE gives up, about thirty + * seconds, and until then Chrome lists the refused address like any other, + * its checks "sent": measured on a Pixel, nothing tells it from a node that + * does not answer when search gives up. So a connection given up on is left + * to reach its own verdict, out of the walk and its places, and + * `onLateRefusal` hears the refusal when it comes. */ -async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) { +async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId, + onLateRefusal = null) { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (!nodesData.nodes || !nodesData.nodes.length) return null; @@ -81,11 +85,12 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user clearTimeout(capTimer); transport.onConnectProgress = null; }; + const connecting = transport.connect( + n.node_id, live, groupId, null, null, bundleKey, + username, userId, null, undefined, undefined, n.pk_node); try { const ack = await Promise.race([ - transport.connect( - n.node_id, live, groupId, null, null, bundleKey, - username, userId, null, undefined, undefined, n.pk_node), + connecting, new Promise((_, reject) => { const giveUp = () => reject(new Error('Connection timeout')); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); @@ -103,13 +108,23 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user } catch (e) { stopTimers(); lastErr = e; - if (!e.reason && await transport.localNetworkRefused()) { - lastErr = new Error('This device refused to reach the local network'); - lastErr.reason = 'local_network_blocked'; + if (e.reason === 'local_network_blocked') lanErr = e; + if (e.reason) { + try { transport.close(); } catch {} + } else { + // Given up on by search, not by the connection: closed once it ends. + connecting.then(() => { + try { transport.close(); } catch {} + }, (err) => { + try { transport.close(); } catch {} + if (err && err.reason === 'local_network_blocked' && onLateRefusal) { + onLateRefusal(groupId); + } + }); + } + if (e.reason && e.reason !== 'not_hosted' && e.reason !== 'local_network_blocked') { + throw e; } - try { transport.close(); } catch {} - if (lastErr.reason === 'local_network_blocked') { lanErr = lastErr; continue; } - if (e.reason && e.reason !== 'not_hosted') throw e; } } throw (lanErr || lastErr || new Error('no node served this group')); @@ -128,7 +143,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user * phone ran into the hub's per-account ceilings with five groups. */ class ConnectionPool { - constructor(hubBase, onEvict) { + constructor(hubBase, onEvict, onLanRefused) { this._hubBase = hubBase; this._connections = new Map(); this._connecting = new Map(); @@ -136,6 +151,9 @@ class ConnectionPool { // (eviction, close or closeAll). SearchPage uses it to drop its own record // so it never hands a tile a `_tRef` pointing at a transport closed here. this._onEvict = onEvict || (() => {}); + // Called with a groupId when a node search gave up on turns out to be one + // this device refuses on the local network (connectToGroup). + this._onLanRefused = onLanRefused || (() => {}); // Negotiations under way, and those waiting for one of the places. this._active = 0; this._waiting = []; @@ -233,7 +251,8 @@ class ConnectionPool { async _doConnect(groupId, token, bundleKey, username, userId) { const found = await connectToGroup( - this._hubBase, groupId, token, bundleKey, username, userId); + this._hubBase, groupId, token, bundleKey, username, userId, + (id) => { if (!this._closed) this._onLanRefused(id); }); if (!found) throw new Error('offline'); const { transport, ack } = found; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index d0150a8e..587e90ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -269,6 +269,15 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); const [fetching, setFetching] = useState(false); const [lanBlocked, setLanBlocked] = useState([]); + // Added to as each refusal is known: some only once search has moved on. + const addLanBlocked = useCallback((more) => { + setLanBlocked((prev) => { + const fresh = more.filter((g) => !prev.some((p) => p.id === g.id)); + return fresh.length ? [...prev, ...fresh] : prev; + }); + }, []); + const groupsRef = useRef(groups); + groupsRef.current = groups; // Bumped by the Files breadcrumb refresh button — re-runs the all-groups // index fetch below, the only "cache" this page has. const [refreshTick, setRefreshTick] = useState(0); @@ -300,6 +309,9 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // Rebuild the entry lists so tiles of the evicted group fall back to a // null `_tRef` (and pick a live one up again once reconnected). if (mountedRef.current) setConnectionGen((g) => g + 1); + }, (refusedId) => { + const g = (groupsRef.current || []).find((x) => x.id === refusedId); + if (g && mountedRef.current) addLanBlocked([g]); }); return () => { mountedRef.current = false; @@ -333,7 +345,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) setFetching(false); // Left up, unlike the line below: there is something to do about it, // and the group page says what (group-page.js explainLanRefusal). - setLanBlocked(refused); + addLanBlocked(refused); // Said once the pass is over, for a few seconds, rather than left above // the results: by then nothing more can be done about it here, and a // line that stays is one more thing between the reader and the results. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 98a6ecfa..11088adc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -844,21 +844,6 @@ class MeshBayTransport { this._wakeReconnect(); } - /** - * Whether the connection being made is refused the local network, read from - * what is paired so far: the signature is there as soon as ICE checks - * begin, where connect() names it only once ICE gives up, half a minute. - * For a caller that stops waiting before that (connection-pool.js). - */ - async localNetworkRefused() { - const pc = this._pc; - if (!pc || !this._rawAnswerSdp || !pc.localDescription) return false; - if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') { - return false; - } - return _localNetworkRefusedBy(pc, pc.localDescription.sdp, this._rawAnswerSdp); - } - get connected() { return this._connected; } /** True while _reconnectLoop is running, backoff included. */ get reconnecting() { return !!this._reconnectPromise; } @@ -1030,7 +1015,6 @@ class MeshBayTransport { // the composer, exactly like one that had. this._setDevicePk('', 'new connection'); this._sessionUp = false; - this._rawAnswerSdp = null; this._pc = new RTCPeerConnection({ iceServers: await iceServers() }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); diff --git a/packages/meshbay-hub/tests/harness/local_network_harness.mjs b/packages/meshbay-hub/tests/harness/local_network_harness.mjs index 3c86cf77..9afdf59e 100644 --- a/packages/meshbay-hub/tests/harness/local_network_harness.mjs +++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs @@ -151,13 +151,6 @@ if (cfg.classify) { undefined, undefined, '', 'pk') .then(() => { outcome = 'connected'; }, (err) => { outcome = err.message; reason = err.reason || null; main.close(); }); - // What a caller that stops waiting early reads (connection-pool.js): at - // `probeAt` ms, while ICE is still checking, or after it connected. - let early = null; - if (cfg.probeAt) { - await run(cfg.probeAt); - early = await main.localNetworkRefused(); - } await run(60000); - process.stdout.write(JSON.stringify({ outcome, reason, statsCalls, early })); + process.stdout.write(JSON.stringify({ outcome, reason, statsCalls })); } diff --git a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs index 71c7e8c4..0c10a39d 100644 --- a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs +++ b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs @@ -28,9 +28,11 @@ const { nodes = 1, // Which of them answer (resolveAt applies to these). null: all. answering = null, - // Which of them this device refuses to reach on the local network: what the - // transport reads from its connection when search gives up on it. + // Which of them this device refuses to reach on the local network. As + // measured on a Pixel: no progress, and connect() names the refusal only + // when ICE gives up, `refusedAt` ms in. lanRefused = [], + refusedAt = 30000, // How far to run the clock before giving up on the harness itself. runUntil = 300000, } = cfg; @@ -70,6 +72,8 @@ async function run(until) { // ── The environment ────────────────────────────────────────────────────────── let closed = 0; +const closedAt = []; +const lateRefusals = []; let made = 0; class FakeTransport { @@ -79,8 +83,6 @@ class FakeTransport { this.index = made++; } - async localNetworkRefused() { return lanRefused.includes(this.index); } - connect() { const started = now; for (const at of progressAt) { @@ -92,14 +94,25 @@ class FakeTransport { } }, at); } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + if (lanRefused.includes(this.index)) { + setTimeout(() => { + const err = new Error('This device refused to reach the local network'); + err.reason = 'local_network_blocked'; + reject(err); + }, refusedAt); + return; + } if (resolveAt !== null && (answering === null || answering.includes(this.index))) { setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt); + } else { + // The transport's own deadline for the channel: every connect() ends. + setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); } }); } - close() { closed += 1; } + close() { closed += 1; closedAt.push(now); } } globalThis.window = { MeshBayTransport: FakeTransport }; @@ -134,7 +147,8 @@ const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window); // ── The scenario ───────────────────────────────────────────────────────────── let outcome = null; -connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1') +connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1', + (groupId) => { lateRefusals.push({ groupId, at: now }); }) .then(() => { outcome = { result: 'connected', at: now }; }) .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message), reason: e.reason || null }; @@ -145,4 +159,6 @@ await run(runUntil); console.log(JSON.stringify({ ...(outcome || { result: 'still-waiting', at: now }), closed, + closedAt, + lateRefusals, })); diff --git a/packages/meshbay-hub/tests/test_local_network_refused.py b/packages/meshbay-hub/tests/test_local_network_refused.py index 4f556474..3d0f3041 100644 --- a/packages/meshbay-hub/tests/test_local_network_refused.py +++ b/packages/meshbay-hub/tests/test_local_network_refused.py @@ -104,17 +104,3 @@ def test_stats_that_cannot_be_read_leave_the_old_error(): out = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED, "statsThrow": True}) assert out["reason"] is None and out["outcome"] == "DataChannel closed", out - - -def test_the_refusal_is_read_before_ice_gives_up(): - """Search stops waiting after ten seconds without progress, and ICE gives up - later: the signature is in the pairs as soon as checks begin.""" - refused = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED, - "probeAt": 5000}) - assert refused["early"] is True, refused - paired = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": EVERY_NODE_ADDRESS, - "probeAt": 5000}) - assert paired["early"] is False, paired - reached = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED, - "iceConnects": True, "probeAt": 5000}) - assert reached["early"] is False, reached diff --git a/packages/meshbay-hub/tests/test_search_connect_deadline.py b/packages/meshbay-hub/tests/test_search_connect_deadline.py index 0dfee4e0..50b52369 100644 --- a/packages/meshbay-hub/tests/test_search_connect_deadline.py +++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py @@ -125,19 +125,25 @@ def test_a_fast_connection_is_unaffected(): assert out["at"] == 3000 -def test_a_node_refused_the_local_network_is_named_when_search_gives_up(): - """Android 17 and Chrome 155 refuse the node's address on the phone's Wi-Fi - without the permission, and connect() names it only when ICE gives up, - long after search has: the refusal is read from the connection then.""" +def test_a_node_refused_the_local_network_is_named_when_its_connection_says_so(): + """Measured on a Pixel: when search gives up, ten seconds in, Chrome still + lists the refused address and counts its checks as sent, so nothing tells it + from a node that does not answer. The connection is left to reach its own + verdict, thirty seconds in, and the refusal is heard then.""" out = _attempt(lanRefused=[0]) - assert out["result"] == "failed" and out["reason"] == "local_network_blocked", out - assert out["at"] == STALL_MS, out + assert out["result"] == "failed" and out["at"] == STALL_MS, out + assert out["lateRefusals"] == [{"groupId": "g1", "at": 30000}], out + # Closed once its verdict is in, not when search moved on. + assert out["closedAt"] == [30000], out -def test_a_refused_node_does_not_stop_the_walk(): - """Refused here is one node: another of the group may be elsewhere.""" - out = _attempt(nodes=2, lanRefused=[0], answering=[1], progressAt=[], resolveAt=500) - assert out["result"] == "connected", out - # And when no node answers, the refusal is what is said, not a timeout. - out = _attempt(nodes=2, lanRefused=[0]) - assert out["reason"] == "local_network_blocked", out +def test_a_refused_node_does_not_hold_up_the_walk(): + """Refused here is one node: another of the group may be elsewhere, and is + tried as soon as search gives up on the first.""" + out = _attempt(nodes=2, lanRefused=[0], answering=[1], resolveAt=500) + assert out["result"] == "connected" and out["at"] == STALL_MS + 500, out + + +def test_a_node_that_does_not_answer_is_closed_without_a_word(): + out = _attempt(resolveAt=None) + assert out["lateRefusals"] == [], out diff --git a/packages/meshbay-hub/tests/test_search_fanout.py b/packages/meshbay-hub/tests/test_search_fanout.py index 73ba4ac0..303045cd 100644 --- a/packages/meshbay-hub/tests/test_search_fanout.py +++ b/packages/meshbay-hub/tests/test_search_fanout.py @@ -189,3 +189,8 @@ def test_the_search_page_shows_the_refused_groups_with_a_way_in(): start = source.index("lanBlocked.length > 0") hint = source[start:start + 400] assert "t('search.lan_blocked'" in hint and "groupLinkRoute(g)" in hint + # The refusals heard after the pass, from the pool, join the same list. + pool = source[source.index("new ConnectionPool(HUB,"):] + pool = pool[:pool.index("return () =>")] + assert "addLanBlocked([g])" in pool + assert "addLanBlocked(refused)" in source and "setLanBlocked(refused)" not in source |