aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--CLAUDE.md21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js36
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py206
-rw-r--r--packages/meshbay-hub/tests/test_chat_send.py73
4 files changed, 336 insertions, 0 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 4bf4bb5..0197d1e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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")