summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js69
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.