#!/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 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. A third scenario, `reconnect`, is about the *other* way this tab freezes, and the one no timeout ends: `reconnect` the connection is untouched, but its **device identity** is cleared and settled again, which is what every reconnect does to it — `connect()` drops it on the way in and `device_hello` restores it on the way out. The composer gates on that identity, and used to read it off the transport during render, where a ref changing re-renders nothing: it latched shut on whatever unrelated re-render came next (a message arriving) and had no event that would open it again. Since MNP 2.0 a send also has to **seal and sign for real** before it goes anywhere, so this drives `chatKeys()`, `openGroup`, `sealChat` and a genuine Ed25519 signature rather than a model of any of them. The device key is generated in the page — `signBytes` imports a pkcs8 key and WebCrypto will not be fooled by a stand-in — and the `chat_keys_resp` the stub answers with is sealed **by the shipped Python**, because a payload the page built itself would prove only that the page agrees with the page. That path found two defects the moment it first ran, neither visible in any source file: `chat_keys_resp` was routed by arrival order and handed to the older pending request (this defect, in a message type that did not exist when the probe was written), and `_asText` had been deleted along with an unrelated helper beside it — its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. 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 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 GROUP_ID = "g" * 32 GEK = bytes.fromhex("5a" * 32) EPOCH_KEY = bytes.fromhex("7c" * 32) def _page() -> str: """ The page, with a real sealed `chat_keys_resp` baked in. Sealed here, by the shipped Python, rather than assembled in the browser: msgpack is private to transport.js and exported to nothing, and a payload the page built itself would prove only that the page agrees with the page. """ import msgpack # noqa: F401 (imported for the failure it gives if absent) from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal sealed = seal(GEK, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, {"epochs": [{"epoch": 1, "key": EPOCH_KEY}], "current": 1}) return (PAGE_TEMPLATE .replace("__GROUP_ID__", GROUP_ID) .replace("__GEK_HEX__", GEK.hex()) .replace("__KEYS_NONCE_HEX__", sealed["nonce"].hex()) .replace("__KEYS_CT_HEX__", sealed["ct"].hex())) PAGE_TEMPLATE = r"""

a group

""" class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass def do_POST(self): # Only the measurement. The `reconnect` scenario drives the real # `connect()`, which POSTs its offer to the hub's signaling endpoint -- # there is no hub here, and answering that with 204 both swallowed the # measurement and put the machine's own SDP (public address included) # into the probe's output. It gets a 404, which is what makes connect() # stop where the scenario needs it to. if self.path != "/log": self.send_response(404) self.end_headers() return 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() # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu) # outlive terminate() on the parent by a moment and go on writing into # the profile. rmtree then walks a directory that gains a file between # its readdir and its rmdir and raises "Directory not empty" -- which # failed the probe, which failed every test in the file, intermittently # and for a reason nowhere near the chat code they were testing. A few # bytes left in a throwaway profile are harmless; failing the run is not. with tempfile.TemporaryDirectory( ignore_cleanup_errors=True) 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() proc.wait() 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())