From 391db2f2197b5f7fbdba0c918a24830a5cbe6ee4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 9 Sep 2026 16:37:23 +0200 Subject: fix(spa): a reconnect must give the Chat composer back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019GXmScYB1uR29YCt74si9J --- .../meshbay-hub/tests/harness/chat_send_probe.py | 63 +++++++++++++++++++- packages/meshbay-hub/tests/test_chat_send.py | 68 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) (limited to 'packages/meshbay-hub/tests') 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""" """ diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py index 5db8f26..d442383 100644 --- a/packages/meshbay-hub/tests/test_chat_send.py +++ b/packages/meshbay-hub/tests/test_chat_send.py @@ -24,6 +24,13 @@ shapes of answer. None of it 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. + +Since 2026-09-09 it covers a **second** way the tab freezes, found from a field +report and reproduced here: a reconnect clears the connection's device identity +and settles it again, and the composer gates on that. Nothing announced the +change, so the panel latched shut on an unrelated re-render and had no event +that would open it again. Unlike the routing defect above, no timeout ends it — +only leaving the group or restarting the client does. """ import json import shutil @@ -147,6 +154,67 @@ def test_the_chat_keys_answer_is_not_handed_to_another_request(probe): "send then waits out its own 30s timeout with the composer disabled") +def test_a_reconnect_gives_the_composer_back(probe): + """ + The other way a Chat tab freezes, and the one no timeout ever ends. + + A send that goes astray holds the composer for 30s. This holds it for the + rest of the session: `devicePk` — "this connection identified a device to + the node" — is settled inside `connect()`, so every reconnect clears it and + re-settles it, and the composer gates on it. Nothing announced the change, + so the panel went disabled on whatever unrelated re-render happened next + (a message arriving) and had no event that would bring it back. The + connection stayed perfectly healthy throughout, no request ever timed out, + and nothing reached the console: a field report of exactly this arrived + with a full console dump that could not say what had happened. + + The three states below are the whole claim: it closes when the identity + goes, it stays closed while it is gone, and it **opens again** when the + identity comes back. + """ + _, steps = probe + sc = steps["reconnect"] + assert sc["older request pending"]["composerDisabled"] is False, ( + "the composer was already unusable before the reconnect") + assert sc["device identity cleared"]["composerDisabled"] is True, ( + "the composer stayed open with no device identity to seal with -- the " + "send would be refused with no reason on screen") + assert sc["a message arrived meanwhile"]["composerDisabled"] is True, ( + "an unrelated re-render changed the answer, which means the answer was " + "never being derived from anything the panel was told about") + assert sc["device identity restored"]["composerDisabled"] is False, ( + "the composer never came back after the reconnect re-identified the " + "device -- this is the freeze that no timeout ends and that only " + "leaving the group or restarting the client clears") + + +def test_the_closed_composer_says_which_of_its_two_reasons_it_is(probe): + """ + A disabled textbox is one symptom with two causes — a send in flight, or no + device identity — and telling them apart is what the field report could not + do. The placeholder is where a person reads the difference. + """ + _, steps = probe + sc = steps["reconnect"] + assert sc["device identity cleared"]["composerPlaceholder"] \ + == "chat.encrypted_cannot_send", ( + "a closed composer offered no reason for being closed") + assert sc["device identity restored"]["composerPlaceholder"] \ + == "chat.placeholder" + + +def test_sending_works_again_after_a_reconnect(probe): + """Not just enabled — actually able to seal and send under the identity + the reconnect settled on.""" + _, steps = probe + sc = steps["reconnect"] + before = sc["device identity restored"]["bubbles"] + assert sc["after send"]["bubbles"] == before + 1, ( + "the message was not added to the conversation after the reconnect") + assert sc["after send"]["composerValue"] == "", ( + "the text came back into the composer, so the send failed") + + def test_history_still_renders(probe): """ Not about sending at all, and here because it broke without a sound: -- cgit v1.2.3