aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 12:13:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 12:13:41 +0200
commit691c6ba4ef51085c89aeddbcabd5c733861eb56b (patch)
tree4e44451043309ed50af5345c34762c5f87150d86 /packages/meshbay-hub/tests/harness
parent7d995ea52d8321495630dd95851626b9664ce133 (diff)
downloadmeshbay-691c6ba4ef51085c89aeddbcabd5c733861eb56b.tar.gz
fix(hub): route the chat ack to the request that asked for it
Typing a message froze the Chat tab: the composer stopped taking clicks and keystrokes, the message never appeared, and it was there all along on the next visit to the tab. 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 the moment anything else this browser asked for is still waiting: the ack went to *that* request, and the chat send waited out _sendAndWait's own 30s timeout. Since the composer is disabled while a send is in flight, that reads as a frozen tab; the node had stored the message and answered, into somebody else's promise. 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 and so reaches none, leaving the Videos tab's media_meta_req in _pending for the full 30s. That is the one that was live when this was found. - `ack` is now matched by request type: chat_msg, or the keypair-bundle store and delete, which name themselves in `detail`. A node naming neither still has its reply placed rather than dropped. Every line of chat-app.js is correct and every routed message in transport.js is routed correctly -- the defect is in the seam, so tests/harness/ chat_send_probe.py drives the two together: the real ChatPanel over the real MeshBayTransport, with only the DataChannel replaced by a stand-in answering what the node answers. test_chat_send.py asserts against it, and with the fix reverted all three of its tests fail on the three visible halves of the defect -- the composer still disabled, the message absent, and the ack resolving the unrelated request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py206
1 files changed, 206 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py
new file mode 100644
index 0000000..f1cc191
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""
+Does sending a chat message come back?
+
+Mounts **the real `ChatPanel` over the real `MeshBayTransport`** — both shipped
+modules, neither a model of the other — and types a message into the composer
+the way a person does. Only the DataChannel is replaced, by a stand-in that
+answers what the node answers.
+
+It exists because the defect it was written for is invisible to a structural
+test and to `chat_scroll_probe.py` alike: nothing in `chat-app.js` is wrong,
+and the transport routes every message it knows how to route. The node's reply
+to a chat message is a bare `{"type": "ack"}` that names no request, so it fell
+through to `_dispatch`'s arrival-order guess and was handed to whichever
+request happened to be waiting — a `media_meta_req` from the Videos tab, say,
+which the node never answered because it refused the file_id with a bare
+`error` that named no request either. The chat send then waited out its own 30s
+timeout with the composer disabled, so the tab looked frozen and the message
+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.
+"""
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8755
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head>
+<body>
+<div class="layout"><div class="main">
+ <div class="group-header"><div><h2>a group</h2></div></div>
+ <div class="group-tabs"><button class="group-tab active">Chat</button></div>
+ <div id="root"></div>
+</div></div>
+<script src="/transport.js"></script>
+<script type="module">
+import { html, render, useRef } from '/vendor/htm-preact.js';
+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++) {
+ history.push({ id: 'm' + i, sender_id: 'someone', sender_name: 'someone',
+ 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);
+ }
+};
+
+function Host() {
+ 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();
+ c.focus();
+ // Preact reads e.target.value on input, so the native setter has to run.
+ Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
+ .set.call(c, text);
+ c.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+(async () => {
+ 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));
+ await wait(100);
+ snap('stale request pending');
+
+ typeInto('hello');
+ await wait(100);
+ composer().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
+ // now is the freeze, not a slow node.
+ await wait(1500);
+ snap('after send');
+
+ fetch('/log', { method: 'POST', body: JSON.stringify(out) });
+})();
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ RECORDS.append(json.loads(
+ self.rfile.read(int(self.headers["Content-Length"])).decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def do_GET(self):
+ if self.path == "/":
+ body, ctype = PAGE.encode(), "text/html; charset=utf-8"
+ else:
+ path = (STATIC / self.path.lstrip("/")).resolve()
+ if not str(path).startswith(str(STATIC)) or not path.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ body = path.read_bytes()
+ ctype = ("text/css" if path.suffix == ".css"
+ else "text/javascript" if path.suffix == ".js"
+ else "application/octet-stream")
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory() as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,800",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())