diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 16:02:10 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 16:02:10 +0200 |
| commit | d1f998b42137465b610667439527917a00030b4d (patch) | |
| tree | 13674bc358ce585b0a2cd14ac210486bed79ff29 /packages/meshbay-hub/src | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-d1f998b42137465b610667439527917a00030b4d.tar.gz | |
fix(mnp): give a reply an id, so it stops being routed by luck
MNP carried no correlation id. A reply named its own type and nothing
else, so a client with more than one request in flight worked out which
one a message answered from the message itself — and for the replies
that name nothing it could not. `_dispatch` fell through to matching by
arrival order, which is a guess. `_sendAndWait` had the right value all
along: it keys `_pending` by `this._seqId++` and never put it on the
wire.
The guess fails asymmetrically, which is why it hid. The victim is not
the request that was answered wrongly — it is the unrelated one that now
waits out its own 30s timeout for a reply already delivered elsewhere.
Live on 2026-09-06: five `music_meta_req` sat pending for over 100
seconds behind a failing MusicBrainz, and a `device_list_result` was
handed to one of them. The composer is disabled while a send is in
flight, so a chat message whose reply went astray the same way left the
Chat tab looking frozen for thirty seconds, then unfroze on its own.
The `ack` half of this was fixed on 2026-08-30 by matching on request
type. That closed the instance and left the class open: a refusal has no
type to match on either, and `_dispatch_message`'s catch-all answers
every unforeseen failure with `{"type": "error", "detail": "Request
failed"}` — 238 of this module's 240 error sends name nothing at all.
`req_id` now rides on the request and comes back on the reply. On the
node it is published for the whole handler in a ContextVar and stamped
by `_send`: a parameter would have meant threading an argument through
all 240 send sites, and asyncio copies the context into a task, so a
handler that `_spawn`s its real work still answers under the right id.
It is never stamped on a broadcast — those answer nothing, and the
owner check in `_send` is what keeps a chat broadcast or an index push
from reaching another peer looking like a reply.
On the client, `_dispatch` resolves on `req_id` first and the
arrival-order fallback is gone the moment a node proves it stamps
(`_correlates`, armed by the handshake's own reply). The fallback stays
for an MNP 1.0 node, unchanged and no wider: there it is the only thing
there is, and removing it would leave device_list_result, join_result
and the handshake replies reaching nobody.
Two things fall out. `sendChat` refuses an `error` reply like every
other request in the file — it returned it as success, which did not
matter while a refusal reached the wrong caller anyway and would now
show a rejected message as sent. And `_group_ctx` uses `.get`: a reload
pops a removed group while sessions connected to it are open, and every
request they had left raised KeyError into that same catch-all.
Sealed index messages are the one exception to the fast path. They
cannot be handed over until they are opened, which is asynchronous while
`_dispatch` is not — resolving on the id alone gave `fetchIndex` the
envelope and skipped `onIndexSync` entirely. Caught by extending
`index_seal_probe.mjs` to stamp a reply the way a current node does,
after the hub suite passed over it: the probe built its own frames and
had never seen one.
Tests, all failing before and passing after: `test_chat_send.py` drives
the real ChatPanel over the real transport for both shapes of reply with
an older request pending (3 of its 6 are new, and the 3 for `ack` pass
either way, so it discriminates); `test_reply_correlation.py` pins the
node's half — the refusals that name nothing else, the broadcast that
must not be stamped, and a late reply from a spawned task answering
under its own id rather than the most recent request's.
Full suite: 1897 passed, same 11 pre-existing failures as before.
QUIC keeps its own dispatch and is not stamped. It is disabled by
default and no browser request reaches it, but the asymmetry is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dn1xYx9uT69mCB6UDvyKAN
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 99 |
1 files changed, 88 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 179292e..e57ad8b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -287,6 +287,11 @@ class MeshBayTransport { this._pc = null; this._channel = null; this._pending = new Map(); + // Set the first time this connection sees a reply that names the request + // it answers (see _dispatch). A node either stamps every reply or none, + // so one is proof for the connection — and once there is proof, the + // arrival-order fallback at the bottom of _dispatch is never right again. + this._correlates = false; this._seqId = 0; this._recvBuf = new Uint8Array(0); this._connected = false; @@ -1429,6 +1434,12 @@ class MeshBayTransport { thread_id: threadId || null, sender_name: senderName || null, }); + // Every other request in this file refuses an `error` reply; this one + // returned it as though the node had accepted the message. It never + // mattered while a refusal reached the wrong caller anyway — now that a + // reply finds the request that made it, a message the node rejected + // would otherwise appear in the conversation as sent. + if (msg.type === 'error') throw new Error(msg.detail || 'chat send refused'); return msg; } @@ -2191,12 +2202,15 @@ class MeshBayTransport { if (msg.type === 'index_sync') { if (this._onIndexSync) this._onIndexSync(opened); - for (const [, handler] of this._pending) { - if (handler._reqType === 'index_sync') { - handler.resolve(opened); - break; - } - } + // The node's first push to a newly connected peer is an index_sync + // nobody asked for, so there is not always a request to resolve. When + // there is, `req_id` says which one — the type match below is what a + // node too old to stamp one leaves us, and it is why two fetches in + // flight at once used to resolve the wrong one. + const handler = opened.req_id !== undefined && opened.req_id !== null + ? this._pending.get(opened.req_id) + : [...this._pending.values()].find(h => h._reqType === 'index_sync'); + if (handler) handler.resolve(opened); return; } if (this._onIndexDelta) this._onIndexDelta(opened); @@ -2283,7 +2297,13 @@ class MeshBayTransport { resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); - this._send(obj); + // The id goes on the wire (MNP 1.1+): a node that understands it stamps + // the reply with it, and _dispatch matches on that alone. It used to be + // local to this map, which is why every reply had to be recognised by + // some field of its own — and why the ones that carry no such field + // reached their caller by luck. An older node ignores the extra key and + // is routed by the per-type fallbacks below, exactly as before. + this._send({ ...obj, req_id: id }); }); } @@ -2323,6 +2343,45 @@ class MeshBayTransport { } _dispatch(msg) { + // A reply that names the request it answers. Nothing below this needs to + // recognise it, and nothing below this may see it: every remaining branch + // exists to identify a reply by some field of its own, which is the job + // this makes unnecessary. + // + // What is left underneath is genuinely unsolicited — a broadcast to every + // connected client, a push, a challenge — or a reply from a node too old + // to stamp one, which is what the per-type keys are for now. + if (msg.req_id !== undefined && msg.req_id !== null) { + this._correlates = true; + // The one exception, and the only one: an index message is sealed under + // the GEK and cannot be handed to its caller until it is opened, which + // is not something this synchronous function can do. Resolving it here + // would give `fetchIndex` the envelope — nonce and ciphertext, no + // entries — and skip `_onIndexSync` entirely. `_queueIndexMessage` + // opens it and then resolves, by this same id. + const sealed = msg.type === 'index_sync' || msg.type === 'index_delta'; + if (!sealed) { + const handler = this._pending.get(msg.req_id); + if (handler) { + handler.resolve(msg); + // The acks whose *broadcast* half their own requester also needs: + // every other client learns the change from the broadcast, and the + // one that asked for it is the only one that would not, because its + // own request swallowed its copy. Same call the keyed `_ack` branch + // below makes, for the same reason. + if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg); + return; + } + // Answers a request that is no longer waiting: it gave up at its own + // timeout, or a reconnect rejected everything in flight. It belongs to + // nobody, and the whole point of this change is that it is not offered + // to somebody else instead. + console.warn('[MeshBay] late reply to req', msg.req_id, '(', msg.type, + ') — nothing waiting'); + return; + } + } + // 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 @@ -2715,10 +2774,28 @@ class MeshBayTransport { return; } - // Everything above is routed by something in the message. What is left is - // matched by arrival order, which is only ever a guess — and a wrong guess - // here hands one request's answer to another, which then waits for a reply - // that already came. Logged so that guess is visible. + // Everything above is routed by something in the message. What is left + // used to be matched by arrival order — a guess, and a wrong guess hands + // one request's answer to another, which then waits out its own 30s + // timeout for a reply that already came and went. That is how the Chat + // composer, disabled while a send is in flight, could stay disabled for + // thirty seconds on a message the node had already stored. + // + // A node that stamps its replies (`req_id`, handled at the top) has taken + // every one of its answers out of this path, so anything arriving here is + // unsolicited and the guess can only ever be wrong. Dropping it loses + // nothing and stops the theft. + if (this._correlates) { + console.warn('[MeshBay] unsolicited', msg.type, '— dropped (pending:', + this._pending.size, ')'); + return; + } + + // Only a node too old to stamp anything reaches here, where arrival order + // is still the only thing there is. Kept deliberately, and no wider than + // it was: the alternative for such a node is that half the protocol + // (device_list_result, join_result, the handshake's own replies) reaches + // nobody at all. const oldest = this._pending.entries().next(); if (!oldest.done) { const [, handler] = oldest.value; |