diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 19:01:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 19:01:08 +0200 |
| commit | 05f4feab641740c944d636f29a03f8c0dd1328c7 (patch) | |
| tree | 9a41cbaac3c36db854d6a1eb7750404ff79fa4e4 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | dd3927a661273734493f65a593755b95aecf5f09 (diff) | |
| download | meshbay-05f4feab641740c944d636f29a03f8c0dd1328c7.tar.gz | |
fix: stop a stream on close, count only real users, record where a node is
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.
While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.
**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.
**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.
Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 57 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 35 |
3 files changed, 103 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 18ce83a..cf33d72 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -2587,10 +2587,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { videoRef.current.removeEventListener('seeking', onSeeking); } if (transport) { + // Tell the node first: dropping the handlers only makes us deaf, and a + // stream nobody is listening to still occupies a transcode slot. + transport.stopStream(); transport.onStreamInit = null; transport.onStreamData = null; transport.onStreamEnd = null; } + // The queue can hold several megabytes of decrypted video. + queueRef.current = []; + const ms = msRef.current; + if (ms && ms.readyState === 'open') { + try { ms.endOfStream(); } catch { /* already ended */ } + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + sbRef.current = null; + msRef.current = null; }; }, [entry, flushQueue]); @@ -3056,6 +3071,7 @@ function AdminPage({ token }) { const [logEvent, setLogEvent] = useState(''); const [logOffset, setLogOffset] = useState(0); const [blocklist, setBlocklist] = useState([]); + const [nodes, setNodes] = useState([]); const [detailUser, setDetailUser] = useState(null); const [error, setError] = useState(''); @@ -3105,6 +3121,10 @@ function AdminPage({ token }) { if (tab === 'stats') loadStats(); else if (tab === 'users') loadUsers(userSearch); else if (tab === 'groups') loadGroups(); + else if (tab === 'nodes') { + hubFetch('/v1/admin/nodes', { token }) + .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message)); + } else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); } else if (tab === 'blocklist') loadBlocklist(); }, [tab]); @@ -3157,7 +3177,7 @@ function AdminPage({ token }) { } catch (e) { setError(e.message); } }, [token]); - const TABS = ['stats', 'users', 'groups', 'logs', 'blocklist']; + const TABS = ['stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; return html` <div> @@ -3267,6 +3287,41 @@ function AdminPage({ token }) { </table> `} + ${tab === 'nodes' && html` + <p class="settings-hint" style="margin-bottom:10px"> + ${t('admin.nodes_hint')} + </p> + <table class="admin-table"> + <thead><tr> + <th>${t('admin.col_username')}</th> + <th>${t('admin.col_observed_ip')}</th> + <th>${t('admin.col_hint')}</th> + <th>${t('admin.col_last_seen')}</th> + <th>${t('admin.col_status')}</th> + </tr></thead> + <tbody> + ${nodes.length === 0 && html` + <tr><td colspan="5" class="admin-empty">${t('admin.no_nodes')}</td></tr> + `} + ${nodes.map(n => html` + <tr key=${n.id}> + <td>${n.username || n.user_id.slice(0, 8)}</td> + <td style="font-family:monospace">${n.observed_ip || '—'}</td> + <td style="font-family:monospace;color:var(--text-dim)"> + ${n.endpoint_hint || '—'} + </td> + <td>${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'}</td> + <td> + <span class="badge ${n.online ? 'badge-ok' : ''}"> + ${n.online ? t('admin.node_online') : t('admin.node_offline')} + </span> + </td> + </tr> + `)} + </tbody> + </table> + `} + ${tab === 'logs' && html` <div class="admin-toolbar"> <select class="admin-select" value=${logEvent} onChange=${e => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 71f6cce..affa7c8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -195,7 +195,19 @@ const en = { 'admin.tab_stats': 'Stats', 'admin.tab_users': 'Users', 'admin.tab_groups': 'Groups', + 'admin.tab_nodes': 'Nodes', 'admin.tab_logs': 'Logs', + 'admin.col_observed_ip': 'Seen from', + 'admin.col_hint': 'Announced hint', + 'admin.col_last_seen': 'Last announce', + 'admin.node_online': 'online', + 'admin.node_offline': 'offline', + 'admin.no_nodes': 'No nodes registered', + 'admin.nodes_hint': '"Seen from" is the address the announcement arrived from, ' + + 'on a request signed with the node key — that is the one to answer a ' + + 'question with. The hint is what the node believes its own address to be, ' + + 'learned from a STUN server and sent to us; it is useful for reaching the ' + + 'node and is not evidence of anything.', 'admin.tab_blocklist': 'Blocklist', // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 844a201..df2abb0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -562,6 +562,18 @@ class MeshBayTransport { } /** + * Nobody is watching any more. + * + * Closing the viewer used to say nothing to the node, which went on + * transcoding and holding one of its two slots until the credit timeout — so + * the next video answered "server busy". + */ + stopStream() { + if (!this._connected) return; + try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ } + } + + /** * Push a whole file, several chunks in flight at once. * * One chunk per round trip is 48 KB of throughput per RTT no matter how much @@ -778,6 +790,11 @@ class MeshBayTransport { }, 30000); this._pending.set(id, { _reqType: obj.type, + // Chunks are the one request that runs several at a time and can be + // interleaved with anything else on the channel. Matching them by + // arrival order was only ever true by luck; this makes it true. + _key: obj.type === 'file_req' + ? `chunk:${obj.file_id}:${obj.chunk_index}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -857,6 +874,24 @@ class MeshBayTransport { return; } + if (msg.type === 'file_chunk') { + const key = `chunk:${msg.file_id}:${msg.chunk_index}`; + for (const [, handler] of this._pending) { + // A node from before the reply carried a file_id: fall back to the + // index, which is still better than the oldest pending request. + const match = msg.file_id + ? handler._key === key + : handler._key && handler._key.endsWith(`:${msg.chunk_index}`); + if (match) { + handler.resolve(msg); + return; + } + } + // Nobody asked for it any more — a cancelled download, most likely. It + // must not be handed to whatever request happens to be waiting. + return; + } + const oldest = this._pending.entries().next(); if (!oldest.done) { const [, handler] = oldest.value; |