aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py145
-rw-r--r--packages/meshbay-hub/tests/test_chat_send.py94
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py30
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")