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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
|
#!/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
It now drives two shapes of reply, because there were two ways for one to go
astray and only the first was ever fixed:
`ack` the node accepts the message and answers `{"type": "ack"}`, which
names no request. Routed by request type since 2026-08-30.
`error` the node *refuses* it — every failure in `_dispatch_message` ends at
one catch-all sending `{"type": "error", "detail": "Request failed"}`,
and 238 of this module's 240 error sends name nothing either. That
reply reached no caller at all: it went to whatever request happened
to be waiting, and the send sat out its own 30s timeout with the
composer disabled.
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
generated in the page — `signBytes` imports a pkcs8 key and WebCrypto will not
be fooled by a stand-in — and the `chat_keys_resp` the stub answers with is
sealed **by the shipped Python**, because a payload the page built itself would
prove only that the page agrees with the page.
That path found two defects the moment it first ran, neither visible in any
source file: `chat_keys_resp` was routed by arrival order and handed to the
older pending request (this defect, in a message type that did not exist when
the probe was written), and `_asText` had been deleted along with an unrelated
helper beside it — its only caller sits inside a promise the panel catches, so
every conversation rendered empty with nothing in the console.
Prints JSON: `scenarios`, the state of the panel at each stage of each, 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
GROUP_ID = "g" * 32
GEK = bytes.fromhex("5a" * 32)
EPOCH_KEY = bytes.fromhex("7c" * 32)
def _page() -> str:
"""
The page, with a real sealed `chat_keys_resp` baked in.
Sealed here, by the shipped Python, rather than assembled in the browser:
msgpack is private to transport.js and exported to nothing, and a payload
the page built itself would prove only that the page agrees with the page.
"""
import msgpack # noqa: F401 (imported for the failure it gives if absent)
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal
sealed = seal(GEK, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID,
{"epochs": [{"epoch": 1, "key": EPOCH_KEY}], "current": 1})
return (PAGE_TEMPLATE
.replace("__GROUP_ID__", GROUP_ID)
.replace("__GEK_HEX__", GEK.hex())
.replace("__KEYS_NONCE_HEX__", sealed["nonce"].hex())
.replace("__KEYS_CT_HEX__", sealed["ct"].hex()))
PAGE_TEMPLATE = 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>
<!-- The two the real page loads and the transport reaches for by global:
`sealChat`/`openGroup` live in crypto.js, `signBytes` in keyderive.js.
Without them a send fails with "cannot read properties of undefined". -->
<script src="/crypto.js"></script>
<script src="/keyderive.js"></script>
<script src="/transport.js"></script>
<script type="module">
import { html, render, useRef, useState, useEffect } from '/vendor/htm-preact.js';
import { ChatPanel } from '/chat-app.js';
const log = [];
window.addEventListener('error', e => log.push('error: ' + e.message));
window.addEventListener('unhandledrejection',
e => log.push('rejected: ' + (e.reason && e.reason.message || e.reason)));
const hex = (s) => Uint8Array.from(s.match(/../g) || [], b => parseInt(b, 16));
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
// One device key for both scenarios. Real, not stubbed: `signBytes` imports a
// pkcs8 key, so nothing else gets a signature past `verifyChatSignature`.
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true,
['sign', 'verify']);
const SK_ED_B64 = b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey));
const DEVICE_PK_B64 = b64(await crypto.subtle.exportKey('raw', kp.publicKey));
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 });
}
const wait = ms => new Promise(r => setTimeout(r, ms));
// Stands in for the node, answering what webrtc_server.py answers — including
// stamping the reply with the id of the request it is answering, which is what
// `_send` does there. `chatReply` is the only difference between the two runs.
function makeTransport(name, chatReply) {
// 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() {} };
// What a completed MNP 2.0 handshake leaves behind: the group and its key
// from `connect`, the current epoch from the sealed ack, and the device this
// connection identified itself as with `device_hello`.
tp._groupId = '__GROUP_ID__';
tp._gekRaw = hex('__GEK_HEX__');
tp.chatEpoch = 1;
tp._sessionKeys = { skEdB64: SK_ED_B64 };
tp.devicePk = DEVICE_PK_B64;
tp._send = (obj) => {
log.push('sent ' + obj.type);
const answer = (reply) => setTimeout(
() => tp._dispatch({ ...reply, req_id: obj.req_id }), 10);
if (obj.type === 'chat_hist') {
answer({ type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false });
} else if (obj.type === 'chat_keys_req') {
// Sealed under the group key, as `_do_chat_keys_req` sends it.
answer({ type: 'chat_keys_resp', v: '2.0', group_id: tp._groupId,
nonce: hex('__KEYS_NONCE_HEX__'), ct: hex('__KEYS_CT_HEX__') });
} else if (obj.type === 'device_hello') {
// What `_do_device_hello` answers once the signature checks out. Driving
// the real `_announceDevice` through it is the point: the composer has to
// come back from the code that actually re-identifies the device after a
// reconnect, not from a test poking `devicePk`.
answer({ type: 'device_hello_ack', v: '2.0' });
} else if (obj.type === 'chat_msg') {
// Recorded so the test can assert what actually left the browser, rather
// than trusting that a composer which accepted the text sealed it.
log.push(name + ': chat_msg format=' + obj.format + ' epoch=' + obj.epoch
+ ' ct=' + (obj.ct ? obj.ct.length : 0)
+ ' sig=' + (obj.sig ? obj.sig.length : 0)
+ ' plaintextLeak=' + JSON.stringify(obj).includes('hello'));
answer(chatReply);
}
// music_meta_req is answered by nothing at all, on purpose: the node holds
// one open for as long as the third-party lookup behind it takes, which
// was measured at over 100 seconds with that service failing. It is the
// older pending request every scenario here needs.
};
// The panel swallows a send failure into `setInput(text)`, which is right for
// a person and useless for a probe: the symptom is a message not appearing,
// with no reason anywhere. Surfaced here so a failure names itself — the
// `error` scenario is *expected* to reach this.
const _sendChat = tp.sendChat.bind(tp);
tp.sendChat = (...a) => _sendChat(...a).catch((e) => {
log.push(name + ': sendChat failed: ' + (e && e.message || e));
throw e;
});
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"
deviceReady=${deviceReady} />`;
}
function typeInto(root, text) {
const c = root.querySelector('.chat-input');
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 function runScenario(name, chatReply, duringSession) {
const root = document.createElement('div');
document.getElementById('root').appendChild(root);
const tp = makeTransport(name, chatReply);
render(html`<${Host} tp=${tp} />`, root);
const steps = [];
const snap = (label) => {
const c = root.querySelector('.chat-input');
steps.push({
label,
bubbles: root.querySelectorAll('.chat-bubble').length,
lastText: [...root.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,
// 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,
});
};
await wait(500);
snap('arrived');
// The Music tab asked about a track and is still waiting on the node, which
// is waiting on something else. Any older unanswered request will do; this
// is the one that was live when the defect was found.
tp.fetchMusicMeta('a-track-the-node-is-slow-about')
.then(m => log.push(name + ': music_meta resolved with ' + m.type),
e => log.push(name + ': music_meta rejected: ' + e.message));
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',
{ 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');
return { name, steps };
}
(async () => {
const out = { scenarios: [], log };
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) => {
// The real `connect()`, called the way `_reconnectLoop` calls it. It
// fails — there is no hub here to sign an offer with — and that is what
// makes the point: the identity is already gone by then, because
// connect() drops it before it touches the network. Nothing about this
// step is simulated, and the clear is not poked in by the test.
await tp.connect('node-1', 'token', tp._groupId, tp._gekRaw,
tp._sessionKeys, null, 'me', 'user-me').then(
() => log.push('reconnect: connect() unexpectedly succeeded'),
(e) => log.push('reconnect: connect() stopped at signaling, as expected: '
+ (e && e.message || e)));
// The dead peer connection would otherwise reach "failed" and start a
// real reconnect loop of its own, against a hub that is not there.
tp._closed = true;
try { if (tp._pc) tp._pc.close(); } catch (e) { /* already gone */ }
// After the close event, not before: the real channel's `onclose` is
// delivered as its own task and sets `_connected` back to false, so a
// stand-in installed ahead of it is undone a tick later.
await wait(100);
tp._channel = { readyState: 'open', send() {} };
tp._connected = true;
await wait(50);
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');
// What the handshake leaves behind, and all `_announceDevice` needs of
// it: connect() above already set `_userId`.
tp._nonceNode = hex('00'.repeat(32));
tp.nodePk = 'a-node-public-key';
const pk = await tp._announceDevice();
log.push('reconnect: _announceDevice settled on '
+ (pk === DEVICE_PK_B64 ? 'the device key' : JSON.stringify(pk)));
await wait(100);
snap('device identity restored');
}));
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):
# Only the measurement. The `reconnect` scenario drives the real
# `connect()`, which POSTs its offer to the hub's signaling endpoint --
# there is no hub here, and answering that with 204 both swallowed the
# measurement and put the machine's own SDP (public address included)
# into the probe's output. It gets a 404, which is what makes connect()
# stop where the scenario needs it to.
if self.path != "/log":
self.send_response(404)
self.end_headers()
return
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()
# ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu)
# outlive terminate() on the parent by a moment and go on writing into
# the profile. rmtree then walks a directory that gains a file between
# its readdir and its rmdir and raises "Directory not empty" -- which
# failed the probe, which failed every test in the file, intermittently
# and for a reason nowhere near the chat code they were testing. A few
# bytes left in a throwaway profile are harmless; failing the run is not.
with tempfile.TemporaryDirectory(
ignore_cleanup_errors=True) 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()
proc.wait()
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())
|