summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 19:08:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 19:08:52 +0200
commit84946b1fef64ee619f1c161d57ef4afdc533eae2 (patch)
tree55d13c6536b5cc2bdb8754eb0d82b6339f01072b /packages
parent7e01b7e8c2ce2ef9fce8b6c053c3b7210b64f9bf (diff)
downloadmeshbay-84946b1fef64ee619f1c161d57ef4afdc533eae2.tar.gz
fix(spa): search hears a refused local network from the connection
On a Pixel, Chrome still lists the refused address when search gives up, so nothing tells it from a silent node. The connection given up on now runs to its own verdict and the refusal is reported then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js16
-rw-r--r--packages/meshbay-hub/tests/harness/local_network_harness.mjs9
-rw-r--r--packages/meshbay-hub/tests/harness/search_connect_harness.mjs30
-rw-r--r--packages/meshbay-hub/tests/test_local_network_refused.py14
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py32
-rw-r--r--packages/meshbay-hub/tests/test_search_fanout.py5
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