diff options
Diffstat (limited to 'packages/meshbay-hub/src')
3 files changed, 123 insertions, 42 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 26519da..4fdf5f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -256,6 +256,44 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } }, [passInput, username]); + // Everything one handshake ack tells this page, applied in one place. + // + // Called by the first connect and again by every automatic reconnect: a node + // that restarted is a different process, and its answers are not the ones the + // first handshake got. Written once because the two paths drifting is how + // `helloworld`'s directories went missing from one of them. + const applyAck = useCallback((ack) => { + if (!ack) return; + setIsNodeAdmin(!!ack.is_node_admin); + setEnabledApps(ack.enabled_apps || null); + setScanSettings(ack.scan_settings || null); + setTmdbConfig({ + // Per-group (2026-08-24, used to be node-wide). + enabled: ack.tmdb_enabled !== false, + // Node-wide — one shared credential/cache. + tokenCustomized: !!ack.tmdb_token_customized, + language: ack.tmdb_language || '', + }); + // Every `<app>_directories` the ack carries, keyed by the app's own + // name — read off the ack rather than from a list of app names held + // here, so an application the node knows about is one this page already + // handles. Three names were hardcoded until 2026-09-10 and `helloworld` + // was not among them, so the app that exists to prove a new one needs + // no special-casing had its directories dropped on arrival. The live + // path below (`onAppDirectories`) was always generic; this was the half + // that was not. + setAppDirectories(Object.fromEntries( + Object.keys(ack) + .filter((k) => k.endsWith('_directories')) + .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); + setChatDirectory(ack.chat_directory || ''); + setChatLinkPreview(ack.chat_link_preview !== false); + setSearchListed(ack.search_listed !== false); + setMusicbrainzConfig({ + enabled: ack.musicbrainz_enabled !== false, + }); + }, []); + // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the @@ -298,6 +336,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, useEffect(() => { let cancelled = false; + // Dropped by the teardown below, so a transport handed on to a running + // download (`releaseWhenIdle`) stops driving a page that is gone. + let offReconnect = null; // The cache is written here and read only by the search page. It used to // seed this list too, which put a stale index on screen and then raced the @@ -398,34 +439,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (!transport) throw (lastErr || new Error('no node served this group')); session.pendingJoinCode = null; if (cancelled) return; - setIsNodeAdmin(!!ack.is_node_admin); - setEnabledApps(ack.enabled_apps || null); - setScanSettings(ack.scan_settings || null); - setTmdbConfig({ - // Per-group (2026-08-24, used to be node-wide). - enabled: ack.tmdb_enabled !== false, - // Node-wide — one shared credential/cache. - tokenCustomized: !!ack.tmdb_token_customized, - language: ack.tmdb_language || '', - }); - // Every `<app>_directories` the ack carries, keyed by the app's own - // name — read off the ack rather than from a list of app names held - // here, so an application the node knows about is one this page already - // handles. Three names were hardcoded until 2026-09-10 and `helloworld` - // was not among them, so the app that exists to prove a new one needs - // no special-casing had its directories dropped on arrival. The live - // path below (`onAppDirectories`) was always generic; this was the half - // that was not. - setAppDirectories(Object.fromEntries( - Object.keys(ack) - .filter((k) => k.endsWith('_directories')) - .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); - setChatDirectory(ack.chat_directory || ''); - setChatLinkPreview(ack.chat_link_preview !== false); - setSearchListed(ack.search_listed !== false); - setMusicbrainzConfig({ - enabled: ack.musicbrainz_enabled !== false, - }); + applyAck(ack); transport.onAppsEnabled = (apps) => setEnabledApps(apps); // Two independent acks now (tmdb_config_ack: token/language, // node-wide; tmdb_enabled_ack: the per-group switch) — each merges @@ -513,6 +527,49 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (onPresence) onPresence(groupId, 'online'); }; + // An automatic reconnect (transport.js's _reconnectLoop) re-does the + // handshake and nothing else: no index is fetched, and any push sent + // while the old channel was dying is simply lost. That was survivable + // while a node that came back came back with the same answers — and a + // restarted one does not. It rebuilds its index from + // `index_cache.db`, which stores path/mtime/size/hash/type and no + // enrichment at all, so for the ~25s its re-enrichment pass takes + // (measured: 6176 audio files, 6.4s of tag reads plus cover work) the + // index it serves has no artist and no album on any track. A client + // that reconnected inside that window kept exactly that view for as + // long as the page stayed open: Files and Videos looked right — one + // needs no enrichment, the other's is restored from `media_cache.db` + // — and Music, whose grouping *is* the enrichment, drew nothing. + // + // So the reconnect asks again, for the ack and the index both. The + // full fetch rather than a delta: this session was never told what it + // missed, and a delta is computed against a snapshot only the node + // has. + offReconnect = transport.addReconnectListener((reack) => { + if (cancelled) return; + (async () => { + try { + applyAck(reack); + // Re-imported, not kept: a chat epoch or a re-key while we were + // away means the handshake just handed us a different GEK, and + // gekRef is what every decrypt on this page reads. + if (transport.gekRaw && window.MeshBayCrypto) { + gekRef.current = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + const msg = await transport.fetchIndex(); + if (cancelled) return; + applyIndex(msg); + } catch (e) { + // The connection went again mid-refresh: the next reconnect + // runs this same handler. Saying so beats a view that is + // quietly one node-restart old. + console.warn('[MeshBay] index refresh after reconnect failed:', + e.message); + } + })(); + }); + // We are in: an invitation to this group has served its purpose. if (onJoined) onJoined(groupId); @@ -591,6 +648,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, return () => { cancelled = true; + if (offReconnect) { offReconnect(); offReconnect = null; } // Nothing will update this group's dock row once the page lets go of it. reportIndexPush(groupId, null); if (transportRef.current) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f4979f4..39e0ccf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -487,7 +487,12 @@ class MeshBayTransport { // "a reconnect to wait for" and stall every handshake step for the full // 6s gate below before ever sending it. this._inReconnectAttempt = false; - this._onReconnected = null; + // A set, not one slot. Two consumers want this at once — the video + // player, to re-ask for the stream it was watching, and the group + // page, to re-read an index the node rebuilt while we were away — + // and a single setter meant the second to arrive silently replaced + // the first, then cleared it on the way out. + this._reconnectListeners = new Set(); this._onNeedToken = null; // Which device key THIS connection has identified itself to the node with. // Empty means "not identified": nothing can be sealed, so nothing can be @@ -548,10 +553,19 @@ class MeshBayTransport { // Fired when a message that must open under the group key does not — // see _failSession. The session is over by the time this runs. set onSessionFailed(fn) { this._onSessionFailed = fn; } - // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh - // handshake, so a consumer with something mid-flight on the old channel — - // today only the video player — can pick back up rather than sit dead. - set onReconnected(fn) { this._onReconnected = fn; } + /** + * Told once an automatic reconnect (see _reconnectLoop) lands a fresh + * handshake, with that handshake's ack. + * + * Returns its own unsubscribe, because the caller that stops listening + * must not be able to stop anyone else listening: `onReconnected` was a + * setter, the video player took it on open and set it back to `null` on + * close, and any other consumer's handler went with it. + */ + addReconnectListener(fn) { + this._reconnectListeners.add(fn); + return () => this._reconnectListeners.delete(fn); + } /** * Told whenever this connection's device identity changes — including to @@ -1213,10 +1227,11 @@ class MeshBayTransport { const token = this._onNeedToken ? await this._onNeedToken() : this._lastToken; trace('reconnect_attempt', { attempt: this._reconnectAttempts }); this._inReconnectAttempt = true; + let ack; try { - await this.connect(args.nodeId, token, args.groupId, args.gekRaw, - this._sessionKeys, args.bundleKey, args.username, - args.userId, args.joinCode); + ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, + this._sessionKeys, args.bundleKey, args.username, + args.userId, args.joinCode); } finally { this._inReconnectAttempt = false; } @@ -1226,9 +1241,14 @@ class MeshBayTransport { // have asked for its slot back first, or its next `file_req` carries a // `tr` the node has never heard of. this._reopenTransfers(); - if (this._onReconnected) { - try { this._onReconnected(); } catch (e) { - console.error('[MeshBay] onReconnected handler threw:', e); + // The ack goes with it: this is a *new* session against whatever the + // node is running now, and everything the first handshake taught the + // page — the folders each app reads, which apps are on, the roots — + // was answered by a process that may since have restarted. One + // listener throwing must not rob the next of the notification. + for (const fn of [...this._reconnectListeners]) { + try { fn(ack); } catch (e) { + console.error('[MeshBay] reconnect listener threw:', e); } } return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index a07558e..66099de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -521,6 +521,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { useEffect(() => { let cancelled = false; + // Held so the teardown below can drop *this* listener and no one else's + // (transport.js's addReconnectListener). + let offReconnect = null; // Reset here, not in the teardown of the run before: switching video while // an append was in flight left `appendingRef` true, and flushQueue bails // out on it. The new SourceBuffer then never appended anything, so no @@ -730,7 +733,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // exactly what dragging the scrubber does — so reusing it here means a // screen-lock reconnect looks like a seek to where the film already // was, not a reload. - transport.onReconnected = () => { + offReconnect = transport.addReconnectListener(() => { if (cancelled) return; const v = videoRef.current; const seek = requestSeekRef.current; @@ -738,7 +741,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { console.log('[MeshBay] transport reconnected — resuming stream at', v.currentTime.toFixed(1)); seek(v.currentTime); - }; + }); transport.onStreamInit = (msg) => { if (cancelled) return; @@ -1121,8 +1124,8 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { transport.onStreamData = null; transport.onStreamEnd = null; transport.onStreamError = null; - transport.onReconnected = null; } + if (offReconnect) { offReconnect(); offReconnect = null; } // The queue can hold several megabytes of decrypted video. queueRef.current = []; const ms = msRef.current; |