aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 10:27:45 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 10:27:45 +0200
commit3be8bd2a7885fbd141f6cc12c2d2073f9a0ac56c (patch)
treec735d6cc99d9d38af2a83f5c0be62f443917f911 /packages/meshbay-hub/src
parent7126fd3c265ba75d77b449bfd0f83f5f3e584b74 (diff)
downloadmeshbay-3be8bd2a7885fbd141f6cc12c2d2073f9a0ac56c.tar.gz
debug(transport): opt-in WebRTC health tracing for mobile-lock investigation
Client (transport.js): a localStorage ring buffer of connection/ICE/ DataChannel state transitions, visibility changes, request timeouts, and periodic health pings — enabled once via ?trace=1 (persists), read back at any time via #mb-debug without devtools. Off by default, zero behavior change unless enabled. Node (webrtc_server.py): MESHBAY_WEBRTC_TRACE=1 gates ICE-state-change logging and a per-session heartbeat (message count, seconds since last message, ICE/connection state) every 30s. Debugging aid for the "stuck after several minutes of mobile screen lock" report — not a fix. Stays on this branch until confirmed useful/resolved.
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js156
1 files changed, 156 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 7535594..6c038f6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -81,6 +81,103 @@ const ADMIN_OP_TYPES = new Set([
'group_detach', 'invite_create',
]);
+// ── Diagnostic trace (opt-in, off by default) ───────────────────────────────
+// Ring buffer of transport health events (connection/ICE/DataChannel state
+// transitions, request timeouts, visibility changes, periodic health pings),
+// persisted to localStorage so a connection that gets stuck can be inspected
+// after the fact — the field case this exists for is a phone with no
+// devtools attached. Added while chasing a report of the transport going
+// unresponsive after a mobile screen lock of several minutes; kept in the
+// tree afterward rather than ripped out, since the next hard-to-reproduce
+// connection bug will want the same thing and it costs nothing while off.
+//
+// Enable once by opening the app with ?trace=1 in the URL — this persists in
+// localStorage, so every later visit stays in trace mode until ?trace=0
+// clears it. Read the log back at any time by navigating to #mb-debug (e.g.
+// https://meshbay.org/app/#mb-debug), which replaces the page with a plain
+// text dump — no devtools required.
+const TRACE_KEY = 'mb_trace';
+const TRACE_LOG_KEY = 'mb_trace_log';
+const TRACE_MAX = 500;
+// How often to probe the channel with a ping while trace mode is on — purely
+// diagnostic (to see when a health check starts failing), not a keepalive:
+// must stay opt-in, never run by default.
+const TRACE_PING_INTERVAL_MS = 25000;
+
+(function _initTraceFlag() {
+ try {
+ const params = new URLSearchParams(location.search);
+ if (params.has('trace')) {
+ if (params.get('trace') === '0') localStorage.removeItem(TRACE_KEY);
+ else localStorage.setItem(TRACE_KEY, '1');
+ }
+ } catch { /* localStorage unavailable (private mode, etc.) — trace stays off */ }
+})();
+
+function traceEnabled() {
+ try { return localStorage.getItem(TRACE_KEY) === '1'; } catch { return false; }
+}
+
+function trace(event, data) {
+ if (!traceEnabled()) return;
+ try {
+ const buf = JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]');
+ buf.push({ t: new Date().toISOString(), event, ...data });
+ while (buf.length > TRACE_MAX) buf.shift();
+ localStorage.setItem(TRACE_LOG_KEY, JSON.stringify(buf));
+ } catch { /* storage full or unavailable — tracing is best-effort */ }
+}
+
+window.MeshBayTrace = {
+ enabled: traceEnabled,
+ dump() {
+ try { return JSON.parse(localStorage.getItem(TRACE_LOG_KEY) || '[]'); } catch { return []; }
+ },
+ clear() { try { localStorage.removeItem(TRACE_LOG_KEY); } catch { /* ignore */ } },
+};
+
+function _showTraceView() {
+ {
+ const renderTraceView = () => {
+ const log = window.MeshBayTrace.dump();
+ const text = JSON.stringify(log, null, 2);
+ document.body.innerHTML = '';
+ document.title = 'MeshBay — Diagnostic';
+ const bar = document.createElement('div');
+ bar.style.cssText = 'font-family:monospace;padding:8px;';
+ const copyBtn = document.createElement('button');
+ copyBtn.textContent = 'Copier';
+ copyBtn.onclick = () => { navigator.clipboard.writeText(text).catch(() => {}); };
+ const clearBtn = document.createElement('button');
+ clearBtn.textContent = 'Vider';
+ clearBtn.onclick = () => { window.MeshBayTrace.clear(); renderTraceView(); };
+ const refreshBtn = document.createElement('button');
+ refreshBtn.textContent = 'Rafraîchir';
+ refreshBtn.onclick = renderTraceView;
+ const info = document.createElement('span');
+ info.textContent = ` — ${log.length} évènement(s) — trace ${traceEnabled() ? 'active' : 'inactive'}`;
+ info.style.marginLeft = '8px';
+ bar.append(copyBtn, clearBtn, refreshBtn, info);
+ const pre = document.createElement('pre');
+ pre.style.cssText = 'font-family:monospace;font-size:11px;white-space:pre-wrap;'
+ + 'word-break:break-all;padding:8px;';
+ pre.textContent = text;
+ document.body.append(bar, pre);
+ };
+ renderTraceView();
+ }
+}
+
+// Fragment-only URL changes (typing #mb-debug into an already-loaded page,
+// or a link to it) do not reload the document, so DOMContentLoaded alone
+// would miss them — hashchange is what a same-document navigation fires.
+if (location.hash === '#mb-debug') {
+ document.addEventListener('DOMContentLoaded', _showTraceView);
+}
+window.addEventListener('hashchange', () => {
+ if (location.hash === '#mb-debug') _showTraceView();
+});
+
const JOIN_REFUSALS = {
code_required: 'This node does not know this browser yet. Ask the node operator '
+ 'for a pairing code (meshbay-node operator pair).',
@@ -168,6 +265,7 @@ class MeshBayTransport {
this._channel.onopen = () => {
clearTimeout(timeout);
this._connected = true;
+ trace('channel_open', {});
resolve();
};
});
@@ -175,6 +273,11 @@ class MeshBayTransport {
this._channel.onmessage = (event) => this._onMessage(event.data);
this._channel.onclose = (ev) => {
console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev);
+ trace('channel_close', {
+ readyState: this._channel?.readyState,
+ pc: this._pc?.connectionState,
+ ice: this._pc?.iceConnectionState,
+ });
this._connected = false;
if (channelReject) channelReject(new Error('DataChannel closed'));
for (const [, p] of this._pending) p.reject(new Error('DataChannel closed'));
@@ -182,16 +285,63 @@ class MeshBayTransport {
};
this._channel.onerror = (ev) => {
console.error('[MeshBay] DataChannel error', ev);
+ trace('channel_error', {
+ pc: this._pc?.connectionState,
+ ice: this._pc?.iceConnectionState,
+ });
if (channelReject) channelReject(new Error('DataChannel error'));
};
this._pc.onconnectionstatechange = () => {
console.log('[MeshBay] PC state:', this._pc.connectionState);
+ trace('pc_state', { state: this._pc.connectionState });
};
this._pc.oniceconnectionstatechange = () => {
console.log('[MeshBay] ICE state:', this._pc.iceConnectionState);
+ trace('ice_state', { state: this._pc.iceConnectionState });
};
+ // Diagnostic-only: a periodic health ping and a resume-triggered one, so
+ // a trace captures exactly what state the connection was in right as the
+ // page comes back from being backgrounded/locked — never active unless
+ // trace mode is on (see TRACE_KEY above).
+ if (traceEnabled()) {
+ const healthPing = async (reason) => {
+ const before = {
+ pc: this._pc?.connectionState,
+ ice: this._pc?.iceConnectionState,
+ channel: this._channel?.readyState,
+ };
+ const start = Date.now();
+ try {
+ await this.ping(8000);
+ trace('health_ping', { reason, ok: true, rtt_ms: Date.now() - start, ...before });
+ } catch (e) {
+ trace('health_ping', { reason, ok: false, error: String(e && e.message || e),
+ elapsed_ms: Date.now() - start, ...before });
+ }
+ };
+ const onVisibility = () => {
+ trace('visibility', {
+ state: document.visibilityState,
+ pc: this._pc?.connectionState,
+ ice: this._pc?.iceConnectionState,
+ channel: this._channel?.readyState,
+ });
+ if (document.visibilityState === 'visible' && this._channel?.readyState === 'open') {
+ healthPing('resume');
+ }
+ };
+ document.addEventListener('visibilitychange', onVisibility);
+ const healthInterval = setInterval(() => {
+ if (this._channel?.readyState === 'open') healthPing('interval');
+ }, TRACE_PING_INTERVAL_MS);
+ this._diagCleanup = () => {
+ document.removeEventListener('visibilitychange', onVisibility);
+ clearInterval(healthInterval);
+ };
+ }
+
const offer = await this._pc.createOffer();
await this._pc.setLocalDescription(offer);
@@ -1440,6 +1590,7 @@ class MeshBayTransport {
get gekRaw() { return this._gekRaw; }
close() {
+ if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; }
if (this._channel) this._channel.close();
if (this._pc) this._pc.close();
this._connected = false;
@@ -1456,6 +1607,11 @@ class MeshBayTransport {
this._pending.delete(id);
console.error('[MeshBay] Response timeout for', obj.type,
'after', timeoutMs, 'ms, channel=', this._channel?.readyState);
+ trace('send_timeout', {
+ reqType: obj.type, timeoutMs,
+ pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState,
+ channel: this._channel?.readyState,
+ });
reject(new Error('Response timeout'));
}, timeoutMs);
this._pending.set(id, {