aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js24
-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.mjs15
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs17
-rw-r--r--packages/meshbay-hub/tests/test_local_network_refused.py14
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py18
-rw-r--r--packages/meshbay-hub/tests/test_search_fanout.py17
-rw-r--r--packages/meshbay-hub/tests/test_search_unreachable_note.py2
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
22 files changed, 150 insertions, 15 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 1480cd57..01555804 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -4187,7 +4187,11 @@ narrow bridge — and Android has all three:
reconnect to try now rather than tear it down, so a download or a film
under way resumes once the permission is granted or mobile data is back;
an attempt still waiting on ICE is given up for a new one, since Chrome
- keeps refusing what it dropped when that attempt began.
+ keeps refusing what it dropped when that attempt began. Search gives up on
+ a node long before ICE does, so it reads the refusal from the connection
+ when it gives up (`localNetworkRefused()`), tries the group's other nodes,
+ and names the groups refused, each a link to its page, apart from those
+ that did not answer.
- **The page hears when the phone changes network** (`meshbay-default-network`,
from the default network callback), mobile data to Wi-Fi or back, and checks
the connection it holds (transport.js `onNetworkChange`). The event carries
@@ -4827,7 +4831,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **A failed download leaves its in-flight chunks rejecting unhandled** | When `pipelinedDownload` throws, the other promises of its window are abandoned and each prints "Uncaught (in promise)" in the console. Noise, but in the log a dropped connection is read from |
| **A queued transfer logs "no answer" every minute** | The lease watchdog re-asks after 60 s of silence whatever the last answer was, so a lease the node has answered `queued` and that has not moved prints "no answer for transfer ... asking again" once a minute for as long as it waits, which reads as a fault |
| **"Prune stale entries" does not touch the index** | The Node page's maintenance button runs `prune_index_cache`, which drops rows of the hash cache and nothing else. An operator who sees a deleted file still listed reaches for it, and nothing changes: only the reconcile removes an index entry, and nothing lets the operator ask for one |
-| **Only the group page explains a refused local network** | `transport.js` names it (`local_network_blocked`, Android 17 and Chrome 155), and `group-page.js` says what to change. Search and the other screens that open a connection show it as a node that cannot be reached |
---
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 15d352a7..8a616c2a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -58,6 +58,12 @@ const SEARCH_MAX_MS = 30000;
* opened fine from the sidebar. A refusal naming a state of this browser (a
* code, a passphrase, a device) is the same from every node and stops the
* walk; `not_hosted`, a timeout or a failed connection moves on.
+ *
+ * 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".
*/
async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
@@ -65,6 +71,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
const live = (await ensureFreshToken()) || token;
let lastErr = null;
+ let lanErr = null;
for (const n of nodesData.nodes) {
const transport = new window.MeshBayTransport(hubBase, live);
transport.onNeedToken = async () => (await ensureFreshToken()) || token;
@@ -96,11 +103,16 @@ 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';
+ }
try { transport.close(); } catch {}
+ if (lastErr.reason === 'local_network_blocked') { lanErr = lastErr; continue; }
if (e.reason && e.reason !== 'not_hosted') throw e;
}
}
- throw (lastErr || new Error('no node served this group'));
+ throw (lanErr || lastErr || new Error('no node served this group'));
}
// -- Connection pool ----------------------------------------------------------
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index ae2dcfbf..228402c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -859,6 +859,7 @@ export default {
'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.',
'search.indexing': 'Indexierung von {done} / {total} Gruppen …',
'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' },
+ 'search.lan_blocked': { one: "Dieses Gerät verweigert den Zugriff auf das lokale Netzwerk, in dem der Node dieser Gruppe ist. Öffnen Sie sie, um zu sehen, wie Sie ihn erlauben:", other: "Dieses Gerät verweigert den Zugriff auf das lokale Netzwerk, in dem die Nodes dieser {n} Gruppen sind. Öffnen Sie eine, um zu sehen, wie Sie ihn erlauben:" },
'search.n_sources': { one: '{n} Quelle', other: '{n} Quellen' },
'search.view_files': 'Dateien',
'search.view_videos': 'Videos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e5ed9cb3..40c20244 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -976,6 +976,7 @@ export default {
'search.hint': 'Type to search file names across all your groups.',
'search.indexing': 'Indexing {done} / {total} groups...',
'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' },
+ 'search.lan_blocked': { one: "This device refuses to reach the local network, where this group's node is. Open it to see how to allow it:", other: "This device refuses to reach the local network, where the nodes of these {n} groups are. Open one to see how to allow it:" },
'search.n_sources': { one: '{n} source', other: '{n} sources' },
'search.view_files': 'Files',
'search.view_videos': 'Videos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 9801f741..643fdeab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -854,6 +854,7 @@ export default {
'search.hint': 'Escriba un término para buscar en todos sus grupos.',
'search.indexing': 'Indexando {done} / {total} grupos…',
'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' },
+ 'search.lan_blocked': { one: "Este dispositivo se niega a acceder a la red local, donde está el node de este grupo. Ábralo para ver cómo permitirlo:", other: "Este dispositivo se niega a acceder a la red local, donde están los nodes de estos {n} grupos. Abra uno para ver cómo permitirlo:" },
'search.n_sources': { one: '{n} fuente', other: '{n} fuentes' },
'search.view_files': 'Archivos',
'search.view_videos': 'Vídeos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index b0dfd37b..8df543e4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -857,6 +857,7 @@ export default {
'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.',
'search.indexing': 'Indexation de {done} / {total} groupes…',
'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' },
+ 'search.lan_blocked': { one: "Cet appareil refuse de joindre le réseau local, où se trouve le node de ce groupe. Ouvrez-le pour savoir comment l’autoriser :", other: "Cet appareil refuse de joindre le réseau local, où se trouvent les nodes de ces {n} groupes. Ouvrez-en un pour savoir comment l’autoriser :" },
'search.n_sources': { one: '{n} source', other: '{n} sources' },
'search.view_files': 'Fichiers',
'search.view_videos': 'Vidéos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 22e09deb..8912af69 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -856,6 +856,7 @@ export default {
'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.',
'search.indexing': 'Indicizzazione di {done} / {total} gruppi…',
'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' },
+ 'search.lan_blocked': { one: "Questo dispositivo rifiuta di raggiungere la rete locale, dove si trova il node di questo gruppo. Aprilo per vedere come consentirlo:", other: "Questo dispositivo rifiuta di raggiungere la rete locale, dove si trovano i node di questi {n} gruppi. Aprine uno per vedere come consentirlo:" },
'search.n_sources': { one: '{n} fonte', other: '{n} fonti' },
'search.view_files': 'File',
'search.view_videos': 'Video',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index a4645ac3..01a84dc9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -844,6 +844,7 @@ export default {
'search.hint': '入力してすべてのグループを検索します。',
'search.indexing': '{done} / {total} グループをインデックス中…',
'search.unreachable': { other: '{n} グループに接続できません' },
+ 'search.lan_blocked': { other: "このデバイスはローカルネットワークへの接続を拒否しています。このネットワークに {n} 件のグループの node があります。グループを開くと、許可する方法が表示されます:" },
'search.n_sources': { other: '{n} 個のソース' },
'search.view_files': 'ファイル',
'search.view_videos': '動画',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 0f31f6cb..dec9e9f5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -858,6 +858,7 @@ export default {
'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.',
'search.indexing': '{done} / {total} groepen indexeren…',
'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' },
+ 'search.lan_blocked': { one: "Dit apparaat weigert het lokale netwerk te bereiken, waar de node van deze groep staat. Open hem om te zien hoe je het toestaat:", other: "Dit apparaat weigert het lokale netwerk te bereiken, waar de nodes van deze {n} groepen staan. Open er een om te zien hoe je het toestaat:" },
'search.n_sources': { one: '{n} bron', other: '{n} bronnen' },
'search.view_files': 'Bestanden',
'search.view_videos': 'Video\'s',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index c89a60d3..0de71416 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -878,6 +878,7 @@ export default {
'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.',
'search.indexing': 'Indeksowanie {done} / {total} grup…',
'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' },
+ 'search.lan_blocked': { one: "To urządzenie odmawia połączenia z siecią lokalną, w której jest node tej grupy. Otwórz ją, aby zobaczyć, jak na to zezwolić:", few: "To urządzenie odmawia połączenia z siecią lokalną, w której są node’y tych {n} grup. Otwórz jedną, aby zobaczyć, jak na to zezwolić:", many: "To urządzenie odmawia połączenia z siecią lokalną, w której są node’y tych {n} grup. Otwórz jedną, aby zobaczyć, jak na to zezwolić:", other: "To urządzenie odmawia połączenia z siecią lokalną, w której są node’y tych {n} grup. Otwórz jedną, aby zobaczyć, jak na to zezwolić:" },
'search.n_sources': { one: '{n} źródło', few: '{n} źródła', many: '{n} źródeł', other: '{n} źródła' },
'search.view_files': 'Pliki',
'search.view_videos': 'Filmy',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index aa33e177..89e854c1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -855,6 +855,7 @@ export default {
'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.',
'search.indexing': 'Indexando {done} / {total} grupos…',
'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' },
+ 'search.lan_blocked': { one: "Este dispositivo se recusa a acessar a rede local, onde está o node deste grupo. Abra-o para ver como permitir:", other: "Este dispositivo se recusa a acessar a rede local, onde estão os nodes destes {n} grupos. Abra um para ver como permitir:" },
'search.n_sources': { one: '{n} fonte', other: '{n} fontes' },
'search.view_files': 'Arquivos',
'search.view_videos': 'Vídeos',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 27f20dc7..08bf5593 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -832,6 +832,7 @@ export default {
'search.hint': '输入关键词搜索所有群组。',
'search.indexing': '正在索引 {done} / {total} 个群组…',
'search.unreachable': { other: '{n} 个群组无法连接' },
+ 'search.lan_blocked': { other: "此设备拒绝访问本地网络,而 {n} 个群组的 node 就在该网络中。打开群组即可查看如何允许:" },
'search.n_sources': { other: '{n} 个来源' },
'search.view_files': '文件',
'search.view_videos': '视频',
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 ae8eac75..d0150a8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -15,6 +15,7 @@ import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
import { copyLink, entryPath, groupItemLink } from './copy-link.js';
import { say } from './note.js';
+import { groupLinkRoute } from './group-link.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';
import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js';
@@ -139,6 +140,9 @@ async function fetchAllIndexes(pool, groups, token, username, userId, onProgress
const total = groups.length;
let done = 0;
const unreachable = [];
+ // Groups whose node this device refused to reach on the local network: a
+ // setting to change, said apart from the groups that did not answer.
+ const lanBlocked = [];
const downNow = new Set();
const results = new Map();
@@ -168,7 +172,8 @@ async function fetchAllIndexes(pool, groups, token, username, userId, onProgress
downNow.add(g.id);
}
} catch (e) {
- unreachable.push(g.name || g.id);
+ if (e && e.reason === 'local_network_blocked') lanBlocked.push(g);
+ else unreachable.push(g.name || g.id);
// Still refused after transport.js's retries: the hub was busy, which
// says nothing about this group's node, so it keeps its place next time.
if (!(e && e.status === 429)) downNow.add(g.id);
@@ -179,7 +184,7 @@ async function fetchAllIndexes(pool, groups, token, username, userId, onProgress
// A sweep cut short by the page closing saw nothing about any node: every
// group left was refused by this page, not by its node.
if (!(pool && pool.closed)) rememberDown(downNow);
- return { results, unreachable };
+ return { results, unreachable, lanBlocked };
}
// -- SearchPage ---------------------------------------------------------------
@@ -263,6 +268,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const [indexedGroups, setIndexedGroups] = useState(new Map());
const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] });
const [fetching, setFetching] = useState(false);
+ const [lanBlocked, setLanBlocked] = useState([]);
// 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);
@@ -314,9 +320,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
(async () => {
setFetching(true);
setProgress({ done: 0, total: groups.length, unreachable: [] });
+ setLanBlocked([]);
if (!poolRef.current) return;
- const { unreachable } = await fetchAllIndexes(
+ const { unreachable, lanBlocked: refused } = await fetchAllIndexes(
poolRef.current, groups, token, username, userId,
(p) => { if (!cancelled) setProgress(p); },
(results) => { if (!cancelled) setIndexedGroups(new Map(results)); },
@@ -324,6 +331,9 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
if (cancelled) return;
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);
// 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.
@@ -714,6 +724,14 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
`}
</div>
+ ${lanBlocked.length > 0 && html`
+ <p class="settings-hint">
+ ${t('search.lan_blocked', { n: lanBlocked.length })}${' '}
+ ${lanBlocked.map((g, i) => html`${i ? ', ' : ''}<a
+ href=${'#' + groupLinkRoute(g)}>${g.name || g.id}</a>`)}
+ </p>
+ `}
+
${fetching && html`
<div class="search-progress">
<span class="spinner"></span>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 11088adc..98a6ecfa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -844,6 +844,21 @@ 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; }
@@ -1015,6 +1030,7 @@ 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 9afdf59e..3c86cf77 100644
--- a/packages/meshbay-hub/tests/harness/local_network_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs
@@ -151,6 +151,13 @@ 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 }));
+ process.stdout.write(JSON.stringify({ outcome, reason, statsCalls, early }));
}
diff --git a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
index 60cea2ba..71c7e8c4 100644
--- a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
@@ -26,6 +26,11 @@ const {
resolveAt = null,
// How many nodes the hub lists for the group.
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.
+ lanRefused = [],
// How far to run the clock before giving up on the harness itself.
runUntil = 300000,
} = cfg;
@@ -65,13 +70,17 @@ async function run(until) {
// ── The environment ──────────────────────────────────────────────────────────
let closed = 0;
+let made = 0;
class FakeTransport {
constructor() {
this.onConnectProgress = null;
this.onNeedToken = null;
+ this.index = made++;
}
+ async localNetworkRefused() { return lanRefused.includes(this.index); }
+
connect() {
const started = now;
for (const at of progressAt) {
@@ -84,7 +93,7 @@ class FakeTransport {
}, at);
}
return new Promise((resolve) => {
- if (resolveAt !== null) {
+ if (resolveAt !== null && (answering === null || answering.includes(this.index))) {
setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt);
}
});
@@ -127,7 +136,9 @@ const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window);
let outcome = null;
connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1')
.then(() => { outcome = { result: 'connected', at: now }; })
- .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message) }; });
+ .catch((e) => {
+ outcome = { result: 'failed', at: now, error: String(e.message), reason: e.reason || null };
+ });
await run(runUntil);
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
index f84d90bc..27ad8757 100644
--- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
@@ -23,6 +23,8 @@ const {
// Indexes (0-based, in the hub's listing order) of the groups whose node is
// down. They take `deadMs`; every other group answers in `liveMs`.
dead = [],
+ // Of those, the ones whose node this device refused on the local network.
+ lanRefused = [],
liveMs = 200,
deadMs = 10000,
// Group indexes the browser remembers as silent from a previous sweep.
@@ -87,8 +89,12 @@ const fetchGroupIndex = (_pool, groupId) => new Promise((resolve, reject) => {
const n = Number(String(groupId).split('-')[1]);
const isDead = dead.includes(n);
setTimeout(
- () => (isDead ? reject(new Error('Connection timeout'))
- : resolve({ entries: [{ id: `e${n}` }], roots: {} })),
+ () => {
+ if (!isDead) return resolve({ entries: [{ id: `e${n}` }], roots: {} });
+ const err = new Error('Connection timeout');
+ if (lanRefused.includes(n)) err.reason = 'local_network_blocked';
+ return reject(err);
+ },
isDead ? deadMs : liveMs,
);
});
@@ -125,11 +131,15 @@ const fetchAllIndexes = make(
const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` }));
let finishedAt = null;
+let outcome = null;
fetchAllIndexes(
null, list, 'tok', 'alice', 'u1',
() => {}, // progress counters only
(results) => { shown.push({ at: now, groups: results.size }); },
-).then(() => { finishedAt = now; });
+).then((out) => {
+ finishedAt = now;
+ outcome = { unreachable: out.unreachable, lanBlocked: out.lanBlocked.map((g) => g.name) };
+});
await run(runUntil);
@@ -140,6 +150,7 @@ console.log(JSON.stringify({
// Every render, so a test can see whether live groups waited on dead ones.
renders: shown,
finishedAt,
+ outcome,
inFlight: Number(ceiling[1]),
// What the browser will remember for next time.
remembered: written === null ? null : JSON.parse(written),
diff --git a/packages/meshbay-hub/tests/test_local_network_refused.py b/packages/meshbay-hub/tests/test_local_network_refused.py
index 3d0f3041..4f556474 100644
--- a/packages/meshbay-hub/tests/test_local_network_refused.py
+++ b/packages/meshbay-hub/tests/test_local_network_refused.py
@@ -104,3 +104,17 @@ 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 953f0277..0dfee4e0 100644
--- a/packages/meshbay-hub/tests/test_search_connect_deadline.py
+++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py
@@ -123,3 +123,21 @@ def test_a_fast_connection_is_unaffected():
out = _attempt(progressAt=[], resolveAt=3000)
assert out["result"] == "connected"
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."""
+ out = _attempt(lanRefused=[0])
+ assert out["result"] == "failed" and out["reason"] == "local_network_blocked", out
+ assert out["at"] == STALL_MS, 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
diff --git a/packages/meshbay-hub/tests/test_search_fanout.py b/packages/meshbay-hub/tests/test_search_fanout.py
index 23a8b67d..73ba4ac0 100644
--- a/packages/meshbay-hub/tests/test_search_fanout.py
+++ b/packages/meshbay-hub/tests/test_search_fanout.py
@@ -50,7 +50,7 @@ import subprocess
from pathlib import Path
import pytest
-from spa_source import search_argv
+from spa_source import search_argv, search_source
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
SEARCH_PAGE = STATIC / "search-page.js"
@@ -174,3 +174,18 @@ def test_no_more_groups_are_dialled_at_once_than_the_ceiling():
out = _sweep(groups=12, dead=list(range(12)))
assert out["inFlight"] == 6
assert out["finishedAt"] == 2 * DEAD_MS
+
+
+def test_a_group_refused_the_local_network_is_named_apart():
+ """Not "unreachable": the page names these groups and says what to change,
+ and they are dialled last next time like any group that did not answer."""
+ out = _sweep(groups=4, dead=[1, 2], lanRefused=[2])
+ assert out["outcome"] == {"unreachable": ["G1"], "lanBlocked": ["G2"]}, out
+ assert sorted(out["remembered"]) == ["g-1", "g-2"], out
+
+
+def test_the_search_page_shows_the_refused_groups_with_a_way_in():
+ source = search_source()
+ start = source.index("lanBlocked.length > 0")
+ hint = source[start:start + 400]
+ assert "t('search.lan_blocked'" in hint and "groupLinkRoute(g)" in hint
diff --git a/packages/meshbay-hub/tests/test_search_unreachable_note.py b/packages/meshbay-hub/tests/test_search_unreachable_note.py
index 41ed25c0..561100f5 100644
--- a/packages/meshbay-hub/tests/test_search_unreachable_note.py
+++ b/packages/meshbay-hub/tests/test_search_unreachable_note.py
@@ -16,7 +16,7 @@ STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
def test_said_once_the_pass_is_over_and_not_rendered():
source = (STATIC / "search-page.js").read_text(encoding="utf-8")
assert "import { say } from './note.js';" in source
- after = source[source.index("const { unreachable } = await fetchAllIndexes("):]
+ after = source[source.index("const { unreachable, lanBlocked: refused } = await fetchAllIndexes("):]
after = after[:after.index("})();")]
assert re.search(r"if \(cancelled\) return;\s*setFetching\(false\);", after)
assert "say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS)" in after
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 0f0edb82..eaa35fbc 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -529,7 +529,7 @@ def test_search_tries_every_node_the_hub_offers():
code = _code_only(search_source())
assert "nodes[0]" not in code, "Search takes the head of the node list again"
walk = code[code.index("for (const n of nodesData.nodes)"):]
- walk = walk[:walk.index("throw (lastErr")]
+ walk = walk[:walk.index("throw (lanErr || lastErr")]
assert "not_hosted" in walk and "throw e" in walk, (
"the walk must stop for a refusal about this browser and move on "
"for one about this node")