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/tests/test_chat_send.py | |
| 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/tests/test_chat_send.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_send.py | 94 |
1 files changed, 63 insertions, 31 deletions
diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py index 4224fdb..8b098c2 100644 --- a/packages/meshbay-hub/tests/test_chat_send.py +++ b/packages/meshbay-hub/tests/test_chat_send.py @@ -1,21 +1,27 @@ """ -Sending a chat message must come back. +Sending a chat message must come back — accepted or refused. -The node answers a chat message with a bare `{"type": "ack"}` — no request id, -no type of its own — so `_dispatch` had nothing to match it on and left it to -the arrival-order guess at the end of the function. That guess is wrong as soon -as anything else this browser asked for is still waiting: the ack was handed to -*that* request, and the send waited out `_sendAndWait`'s 30s timeout. Since the -composer is disabled while a send is in flight, the Chat tab stopped taking -clicks and keys, the message never appeared — and it was there on the next -visit, because the node had stored it and answered. +The node's replies to a chat message name no request. The acceptance is a bare +`{"type": "ack"}`; the refusal is a bare `{"type": "error"}`, and it is not a +special case — `_dispatch_message`'s catch-all answers *every* failure that +way, and 238 of webrtc_server.py's 240 error sends name nothing either. So +`_dispatch` had nothing to match either reply on and left both to the +arrival-order guess at the end of the function. -An outstanding request is the ordinary case, not a rare one: the node refuses -an unknown file_id with a bare `error`, which names no request either, so a -Videos tab that asked about a file the index no longer has leaves a -`media_meta_req` in `_pending` for a full 30s. +That guess is wrong as soon as anything else this browser asked for is still +waiting, which is the ordinary case rather than a rare one: a `music_meta_req` +sits in `_pending` for as long as the third-party lookup behind it takes, and +that was measured live at over 100 seconds with the service failing. The reply +went to *that* request, and the send waited out `_sendAndWait`'s 30s timeout. +Since the composer is disabled while a send is in flight, the Chat tab stopped +taking clicks and keys, and the message never appeared. -None of that is visible in `chat-app.js`, where every line is correct, so this +The ack half was fixed by matching on request type. The refusal half could not +be: an `error` has no type of its own to match on. `req_id` is what closed it — +the caller's id, stamped on the reply by the node — so this now drives both +shapes of answer. + +None of it is visible in `chat-app.js`, where every line is correct, so this drives the real panel over the real transport in a browser rather than reading either source. """ @@ -39,35 +45,61 @@ def probe(): run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180) assert run.returncode == 0, run.stderr.decode()[-2000:] data = json.loads(run.stdout.decode()) - return data, {s["label"]: s for s in data["steps"]} + steps = {sc["name"]: {s["label"]: s for s in sc["steps"]} + for sc in data["scenarios"]} + return data, steps + +@pytest.mark.parametrize("reply", ["ack", "error"]) +def test_the_composer_comes_back(probe, reply): + """The one thing a person sees: the tab is usable again. -def test_the_composer_comes_back(probe): - """The one thing a person sees: the tab is usable again.""" + Both answers have to release it. A refusal that reaches nobody leaves the + composer disabled exactly as long as an acceptance that reaches nobody — + the composer is not waiting for good news, it is waiting for an answer. + """ _, steps = probe - assert steps["stale request pending"]["composerDisabled"] is False, ( + assert steps[reply]["older request pending"]["composerDisabled"] is False, ( "the composer was already unusable before the send") - assert steps["after send"]["composerDisabled"] is False, ( + assert steps[reply]["after send"]["composerDisabled"] is False, ( "the composer is still disabled well inside the 30s request timeout -- " "the send never came back, which is what reads as a frozen Chat tab") -def test_the_message_is_displayed(probe): +def test_an_accepted_message_is_displayed(probe): """A sent message appears at once, not on the next visit to the tab.""" _, steps = probe - before = steps["stale request pending"]["bubbles"] - assert steps["after send"]["bubbles"] == before + 1, ( + before = steps["ack"]["older request pending"]["bubbles"] + assert steps["ack"]["after send"]["bubbles"] == before + 1, ( "the message was not added to the conversation") - assert steps["after send"]["lastText"] == "hello" - assert steps["after send"]["composerValue"] == "", ( + assert steps["ack"]["after send"]["lastText"] == "hello" + assert steps["ack"]["after send"]["composerValue"] == "", ( "the text came back into the composer, so the send was treated as failed") -def test_the_ack_is_not_handed_to_another_request(probe): - """The other half of the same defect: whatever was waiting got the ack and - carried on with a reply to a question it never asked.""" +def test_a_refused_message_is_not_displayed_as_sent(probe): + """The other direction, and the one routing this correctly makes possible. + + While a refusal reached the wrong caller it did not matter what `sendChat` + would have done with it. Now that it arrives, a message the node rejected + must not appear in the conversation as though it had been stored — it must + come back into the composer, where a person can see it did not go. + """ + _, steps = probe + before = steps["error"]["older request pending"]["bubbles"] + assert steps["error"]["after send"]["bubbles"] == before, ( + "a refused message was added to the conversation anyway") + assert steps["error"]["after send"]["composerValue"] == "hello", ( + "the refused text was dropped instead of being handed back") + + +@pytest.mark.parametrize("reply", ["ack", "error"]) +def test_the_reply_is_not_handed_to_another_request(probe, reply): + """The other half of the same defect: whatever was waiting got the reply + and carried on with an answer to a question it never asked.""" data, _ = probe - assert "media_meta resolved with ack" not in data["log"], ( - "the chat ack was routed to the pending media_meta_req -- that request " - "now believes it has an answer, and the chat send is waiting for a " - "reply that already arrived") + stolen = [line for line in data["log"] if line.startswith(f"{reply}: music_meta")] + assert not stolen, ( + f"the chat {reply} was routed to the pending music_meta_req ({stolen}) -- " + "that request now believes it has an answer, and the chat send is " + "waiting for a reply that already arrived") |