aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-18 22:48:40 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-18 22:48:40 +0200
commitc066739551449bc8fea6a6ccc0793228fe0f907e (patch)
tree2419ac396a420f1928cc8ad1361545653f5213e1 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js
parentaf55e6bc052187855420c1d549659879e35d4a41 (diff)
downloadmeshbay-c066739551449bc8fea6a6ccc0793228fe0f907e.tar.gz
fix(hub): Search waits on a connection that stalls, not on one that is slow
Opening a group from the sidebar has no deadline of its own and gets the transport's: 30 s for the DataChannel, 30 s for each request after it. Search wrapped the same `connect()` in a flat 10 s, and that 10 s had to cover the hub round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the channel opening and the handshake's own round trips. On a phone on 4G the budget was met by luck rather than margin, and the same group then failed in Search while it opened from the sidebar, against the same node. The budgets were inverted: the phase full of round trips had a third of what one request on an open channel got. Raising the number would have been the wrong repair. `fetchAllIndexes` fans out three at a time and waits for the slowest of each batch, so a page of unreachable groups costs batches x the deadline in spinner: a bigger number taxes every dead group for the sake of the live ones. So the deadline measures stalling. A node that is not there reports nothing and still fails in `SEARCH_STALL_MS`, unchanged at 10 s, which is what keeps the fan-out where it was. A node that answers ICE, then opens a channel, buys another window at each step, up to `SEARCH_MAX_MS` — a deadline that only ever resets has none, and a node that answers and then goes quiet would otherwise never be given up on. The transport reports those steps through `onConnectProgress`, set by the one caller that imposes a deadline of its own. `connected`/`completed` is the signal and not `checking`, because the first means a candidate pair answered and the second means this side is still trying addresses that may all be dead. A caller's callback cannot break the connection it is reporting on. The tests run the shipped `connectToGroup`, lifted out as text, against a fake clock — a real one would make each scenario a minute and blur the only thing worth asserting, which is when the deadline fires. Dead node: 10 s. Slow but moving: connects at 20 s where it used to fail at 10. Answers then stops: 18 s. Progress that never finishes: the 30 s ceiling. Two dead nodes: two windows, both transports closed. Checked against the flat deadline, which fails three of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js38
1 files changed, 33 insertions, 5 deletions
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 593760c..679b3f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -27,7 +27,22 @@ const BATCH_SIZE = 3;
// good. grenet (one shared group) never hit it; cbesson (many) always did.
const MAX_POOL_SIZE = 12;
const DEBOUNCE_MS = 200;
-const SEARCH_TIMEOUT = 10000;
+// How long a connection attempt may make **no progress**, not how long it may
+// take. Search used a flat 10 s for the whole of `connect()`, which is the hub
+// round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the
+// DataChannel opening and the handshake's own round trips. Opening the same
+// group from the sidebar has no such deadline and gets the transport's own 30 s,
+// so a link slow enough to need twelve seconds — a phone on 4G — failed here and
+// worked from there, for the same work against the same node.
+//
+// A node that is not answering produces no progress event and still fails in
+// `SEARCH_STALL_MS`, which is what keeps the fan-out bounded: `fetchAllIndexes`
+// goes in batches of three and waits for the slowest of each, so the number that
+// matters for a page full of unreachable groups is this one, unchanged.
+// `SEARCH_MAX_MS` bounds the other case — a node that answers ICE and then stops
+// — because a deadline that only ever resets has none.
+const SEARCH_STALL_MS = 10000;
+const SEARCH_MAX_MS = 30000;
const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
@@ -55,20 +70,33 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
for (const n of nodesData.nodes) {
const transport = new window.MeshBayTransport(hubBase, live);
transport.onNeedToken = async () => (await ensureFreshToken()) || token;
- let timer;
+ let stallTimer, capTimer;
+ const stopTimers = () => {
+ clearTimeout(stallTimer);
+ clearTimeout(capTimer);
+ transport.onConnectProgress = null;
+ };
try {
const ack = await Promise.race([
transport.connect(
n.node_id, live, groupId, null, null, bundleKey,
username, userId, null),
new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT);
+ const giveUp = () => reject(new Error('Connection timeout'));
+ stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
+ capTimer = setTimeout(giveUp, SEARCH_MAX_MS);
+ // Each step the transport reports — the peer answering ICE, the
+ // channel opening — buys another window, never more than the cap.
+ transport.onConnectProgress = () => {
+ clearTimeout(stallTimer);
+ stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
+ };
}),
]);
- clearTimeout(timer);
+ stopTimers();
return { transport, ack };
} catch (e) {
- clearTimeout(timer);
+ stopTimers();
lastErr = e;
try { transport.close(); } catch {}
if (e.reason && e.reason !== 'not_hosted') throw e;