summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
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.js226
1 files changed, 175 insertions, 51 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 0a96237..61565e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -16,10 +16,18 @@ import { transfers } from './transfers.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';
-// How many groups Search dials at once — a ceiling on concurrency, never a
-// batch. See `inFlight` below for why the difference is the whole of what a
-// reader waits for when a node is down.
-const MAX_IN_FLIGHT = 3;
+// How many connections this page negotiates at once — a ceiling on concurrency,
+// never a batch (see `inFlight` below), and one ceiling for everything here: the
+// sweep, the warm-up and a tile asking for its group all go through
+// `ConnectionPool.connect`, which holds it. Three separate ceilings used to add
+// up behind each other's backs, past what the hub admits per account.
+//
+// Six, sized for twenty groups on a phone: a connection there takes about two
+// seconds, so three at a time is fourteen seconds to reach them all and six is
+// seven, and a node that is down holds one place in six rather than one in
+// three. The hub admits 32 pending offers per account (signaling.py), which
+// leaves room for this page on three devices at once plus their reconnections.
+const MAX_IN_FLIGHT = 6;
// One WebRTC peer connection per group the search view touches. The cap bounds
// how many a busy account (many groups on the hub) keeps open at once; groups
// past it connect lazily when a tile of theirs scrolls into view (video-app.js's
@@ -39,9 +47,10 @@ const DEBOUNCE_MS = 200;
// 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_STALL_MS`, which is what keeps the fan-out bounded: a dead group holds
+// one of the `MAX_IN_FLIGHT` places for this long, so the number that matters
+// for a page full of unreachable groups is this one. A hub that refuses an offer
+// for load *is* answering, and transport.js reports each retry as progress.
// `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;
@@ -110,92 +119,189 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
// -- Connection pool ----------------------------------------------------------
+/**
+ * Every connection this page makes, and the only way it makes one.
+ *
+ * The sweep, the warm-up and a tile asking for its group all come here, so the
+ * page has one ceiling on how many it negotiates at once (`MAX_IN_FLIGHT`) and
+ * one connection per group. The sweep used to open its own, fetch the index,
+ * close it, and leave the warm-up to open the same group again straight after:
+ * two offers per group per visit, and on 4G the two overlapped, which is how a
+ * phone ran into the hub's per-account ceilings with five groups.
+ */
class ConnectionPool {
constructor(hubBase, onEvict) {
this._hubBase = hubBase;
this._connections = new Map();
this._connecting = new Map();
// Called with a groupId whenever this pool closes that group's connection
- // (eviction or closeAll). SearchPage uses it to drop its own record so it
- // never hands a tile a `_tRef` pointing at a transport just closed here.
+ // (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 || (() => {});
+ // Negotiations under way, and those waiting for one of the places.
+ this._active = 0;
+ this._waiting = [];
+ this._closed = false;
}
- async connect(groupId, token, bundleKey, username, userId) {
- const existing = this._connections.get(groupId);
- if (existing && existing.transport.connected) {
- existing.lastUsed = Date.now();
- return existing;
+ get size() { return this._connections.size; }
+
+ get closed() { return this._closed; }
+
+ /** Whether `groupId` has a connection here that using costs no offer. */
+ has(groupId) {
+ const conn = this._connections.get(groupId);
+ return !!(conn && conn.transport.connected);
+ }
+
+ /**
+ * The group's connection, negotiating one if there is none.
+ *
+ * `hold` keeps it from being evicted until `release` — the sweep holds each
+ * group while its index is on the way, because with more groups than the pool
+ * keeps, the connection it is reading from would otherwise be the oldest one
+ * and closed under it.
+ */
+ async connect(groupId, token, bundleKey, username, userId, { hold = false } = {}) {
+ if (this._closed) throw new Error('Search was closed');
+ let conn = this._connections.get(groupId);
+ if (!(conn && conn.transport.connected)) {
+ let p = this._connecting.get(groupId);
+ if (!p) {
+ p = this._negotiate(groupId, token, bundleKey, username, userId);
+ this._connecting.set(groupId, p);
+ p.finally(() => this._connecting.delete(groupId)).catch(() => {});
+ }
+ conn = await p;
}
- if (this._connecting.has(groupId)) return this._connecting.get(groupId);
+ conn.lastUsed = Date.now();
+ if (hold) conn.holds += 1;
+ this._evict();
+ return conn;
+ }
+
+ release(conn) {
+ conn.holds = Math.max(0, conn.holds - 1);
+ this._evict();
+ }
- const p = this._doConnect(groupId, token, bundleKey, username, userId);
- this._connecting.set(groupId, p);
+ /** Drop one group's connection, e.g. one that stopped answering. */
+ close(groupId) {
+ const conn = this._connections.get(groupId);
+ if (!conn) return;
+ try { conn.transport.close(); } catch {}
+ this._connections.delete(groupId);
+ this._onEvict(groupId);
+ }
+
+ async _negotiate(groupId, token, bundleKey, username, userId) {
+ await this._takePlace();
+ let conn;
try {
- const conn = await p;
- this._connections.set(groupId, conn);
- this._evict();
- return conn;
+ // Waiting for a place is not part of any deadline: `connectToGroup`'s
+ // timers start inside `_doConnect`, once this negotiation is really on.
+ if (this._closed) throw new Error('Search was closed');
+ conn = await this._doConnect(groupId, token, bundleKey, username, userId);
} finally {
- this._connecting.delete(groupId);
+ this._givePlace();
+ }
+ if (this._closed) {
+ try { conn.transport.close(); } catch {}
+ throw new Error('Search was closed');
+ }
+ // A connection that dropped is replaced, and closed so its own reconnect
+ // loop does not keep negotiating a second one for the same group.
+ const stale = this._connections.get(groupId);
+ if (stale && stale.transport !== conn.transport) {
+ try { stale.transport.close(); } catch {}
+ }
+ this._connections.set(groupId, conn);
+ return conn;
+ }
+
+ _takePlace() {
+ if (this._active < MAX_IN_FLIGHT) {
+ this._active += 1;
+ return Promise.resolve();
}
+ return new Promise((resolve) => this._waiting.push(resolve));
+ }
+
+ _givePlace() {
+ const next = this._waiting.shift();
+ if (next) next(); // handed straight over: `_active` is unchanged
+ else this._active -= 1;
}
async _doConnect(groupId, token, bundleKey, username, userId) {
const found = await connectToGroup(
this._hubBase, groupId, token, bundleKey, username, userId);
if (!found) throw new Error('offline');
- const { transport } = found;
+ const { transport, ack } = found;
let gek = null;
if (transport.gekRaw && window.MeshBayCrypto) {
gek = await window.MeshBayCrypto.importGEK(
window.MeshBayCrypto.b64encode(transport.gekRaw));
}
- return { transport, gek, lastUsed: Date.now() };
+ return { transport, gek, ack, lastUsed: Date.now(), holds: 0 };
}
_evict() {
while (this._connections.size > MAX_POOL_SIZE) {
let oldestId = null, oldestTime = Infinity;
for (const [id, conn] of this._connections) {
+ if (conn.holds > 0) continue;
if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; }
}
+ // Everything over the size is held: it goes when it is released.
if (!oldestId) break;
- const conn = this._connections.get(oldestId);
- try { conn.transport.close(); } catch {}
- this._connections.delete(oldestId);
- this._onEvict(oldestId);
+ this.close(oldestId);
}
}
closeAll() {
+ // In-flight negotiations see this when they finish and close what they got.
+ this._closed = true;
for (const [id, conn] of this._connections) {
try { conn.transport.close(); } catch {}
this._onEvict(id);
}
this._connections.clear();
- for (const [, p] of this._connecting) {
- p.then(c => { try { c.transport.close(); } catch {} }).catch(() => {});
- }
- this._connecting.clear();
}
}
// -- Index fetching -----------------------------------------------------------
-async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
- const found = await connectToGroup(HUB, groupId, token, bundleKey, username, userId);
- if (!found) return null;
- const { transport, ack } = found;
+// How long a connection the pool already holds gets to prove it is alive before
+// its index is asked for. A phone that slept keeps reporting `connected` on a
+// channel that is gone, and an index request on it would wait thirty seconds.
+const REUSE_PING_MS = 4000;
+/**
+ * One group's index, over the pool's connection to it — which the page then
+ * keeps for its tiles, so reaching a group costs one offer and not two.
+ */
+async function fetchGroupIndex(pool, groupId, token, bundleKey, username, userId) {
+ if (pool.has(groupId)) {
+ const held = await pool.connect(groupId, token, bundleKey, username, userId);
+ try { await held.transport.ping(REUSE_PING_MS); } catch { pool.close(groupId); }
+ }
+ const conn = await pool.connect(
+ groupId, token, bundleKey, username, userId, { hold: true });
+ const { transport, ack } = conn;
+
+ let unlisted = false;
try {
// The operator asked for this group to stay out of the global listing.
// Decided here, before the index is asked for, so nothing of it is held,
// cached or merged by this page. A listing preference and not a boundary:
// the node cannot tell this request from the group page's, and opening the
// group lists everything.
- if (ack.search_listed === false) return { unlisted: true };
+ if (ack.search_listed === false) {
+ unlisted = true;
+ return { unlisted: true };
+ }
const indexMsg = await transport.fetchIndex();
// Plural, with the old scalars as the fallback for a node still speaking
@@ -213,7 +319,9 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
// describes this connection, not the group's content.
return { entries: indexMsg.entries || [], roots, isNodeAdmin: !!ack.is_node_admin };
} finally {
- try { transport.close(); } catch {}
+ pool.release(conn);
+ // Nothing of an unlisted group is shown, so nothing will use its connection.
+ if (unlisted) pool.close(groupId);
}
}
@@ -269,7 +377,7 @@ function rememberDown(ids) {
} catch { /* private window, or storage refused: the order is advisory */ }
}
-async function fetchAllIndexes(groups, token, username, userId, onProgress, onResult) {
+async function fetchAllIndexes(pool, groups, token, username, userId, onProgress, onResult) {
const bundleKey = session.bundleKey || await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
@@ -286,7 +394,7 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onRe
await inFlight(queue, MAX_IN_FLIGHT, async (g) => {
try {
- const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId);
+ const result = await fetchGroupIndex(pool, g.id, token, bundleKey, username, userId);
if (result && result.unlisted) {
// Left out of Search by its operator: not shown, and not down either.
} else if (result) {
@@ -304,14 +412,18 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onRe
unreachable.push(g.name || g.id);
downNow.add(g.id);
}
- } catch {
+ } catch (e) {
unreachable.push(g.name || g.id);
- downNow.add(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);
}
done++;
onProgress({ done, total, unreachable: [...unreachable] });
});
- rememberDown(downNow);
+ // 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 };
}
@@ -448,8 +560,9 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
setFetching(true);
setProgress({ done: 0, total: groups.length, unreachable: [] });
+ if (!poolRef.current) return;
await fetchAllIndexes(
- groups, token, username, userId,
+ poolRef.current, groups, token, username, userId,
(p) => { if (!cancelled) setProgress(p); },
(results) => { if (!cancelled) setIndexedGroups(new Map(results)); },
);
@@ -530,19 +643,30 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
return entry;
}, [token, username, userId, markGroupDown]);
- // Warm up connections as soon as indexing finishes so thumbnails start
- // loading before the user switches views — but only up to the pool's
- // capacity. Warming every group would just evict the earlier ones before
- // the user ever gets there; groups past the cap connect lazily when a tile
- // of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps
+ // Hand the tiles their connections as soon as indexing finishes, so
+ // thumbnails start loading before the user switches views. The sweep left
+ // its connections in the pool, so for those this costs no offer at all; a
+ // group is dialled here only when its connection has gone since and the pool
+ // has room — never to evict one that is already open, which would be an offer
+ // spent to lose another. Groups past the pool's size connect lazily when a
+ // tile of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps
// fetched thumbnails across evictions.
useEffect(() => {
if (fetching || indexedGroups.size === 0) return;
let cancelled = false;
(async () => {
- const groupIds = [...indexedGroups.keys()].slice(0, MAX_POOL_SIZE);
- await inFlight(groupIds, MAX_IN_FLIGHT, async (gid) => {
+ const pool = poolRef.current;
+ if (!pool) return;
+ const ids = [...indexedGroups.keys()];
+ const open = ids.filter((gid) => pool.has(gid));
+ for (const gid of open) {
+ if (cancelled) return;
+ try { await connectGroup(gid); } catch { /* skip */ }
+ }
+ const room = Math.max(0, MAX_POOL_SIZE - pool.size);
+ const closed = ids.filter((gid) => !pool.has(gid)).slice(0, room);
+ await inFlight(closed, MAX_IN_FLIGHT, async (gid) => {
if (cancelled) return;
try { await connectGroup(gid); } catch { /* skip */ }
});