aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/chat_send_probe.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 16:37:23 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 16:37:23 +0200
commit391db2f2197b5f7fbdba0c918a24830a5cbe6ee4 (patch)
tree570480f075de3b8f6609675b9ced67815771d961 /packages/meshbay-hub/tests/harness/chat_send_probe.py
parent56776934c2ddfbad4884b252da6f5fb25864b8db (diff)
downloadmeshbay-391db2f2197b5f7fbdba0c918a24830a5cbe6ee4.tar.gz
fix(spa): a reconnect must give the Chat composer back
The Chat tab froze about every other day — the textbox stopped taking clicks — and it never recovered on its own: no timeout ends this one, only leaving the group or restarting the client. A console dump of a session it happened in ruled out everything it could and named nothing. What that dump established was almost entirely negative, and that was the useful part. No `Response timeout`, no `unsolicited`/`unrouted`/`with nothing waiting` — so the 2026-08-30 routing defect, which produces this exact symptom for thirty seconds, had not recurred. No `PC state: disconnected|failed`, no second ICE cycle, no `Reconnected after N attempt(s)` — so the connection was alive and untouched. The freeze was in the page, and no path that logs anything had run. The composer is `disabled=${sending || cannotSend}`, and `cannotSend` was `transport.connected && !transport.devicePk`, read off a **ref** during render. `devicePk` is settled inside connect(), so every reconnect clears it and settles it again; a ref changing re-renders nothing, and nothing else announced it. So the panel went disabled on whatever unrelated re-render came next — a message arriving — long after the identity was actually lost, and had no event that would open it again. group-page.js never touches `status` after 'connected', and `onReconnected` is claimed by video-player.js, so there was no second chance. It was silent as well as sticky. `_announceDevice` had three exits that wrote `devicePk` without a word: two early returns that left the *previous* connection's value standing, and a reply that is not `device_hello_ack` — an `error` reply does not throw, so the `.catch()` at the call site never saw it. Reproduced in chat_send_probe.py, which mounts the real ChatPanel over the real transport: with the old code, identity cleared leaves the composer open, an arriving message latches it shut, and restoring the identity does not reopen it. Every write to `devicePk` now goes through `_setDevicePk(pk, why)`, which logs, traces and calls `onDeviceIdentity`; group-page holds the answer as state and ChatPanel takes it as `deviceReady`. Defaulting that prop to `true` fails open — a wiring mistake here must not be able to leave anyone with a dead textbox. Two things found on the same path and fixed with it. `_send` throwing inside _sendAndWait's executor left the pending entry and its 30s timer behind, so a request that never reached the wire still logged a "Response timeout" half a minute later. And the instrumentation this was meant to be diagnosed with (3be8bd2) writes to localStorage behind ?trace=1, not to the console, so the dump could not have carried it: the two lines that decide the composer's state are now logged unconditionally, and MeshBayTrace gains `record` so the composer writes into the same timeline as the channel events. Hub suite 2264 passed, 4 skipped. chat_send_probe.py gains a `reconnect` scenario and test_chat_send.py four cases, each checked against the unfixed source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J
Diffstat (limited to 'packages/meshbay-hub/tests/harness/chat_send_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py63
1 files changed, 60 insertions, 3 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py
index 28635b0..2df12e3 100644
--- a/packages/meshbay-hub/tests/harness/chat_send_probe.py
+++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py
@@ -36,6 +36,18 @@ 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
@@ -110,7 +122,7 @@ PAGE_TEMPLATE = r"""<!doctype html><html><head><meta charset=utf-8>
<script src="/keyderive.js"></script>
<script src="/transport.js"></script>
<script type="module">
-import { html, render, useRef } from '/vendor/htm-preact.js';
+import { html, render, useRef, useState, useEffect } from '/vendor/htm-preact.js';
import { ChatPanel } from '/chat-app.js';
const log = [];
@@ -192,11 +204,23 @@ function makeTransport(name, chatReply) {
return tp;
}
+// Stands in for group-page.js the way the stub above stands in for the node,
+// and for the same reason: what is under test is the seam between them. These
+// are its three lines — state, the callback wired before connect() runs, and
+// the prop — because the defect was that ChatPanel read `devicePk` off the
+// transport during render instead, and a ref changing re-renders nothing.
function Host({ tp }) {
const transportRef = useRef(tp);
const gekRef = useRef(null);
+ // Seeded from the transport because makeTransport hands over a connection
+ // whose handshake is already done; in the page the callback below is what
+ // sets it, since it is wired before connect() and connect() is where
+ // device_hello runs.
+ const [deviceReady, setDeviceReady] = useState(!!tp.devicePk);
+ useEffect(() => { tp.onDeviceIdentity = (ok) => setDeviceReady(ok); }, [tp]);
return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef}
- username="me" userId="user-me" entries=${[]} status="connected" />`;
+ username="me" userId="user-me" entries=${[]} status="connected"
+ deviceReady=${deviceReady} />`;
}
function typeInto(root, text) {
@@ -208,7 +232,7 @@ function typeInto(root, text) {
c.dispatchEvent(new Event('input', { bubbles: true }));
}
-async function runScenario(name, chatReply) {
+async function runScenario(name, chatReply, duringSession) {
const root = document.createElement('div');
document.getElementById('root').appendChild(root);
const tp = makeTransport(name, chatReply);
@@ -225,6 +249,10 @@ async function runScenario(name, chatReply) {
// a send is in flight.
composerDisabled: c ? c.disabled : null,
composerValue: c ? c.value : null,
+ // Which of the composer's two reasons it is. "Disabled" alone was all
+ // the field report could say, and it is the half that does not identify
+ // the defect.
+ composerPlaceholder: c ? c.placeholder : null,
pending: tp._pending.size,
});
};
@@ -241,6 +269,8 @@ async function runScenario(name, chatReply) {
await wait(100);
snap('older request pending');
+ if (duringSession) await duringSession(tp, snap);
+
typeInto(root, 'hello');
await wait(100);
root.querySelector('.chat-input').dispatchEvent(new KeyboardEvent('keydown',
@@ -259,6 +289,33 @@ async function runScenario(name, chatReply) {
out.scenarios.push(await runScenario('ack', { type: 'ack', v: '0.14' }));
out.scenarios.push(await runScenario(
'error', { type: 'error', detail: 'Request failed' }));
+ // The connection survives, the device identity does not — which is exactly
+ // what a reconnect does: connect() clears it on the way in and device_hello
+ // settles it again on the way out. Nothing about the channel changes, so
+ // nothing else in the page moves, and the composer has to follow this on its
+ // own or it never comes back.
+ out.scenarios.push(await runScenario(
+ 'reconnect', { type: 'ack', v: '0.14' },
+ async (tp, snap) => {
+ tp._setDevicePk('', 'new connection');
+ await wait(100);
+ snap('device identity cleared');
+ // A live message arrives — the ordinary thing that re-renders this
+ // panel, and the step that made the old defect permanent. The composer
+ // read `devicePk` off the transport during render, so it went disabled
+ // *here*, on an unrelated re-render, long after the identity was
+ // actually lost; and since nothing re-rendered it when the identity came
+ // back, it stayed that way for the rest of the session.
+ if (tp._onChat) {
+ tp._onChat({ id: 'live-1', sender_id: 'someone', sender_name: 'someone',
+ payload: 'still there?', timestamp: now, verified: true });
+ }
+ await wait(100);
+ snap('a message arrived meanwhile');
+ tp._setDevicePk(DEVICE_PK_B64, 'device_hello_ack');
+ await wait(100);
+ snap('device identity restored');
+ }));
fetch('/log', { method: 'POST', body: JSON.stringify(out) });
})();
</script></body></html>"""