diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-03 12:13:41 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-03 12:13:41 +0200 |
| commit | 691c6ba4ef51085c89aeddbcabd5c733861eb56b (patch) | |
| tree | 4e44451043309ed50af5345c34762c5f87150d86 /packages/meshbay-hub/tests/harness/chat_send_probe.py | |
| parent | 7d995ea52d8321495630dd95851626b9664ce133 (diff) | |
| download | meshbay-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/chat_send_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_send_probe.py | 206 |
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()) |