diff options
| -rw-r--r-- | CLAUDE.md | 21 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 36 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_send_probe.py | 206 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_send.py | 73 |
4 files changed, 336 insertions, 0 deletions
@@ -452,6 +452,26 @@ anything that assumes one key per person. the correction once and write nothing in the steady state. And a scroll position that must survive a gesture has to be released **by the gesture** (`wheel`/`touchmove`/`pointerdown`), never by the `scroll` event alone +- **A reply that names nothing is routed by luck.** 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 fell through to its + arrival-order guess, which hands a reply to whichever request happens to be + oldest. That is wrong the moment anything else this browser asked for is + still waiting, and one *is*: 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 30 s. The ack went + to that, the send waited out its own timeout, and because the composer is + disabled while a send is in flight, **typing a message froze the Chat tab**: + no click, no keystroke, no message — and the message there all along on the + next visit to the tab, since the node had stored it and answered. Every line + of `chat-app.js` is correct and every routed message in `transport.js` is + routed correctly; the defect is in the seam, which is why + `tests/harness/chat_send_probe.py` drives the two together. `ack` is now + matched by request type (`chat_msg`, or the keypair-bundle store/delete that + name themselves in `detail`). Anything left to the "oldest pending" guess is + a latent version of this bug: a request type deserves a key, and a reply + deserves something to key it by + - **A refusal that never rejects.** Denying Chromium's `fullscreen` permission does not make `requestFullscreen()` throw — the promise never settles. The deny-everything handler was written from a true sentence ("nothing here needs @@ -781,6 +801,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Resume position | `static/video-player.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch | | Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values | | Chat scrolling, measured | `tests/harness/chat_scroll_probe.py` | Mounts the real `ChatPanel` in Chrome and reads a conversation back. Answers "can the reader scroll up" and "does the panel resize itself"; `test_chat_scroll_bottom.py` only pins the source's shape | +| Chat sending, measured | `tests/harness/chat_send_probe.py` | Mounts the real `ChatPanel` over the real `MeshBayTransport` (only the DataChannel is a stand-in) and types a message. Answers "does the send come back" — the freeze it was written for lives in the seam between the two, so neither source shows it | | Group landing tab, measured | `tests/harness/group_tab_probe.py` | Renders the real `GroupPage` against a stub node answering a chosen `enabled_apps`, and reads the tab bar back. The landing tab is picked from a preference at mount; the app list arrives from the handshake later, and the two can disagree | | Session renewal (browser) | `static/hub-client.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays. Moved out of app.js in the 2026-08-23 split | | Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f7ae256..62ca2d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -2310,6 +2310,42 @@ class MeshBayTransport { return; } + // A bare `ack` answers three requests: sending a chat message, and storing + // or withdrawing a keypair bundle. The bundle acks name themselves in + // `detail`; the chat one carries nothing at all, so it was left to the + // arrival-order guess below — and that guess is wrong whenever anything + // else this browser asked for is still waiting. The ack went to *that* + // request, and the chat send waited out its own 30s timeout instead. + // + // What that looked like, and what this was found from: typing a message + // froze the Chat tab. The composer is disabled while a send is in flight, + // so it stopped accepting clicks and keys; the message never appeared; + // and it was there all along on the next visit to the tab, because the + // node had stored it and answered — into somebody else's promise. One + // unanswered request is enough, and an unanswered request is ordinary + // rather than exceptional: the node refuses an unknown file_id with a + // bare `error`, which names no request either and so reaches none, and a + // Videos tab that asked about a file the index no longer has leaves a + // `media_meta_req` sitting in `_pending` for the full 30s. + if (msg.type === 'ack') { + const named = msg.detail === 'keypair_bundle_stored' ? 'keypair_bundle_store' + : msg.detail === 'keypair_bundle_deleted' ? 'keypair_bundle_delete' + : null; + // Without a `detail` it is a chat ack — but a node that names neither + // is answering whichever of the three this browser has outstanding, so + // the reply is placed rather than dropped. + const wanted = named + ? [named] + : ['chat_msg', 'keypair_bundle_store', 'keypair_bundle_delete']; + for (const want of wanted) { + for (const [, handler] of this._pending) { + if (handler._reqType === want) { handler.resolve(msg); return; } + } + } + console.warn('[MeshBay] ack (detail=', msg.detail, ') with nothing waiting'); + return; + } + // Everything above is routed by something in the message. What is left is // matched by arrival order, which is only ever a guess — and a wrong guess // here hands one request's answer to another, which then waits for a reply 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()) diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py new file mode 100644 index 0000000..4224fdb --- /dev/null +++ b/packages/meshbay-hub/tests/test_chat_send.py @@ -0,0 +1,73 @@ +""" +Sending a chat message must come back. + +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. + +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. + +None of that 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. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "chat_send_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "chat-app.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@pytest.fixture(scope="module") +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"]} + + +def test_the_composer_comes_back(probe): + """The one thing a person sees: the tab is usable again.""" + _, steps = probe + assert steps["stale request pending"]["composerDisabled"] is False, ( + "the composer was already unusable before the send") + assert steps["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): + """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, ( + "the message was not added to the conversation") + assert steps["after send"]["lastText"] == "hello" + assert steps["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.""" + 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") |