diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_send_probe.py | 145 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_send.py | 94 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_index_seal_client.py | 30 |
3 files changed, 181 insertions, 88 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index f1cc191..98983f3 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -20,8 +20,25 @@ never appeared, while the node had stored it all along. chat_send_probe.py -Prints JSON: `steps`, the state of the panel at each stage, and `log`, what the -transport sent and how the deliberately-unanswered request ended up. +It now drives two shapes of reply, because there were two ways for one to go +astray and only the first was ever fixed: + + `ack` the node accepts the message and answers `{"type": "ack"}`, which + names no request. Routed by request type since 2026-08-30. + `error` the node *refuses* it — every failure in `_dispatch_message` ends at + one catch-all sending `{"type": "error", "detail": "Request failed"}`, + and 238 of this module's 240 error sends name nothing either. That + reply reached no caller at all: it went to whatever request happened + to be waiting, and the send sat out its own 30s timeout with the + composer disabled. + +Both are run with an older request already pending — the condition that turns +"guess by arrival order" from usually-right into wrong — and both must come +back inside a second and a half. + +Prints JSON: `scenarios`, the state of the panel at each stage of each, and +`log`, what the transport sent and how the deliberately-unanswered request +ended up. """ import http.server import json @@ -54,13 +71,6 @@ import { ChatPanel } from '/chat-app.js'; const log = []; window.addEventListener('error', e => log.push('error: ' + e.message)); -// The real transport, with only the channel replaced: _send takes the plain -// object _sendAndWait built, so the framing and msgpack are the only things -// skipped — every pending entry, key and dispatch path below is the shipped one. -const tp = new window.MeshBayTransport('', 'token'); -tp._connected = true; -tp._channel = { readyState: 'open', send() {} }; - const now = Date.now() / 1000; const history = []; for (let i = 0; i < 5; i++) { @@ -68,50 +78,45 @@ for (let i = 0; i < 5; i++) { payload: 'message ' + i, timestamp: now - (5 - i) * 60 }); } -// Stands in for the node, answering exactly what webrtc_server.py answers. -// media_meta_req is answered with nothing at all, which is what a refusal -// amounts to for the request that asked: `_do_media_meta_request` sends a bare -// `error` for a file_id the index does not have, and a bare error names no -// request, so it reaches none. -tp._send = (obj) => { - log.push('sent ' + obj.type); - if (obj.type === 'chat_hist') { - setTimeout(() => tp._dispatch( - { type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }), 10); - } else if (obj.type === 'chat_msg') { - setTimeout(() => tp._dispatch({ type: 'ack', v: '0.14' }), 10); - } -}; +const wait = ms => new Promise(r => setTimeout(r, ms)); -function Host() { +// Stands in for the node, answering what webrtc_server.py answers — including +// stamping the reply with the id of the request it is answering, which is what +// `_send` does there. `chatReply` is the only difference between the two runs. +function makeTransport(chatReply) { + // The real transport, with only the channel replaced: _send takes the plain + // object _sendAndWait built, so the framing and msgpack are the only things + // skipped — every pending entry, key and dispatch path below is the shipped + // one. + const tp = new window.MeshBayTransport('', 'token'); + tp._connected = true; + tp._channel = { readyState: 'open', send() {} }; + tp._send = (obj) => { + log.push('sent ' + obj.type); + const answer = (reply) => setTimeout( + () => tp._dispatch({ ...reply, req_id: obj.req_id }), 10); + if (obj.type === 'chat_hist') { + answer({ type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }); + } else if (obj.type === 'chat_msg') { + answer(chatReply); + } + // music_meta_req is answered by nothing at all, on purpose: the node holds + // one open for as long as the third-party lookup behind it takes, which + // was measured at over 100 seconds with that service failing. It is the + // older pending request every scenario here needs. + }; + return tp; +} + +function Host({ tp }) { const transportRef = useRef(tp); const gekRef = useRef(null); return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef} username="me" entries=${[]} status="connected" />`; } -render(html`<${Host} />`, document.getElementById('root')); - -const out = { steps: [], log }; -const composer = () => document.querySelector('.chat-input'); - -function snap(label) { - const c = composer(); - out.steps.push({ - label, - bubbles: document.querySelectorAll('.chat-bubble').length, - lastText: [...document.querySelectorAll('.chat-text')].pop()?.textContent ?? null, - // What a frozen tab actually is: the composer is disabled for as long as - // a send is in flight. - composerDisabled: c ? c.disabled : null, - composerValue: c ? c.value : null, - pending: tp._pending.size, - }); -} - -const wait = ms => new Promise(r => setTimeout(r, ms)); -function typeInto(text) { - const c = composer(); +function typeInto(root, text) { + const c = root.querySelector('.chat-input'); c.focus(); // Preact reads e.target.value on input, so the native setter has to run. Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value') @@ -119,22 +124,42 @@ function typeInto(text) { c.dispatchEvent(new Event('input', { bubbles: true })); } -(async () => { +async function runScenario(name, chatReply) { + const root = document.createElement('div'); + document.getElementById('root').appendChild(root); + const tp = makeTransport(chatReply); + render(html`<${Host} tp=${tp} />`, root); + + const steps = []; + const snap = (label) => { + const c = root.querySelector('.chat-input'); + steps.push({ + label, + bubbles: root.querySelectorAll('.chat-bubble').length, + lastText: [...root.querySelectorAll('.chat-text')].pop()?.textContent ?? null, + // What a frozen tab actually is: the composer is disabled for as long as + // a send is in flight. + composerDisabled: c ? c.disabled : null, + composerValue: c ? c.value : null, + pending: tp._pending.size, + }); + }; + await wait(500); snap('arrived'); - // The Videos tab asked about a file a moment ago and is still waiting. Any - // unanswered request will do; this is the one that was live when the defect - // was found. - tp.fetchMediaMeta('a-file-the-node-refused') - .then(m => log.push('media_meta resolved with ' + m.type), - e => log.push('media_meta rejected: ' + e.message)); + // The Music tab asked about a track and is still waiting on the node, which + // is waiting on something else. Any older unanswered request will do; this + // is the one that was live when the defect was found. + tp.fetchMusicMeta('a-track-the-node-is-slow-about') + .then(m => log.push(name + ': music_meta resolved with ' + m.type), + e => log.push(name + ': music_meta rejected: ' + e.message)); await wait(100); - snap('stale request pending'); + snap('older request pending'); - typeInto('hello'); + typeInto(root, 'hello'); await wait(100); - composer().dispatchEvent(new KeyboardEvent('keydown', + root.querySelector('.chat-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); // Far short of _sendAndWait's 30s timeout: a send that has not come back by @@ -142,6 +167,14 @@ function typeInto(text) { await wait(1500); snap('after send'); + return { name, steps }; +} + +(async () => { + const out = { scenarios: [], log }; + out.scenarios.push(await runScenario('ack', { type: 'ack', v: '0.14' })); + out.scenarios.push(await runScenario( + 'error', { type: 'error', detail: 'Request failed' })); fetch('/log', { method: 'POST', body: JSON.stringify(out) }); })(); </script></body></html>""" 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") diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py index ca2c7a2..e1135da 100644 --- a/packages/meshbay-hub/tests/test_index_seal_client.py +++ b/packages/meshbay-hub/tests/test_index_seal_client.py @@ -45,10 +45,15 @@ def _frame(msg: dict) -> str: return (struct.pack(">I", len(body)) + body).hex() -def _sync_frame(gek: bytes, *names: str, version: int = 3) -> str: +def _sync_frame(gek: bytes, *names: str, version: int = 3, + req_id: int | None = None) -> str: payload = {"version": version, "entries": [_entry(n) for n in names], "dirs": ["library"], "roots": [{"name": "library"}]} + # `req_id` is what a current node stamps on a *reply*; the push it sends a + # newly connected peer answers no request and carries none. Both shapes + # arrive here, and only one of them may resolve a waiting fetchIndex. return _frame({"type": "index_sync", "v": "1.0", "group_id": GROUP, + **({"req_id": req_id} if req_id is not None else {}), **seal(gek, PURPOSE_INDEX, "index_sync", GROUP, payload)}) @@ -141,3 +146,26 @@ def test_deltas_are_applied_in_arrival_order(): assert [e["additions"][0] for e in out["events"][1:]] == [ "added-0.mkv", "added-1.mkv", "added-2.mkv"] assert [e["base_version"] for e in out["events"][1:]] == [3, 4, 5] + + +def test_a_sealed_reply_is_opened_before_it_reaches_its_caller(): + """A stamped index_sync must not be short-circuited by its `req_id`. + + Every other reply a node stamps is resolved straight out of the pending + map, which is the whole point of the id. An index message cannot be: it is + sealed, opening it is asynchronous, and `_dispatch` is not. Handing it over + on the strength of the id alone gives `fetchIndex` the envelope — nonce and + ciphertext, no entries — and never calls `onIndexSync` at all. + + `req_id` is 0 here because it is the transport's first request, and a + falsy id is exactly the one a presence check gets wrong. + """ + out = _run([_sync_frame(GEK, "a-film.mkv", req_id=0)]) + + assert [e["event"] for e in out["events"]] == ["index_sync"], ( + "the consumer was never told about an index that arrived as a reply") + assert out["events"][0]["entries"] == ["a-film.mkv"] + assert out["events"][0]["hasCiphertext"] is False + assert out["fetchIndex"]["state"] == "resolved" + assert out["fetchIndex"]["entries"] == ["a-film.mkv"], ( + "the caller was handed the sealed envelope instead of the index") |