From 691c6ba4ef51085c89aeddbcabd5c733861eb56b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 3 Sep 2026 12:13:41 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_01GFF4BL8VSKrghkSLzCrTVs --- .../meshbay-hub/tests/harness/chat_send_probe.py | 206 +++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/chat_send_probe.py (limited to 'packages/meshbay-hub/tests/harness') 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""" + + +
+

a group

+
+
+
+ +""" + + +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()) -- cgit v1.2.3