diff options
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.js | 226 |
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 */ } }); |