1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
|
"""
Sending a chat message must come back — accepted or refused.
The node's replies to a chat message name no request. The acceptance is a bare
`{"type": "ack"}`; the refusal is a bare `{"type": "error"}`, and it is not a
special case — `_dispatch_message`'s catch-all answers *every* failure that
way, and 238 of webrtc_server.py's 240 error sends name nothing either. So
`_dispatch` had nothing to match either reply on and left both 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, which is the ordinary case rather than a rare one: a `music_meta_req`
sits in `_pending` for as long as the third-party lookup behind it takes, and
that was measured live at over 100 seconds with the service failing. The reply
went 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, and the message never appeared.
The ack half was fixed by matching on request type. The refusal half could not
be: an `error` has no type of its own to match on. `req_id` is what closed it —
the caller's id, stamped on the reply by the node — so this now drives both
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
import subprocess
import sys
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"
GROUP_PAGE = STATIC / "group-page.js"
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():
# `sys.executable`, not a bare "python3": the harness imports
# `meshbay_common` to seal the chat keys the way the node does, and the
# system interpreter has neither that nor msgpack. The other probes get
# away with "python3" because they import nothing from this project.
run = subprocess.run([sys.executable, str(HARNESS)],
capture_output=True, timeout=180)
assert run.returncode == 0, run.stderr.decode()[-2000:]
data = json.loads(run.stdout.decode())
steps = {sc["name"]: {s["label"]: s for s in sc["steps"]}
for sc in data["scenarios"]}
return data, steps
@pytest.mark.parametrize("reply", ["ack", "error"])
def test_the_composer_comes_back(probe, reply):
"""The one thing a person sees: the tab is usable again.
Both answers have to release it. A refusal that reaches nobody leaves the
composer disabled exactly as long as an acceptance that reaches nobody —
the composer is not waiting for good news, it is waiting for an answer.
"""
_, steps = probe
assert steps[reply]["older request pending"]["composerDisabled"] is False, (
"the composer was already unusable before the send")
assert steps[reply]["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_an_accepted_message_is_displayed(probe):
"""A sent message appears at once, not on the next visit to the tab."""
_, steps = probe
before = steps["ack"]["older request pending"]["bubbles"]
assert steps["ack"]["after send"]["bubbles"] == before + 1, (
"the message was not added to the conversation")
assert steps["ack"]["after send"]["lastText"] == "hello"
assert steps["ack"]["after send"]["composerValue"] == "", (
"the text came back into the composer, so the send was treated as failed")
def test_a_refused_message_is_not_displayed_as_sent(probe):
"""The other direction, and the one routing this correctly makes possible.
While a refusal reached the wrong caller it did not matter what `sendChat`
would have done with it. Now that it arrives, a message the node rejected
must not appear in the conversation as though it had been stored — it must
come back into the composer, where a person can see it did not go.
"""
_, steps = probe
before = steps["error"]["older request pending"]["bubbles"]
assert steps["error"]["after send"]["bubbles"] == before, (
"a refused message was added to the conversation anyway")
assert steps["error"]["after send"]["composerValue"] == "hello", (
"the refused text was dropped instead of being handed back")
@pytest.mark.parametrize("reply", ["ack", "error"])
def test_the_reply_is_not_handed_to_another_request(probe, reply):
"""The other half of the same defect: whatever was waiting got the reply
and carried on with an answer to a question it never asked."""
data, _ = probe
stolen = [line for line in data["log"] if line.startswith(f"{reply}: music_meta")]
assert not stolen, (
f"the chat {reply} was routed to the pending music_meta_req ({stolen}) -- "
"that request now believes it has an answer, and the chat send is "
"waiting for a reply that already arrived")
@pytest.mark.parametrize("reply", ["ack", "error"])
def test_the_message_goes_out_sealed_and_signed(probe, reply):
"""
What actually left the browser (MNP 2.0).
Asserted for both scenarios because the composer is what decides to send:
a client that fell back to plaintext when something went wrong would be
refused by the node, but the refusal arrives after the fact and reads as
"the message did not send". There is no plaintext form on the wire.
"""
data, _ = probe
sent = [line for line in data["log"]
if line.startswith(f"{reply}: chat_msg ")]
assert sent, (f"no chat_msg reached the stand-in node in the {reply} "
f"scenario — the send did not complete. log: {data['log']}")
assert "format=1" in sent[0], "the message was not sealed"
assert "sig=64" in sent[0], "the message was not signed"
assert "ct=" in sent[0] and "ct=0" not in sent[0], "there was no ciphertext"
assert "plaintextLeak=false" in sent[0], (
"the text the person typed appears somewhere in the message that went "
"on the wire")
def test_the_chat_keys_answer_is_not_handed_to_another_request(probe):
"""
The same defect this file exists for, in the message type that carries the
group's chat keys — which did not exist when it was written, and which a
send now depends on. It went astray on the probe's first encrypted run.
"""
data, _ = probe
stolen = [line for line in data["log"] if "music_meta resolved with" in line]
assert not stolen, (
f"a reply was routed to the pending music_meta_req ({stolen}) — the "
"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_the_reconnect_scenario_drives_the_shipped_path(probe):
"""The scenario has to be worth what it claims.
It would be easy to write one that sets `devicePk` itself at both ends and
proves only that the composer follows a variable. These three lines say the
clear came out of the real `connect()`, that the restore came out of the
real `_announceDevice`, and that the re-identification actually went over
the wire as a `device_hello`.
"""
data, _ = probe
log = data["log"]
assert any("connect() stopped at signaling" in line for line in log), (
"the scenario never ran the real connect(), so it did not test the "
"reconnect path at all")
assert any("sent device_hello" == line for line in log), (
"no device_hello reached the stand-in node -- the identity was not "
"re-announced, it was assigned")
assert any("_announceDevice settled on the device key" in line for line in log), (
"_announceDevice did not settle on the key it signed with")
def test_the_page_tells_the_composer_when_the_identity_moves():
"""The one seam the probe stands in for.
`Host` in the harness plays group-page.js, so a green probe proves that
ChatPanel and the transport agree — not that the page joins them. These are
the lines that do, and the order matters: the callback has to be wired
before `connect()`, because `connect()` is where `device_hello` runs, and a
callback set after it misses the first answer and starts the composer shut.
"""
src = GROUP_PAGE.read_text(encoding="utf-8")
# The assignment, not a mention of it. Matching the bare name passed with
# the wiring deleted, on the strength of a comment that named it.
assert "transport.onDeviceIdentity = " in src, (
"nothing tells the page that the device identity moved, so the "
"composer has no event to open back up on -- the freeze this file's "
"reconnect scenario is about")
assert "deviceReady," in src, (
"the answer never reaches the apps: ChatPanel defaults the prop to "
"true, so a composer wired this way is merely never closed rather "
"than correct")
assert src.index("transport.onDeviceIdentity = ") \
< src.index("await transport.connect("), (
"the callback is wired after connect(), which is where device_hello "
"runs -- its answer is missed and the composer starts closed")
def test_history_still_renders(probe):
"""
Not about sending at all, and here because it broke without a sound:
`_asText` was deleted with an unrelated helper beside it, its only caller
sits inside a promise the panel catches, and every conversation rendered
empty with the node answering perfectly.
"""
_, steps = probe
assert steps["ack"]["older request pending"]["bubbles"] == 5, (
"the five history messages did not render — the panel swallows a "
"failure in the transport's message reader, so this is silent")
|