diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 23:09:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 23:09:28 +0200 |
| commit | 1e6abbb8db76cdabd53b85d938ee0e76486f5ae4 (patch) | |
| tree | 1263996e4751e2a083998acdbd9f07f1ca465b35 /packages/meshbay-hub/src | |
| parent | 25bf672719be2e47c15fc3659635b50a1540571d (diff) | |
| download | meshbay-1e6abbb8db76cdabd53b85d938ee0e76486f5ae4.tar.gz | |
fix(hub): key admin_challenge/admin_response by op, not arrival order
Reproduced from a real report: enabling the Music app and saving its root
folder in the same Settings visit (the new merged Directories section
makes this a fast, natural back-to-back sequence) fired two signed admin
ops within milliseconds. Neither the admin_challenge reply nor the
admin_response ack two steps later was keyed by anything — both were
matched purely by "whichever request happens to be oldest pending"
(transport.js's own documented last-resort guess). apps_enabled's
challenge stole audio_root's pending slot; audio_root's own request never
received a challenge at all and just sat there until its 30s timeout.
Both hops are now keyed by op name: admin_challenge already carries `op`
from the node, and admin_response is given one client-side purely for
this (the node's _do_admin_response never reads it — only op_id and
signature). A stray admin_challenge with no matching request is dropped
outright rather than guessed at — it is never a broadcast (one
`self._send`, no peer loop, docs/webrtc_server.py), so a session with no
matching key genuinely has nothing to do with it. A domain ack (an actual
broadcast — every connected client gets audio_root_ack, not just the
requester) still falls through to the existing per-type handling when
nobody here is waiting on it, unchanged.
Verified against a standalone reproduction of the exact race (two admin
ops racing, reordered replies) — this codebase has no browser-JS test
runner to add as a real regression test, so the repro lived in a scratch
script rather than the suite.
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 69 |
1 files changed, 69 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 817db7a..8d8027e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -57,6 +57,29 @@ function _aborted() { return err; } +// Every request type that goes through the two-step admin_challenge / +// admin_response flow (_authorizeAdminOp below) — one entry per +// `_authorizeAdminOp(msg, expectedOp, ...)` call site. Found live: enabling +// the Music app and then saving its root folder in the same Settings visit +// (the new merged Directories section makes this a natural, fast +// back-to-back sequence) fired two of these within milliseconds of each +// other. Both admin_challenge replies, and both domain acks afterward, +// were routed by nothing more than "whichever request happens to be +// oldest pending" — apps_enabled's challenge stole audio_root's slot, then +// audio_root's own request just sat there until its 30s timeout, having +// never received a challenge to answer at all. Keying both hops by op name +// (below, in _key and in _dispatch) fixes this without needing the node to +// change anything — `op` is already on every admin_challenge, and this +// list is what lets a response two steps later be tied back to the right +// one. +const ADMIN_OP_TYPES = new Set([ + 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', + 'musicbrainz_config', 'musicbrainz_enabled', 'file_delete', 'dir_delete', + 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', + 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach', + 'group_detach', 'invite_create', +]); + const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', @@ -782,6 +805,13 @@ class MeshBayTransport { v: '0.1', op_id: challenge.op_id, signature, + // Not read by the node (_do_admin_response only looks at op_id and + // signature) — carried so _sendAndWait can key this reply by op, the + // same way the admin_challenge that preceded it was keyed. Without + // it, two admin_response replies in flight together (e.g. one op's + // audio_root_ack arriving while another's apps_enabled_ack is still + // pending) are matched by nothing more than arrival order. + op: challenge.op, }); if (ack.type === 'error') throw new Error(ack.detail); return ack; @@ -1420,6 +1450,14 @@ class MeshBayTransport { // Same reordering hazard as media_meta_req: the player prefetches // the next track while the current one may still be transcoding. : obj.type === 'audio_transcode_req' ? `audio_transcode:${obj.file_id}` + // Two-step admin-op flow (_authorizeAdminOp) — see ADMIN_OP_TYPES' + // own comment for the race this closes. The initial request and + // the admin_response that follows it are keyed the same way + // (`admin:${op}`) precisely so a reply belongs to the request + // that named that op, not to whichever admin op happened to be + // submitted first. + : ADMIN_OP_TYPES.has(obj.type) ? `admin:${obj.type}` + : obj.type === 'admin_response' ? `admin:${obj.op}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, @@ -1464,6 +1502,37 @@ class MeshBayTransport { } _dispatch(msg) { + // Two-step admin-op flow (_authorizeAdminOp, ADMIN_OP_TYPES) — resolve + // by (op) key before anything below gets a chance to steal it via the + // generic "oldest pending" fallback further down. Returns as soon as a + // match resolves: this transport instance is the one that submitted + // the request, and its own caller already updates local state from + // what *it* sent (setAppsEnabled/setVideoRoot/... callers all do + // `onX(next)` with their own local value, never by reading the ack), + // so the broadcast-oriented per-type handlers below — there for every + // *other* connected client learning the change — have nothing left to + // add for this one. A message nobody here is waiting on (the common + // case: this key match finds nothing) falls through exactly as before. + if (msg.type === 'admin_challenge' && msg.op) { + // Unlike an *_ack, this one is never a broadcast — the node only + // ever sends it as a private reply to whichever session just + // submitted the op it names (_issue_admin_challenge, one `self._send` + // call, no peer loop) — so a session with no matching key genuinely + // has nothing further to do with it either, and falling through to + // "oldest pending" here can only ever be wrong, never a fallback + // that happens to be right. + const key = `admin:${msg.op}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); break; } + } + return; + } else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) { + const key = `admin:${msg.type.slice(0, -4)}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + } + // While an upload is in flight the acks are its own, and there are many of // them: they must not be handed to whatever request happens to be oldest in // the pending map. |