From 3ca9042e54393b99989d205b436f1f8da25aa47c Mon Sep 17 00:00:00 2001
From: Russell Ballestrini
Date: Wed, 3 Jun 2026 15:51:18 -0400
Subject: [PATCH] zebra-spaces: boot-error logging + watchFirstFrame keyframe
diagnostic
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- moderation: 'boot' button onclick now .catch'es and logs server
errors. Server now returns 'boot target not found (stale uuid?)'
instead of silently no-op'ing when the page's member roster lagged
the room — the moderator was clicking 'boot' and seeing nothing
happen because their page held a stale uuid.
- diagnostic: watchFirstFrame logs 'still black after 2500ms — no
keyframe?' on any fresh SFU video track (screen/camera/game) whose
decoder never unmutes. Pairs with the SFU's extended kfBurst so we
can tell next session which path actually broke when a camera tile
renders black.
multi-peer-mesh test harness extracts the new fn alongside
watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end
in the headless sandbox.
Stamp date refresh on the other pages (no behavior change).
---
test/multi-peer-mesh.test.js | 2 ++
web/chat.html | 6 +++---
web/host-your-own.html | 6 +++---
web/how-it-works.html | 6 +++---
web/zebra-audio.html | 6 +++---
web/zebra-spaces.html | 42 +++++++++++++++++++++++++++++++++---
6 files changed, 53 insertions(+), 15 deletions(-)
diff --git a/test/multi-peer-mesh.test.js b/test/multi-peer-mesh.test.js
index a86feb9..c6f8447 100644
--- a/test/multi-peer-mesh.test.js
+++ b/test/multi-peer-mesh.test.js
@@ -50,6 +50,7 @@ function extractConst(name){
}
const watchSrc = extractFn(/function watchVideoTrackForRemoval\(/);
+const firstFrameSrc = extractFn(/function watchFirstFrame\(/);
const renderTileSrc = extractFn(/function renderVideoTile\(/);
const removeTileSrc = extractFn(/function removeVideoTile\(/);
const renderScreenShim = extractFn(/function renderScreenTile\(/);
@@ -229,6 +230,7 @@ function makeBrowser(name, pubKeyHex){
'const members = new Map();\n' +
tileKindsSrc + '\n' +
watchSrc + '\n' +
+ firstFrameSrc + '\n' +
renderTileSrc + '\n' +
removeTileSrc + '\n' +
renderScreenShim + '\n' +
diff --git a/web/chat.html b/web/chat.html
index 5a3438b..d0af6ea 100644
--- a/web/chat.html
+++ b/web/chat.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
diff --git a/web/host-your-own.html b/web/host-your-own.html
index 15af14a..9385b5c 100644
--- a/web/host-your-own.html
+++ b/web/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
diff --git a/web/how-it-works.html b/web/how-it-works.html
index b3ba742..83f40f4 100644
--- a/web/how-it-works.html
+++ b/web/how-it-works.html
@@ -341,9 +341,9 @@ try {
diff --git a/web/zebra-audio.html b/web/zebra-audio.html
index f744be0..64fe765 100644
--- a/web/zebra-audio.html
+++ b/web/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index d32fe3f..5984ad4 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -1702,6 +1702,38 @@ function watchVideoTrackForRemoval(track, removeFn, windowMs){
track.addEventListener('unmute', onUnmute);
}
+/* watchFirstFrame — log a warning if a freshly-attached SFU video track
+ * never unmutes within the deadline. Fresh remote tracks start `muted`
+ * until the first RTP packet arrives; if the decoder never gets a
+ * keyframe (PLI dropped in UDP, publisher encoder stalled, codec
+ * mismatch), the tile renders permanently black with no clear signal in
+ * the log. This emits one line so we can tell next session which path
+ * actually broke. No state change — purely diagnostic. */
+function watchFirstFrame(track, kind, pubHex){
+ if (!track) return;
+ if (!track.muted){
+ /* already flowing — nothing to wait for */
+ return;
+ }
+ const deadline = 2500;
+ const start = Date.now();
+ let done = false;
+ const settle = (note) => {
+ if (done) return;
+ done = true;
+ track.removeEventListener('unmute', onUnmute);
+ if (note) logLine('', note);
+ };
+ const onUnmute = () => settle('first frame: kind=' + kind + ' pub=' + pubHex + ' in ' + (Date.now()-start) + 'ms');
+ track.addEventListener('unmute', onUnmute);
+ setTimeout(() => {
+ if (done) return;
+ if (track.readyState !== 'live'){ settle(null); return; }
+ if (!track.muted){ settle('first frame: kind=' + kind + ' pub=' + pubHex + ' (unmute event missed)'); return; }
+ settle('err: ' + kind + ' from ' + pubHex + ' still black after ' + deadline + 'ms — no keyframe?');
+ }, deadline);
+}
+
/* Spotlight model — every tile keeps its thumbnail permanently in the
* cameras column (cameras above, screens-thumbs below). When a tile is
* spotlit, a SECOND larger tile renders in the middle slot pointing at
@@ -2505,6 +2537,7 @@ function handleRemoteSfuTrack(ev){
screenStreams.set(pubHex, s);
renderScreenTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind === 'camera'){
@@ -2512,6 +2545,7 @@ function handleRemoteSfuTrack(ev){
cameraStreams.set(pubHex, s);
renderCameraTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind === 'game'){
@@ -2522,6 +2556,7 @@ function handleRemoteSfuTrack(ev){
gameStreams.set(pubHex, s);
renderVideoTile('gameshare', pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
+ watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind !== 'mic'){
@@ -3728,7 +3763,8 @@ function renderRoom(){
/* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
const b = document.createElement('button'); b.className='small';
- b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid);
+ b.textContent = 'boot';
+ b.onclick = () => modBoot(m.uuid).catch(e => logLine('err','boot: '+e.message));
acts.appendChild(b);
}
}
@@ -4171,8 +4207,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');