zebra-spaces: only prune mute AFTER data flowed — fresh tiles stop disappearing

Symptom: listener saw camera tiles appear black, then vanish ~1.5s later.

Cause: remote MediaStreamTracks ALWAYS start in muted state until the
first RTP packet arrives. My watchVideoTrackForRemoval had a
'defensive' branch that scheduled a prune if the track was already
muted at attach time — so the moment we attached, the 1.5s timer
started, and if the publisher's encoder hadn't pushed a keyframe yet
(common for cameras), the tile was pruned before any frame rendered.

Fix: track a hasFlowed flag. Set true on the FIRST 'unmute' (RTP
actually arrived). Only schedule a prune on 'mute' events that fire
AFTER hasFlowed — those are the real 'publisher stopped sending' case.
Initial muted state is now ignored. Also bumped the debounce from
1.5s to 3s for extra safety on slow networks.

'ended' still removes immediately (terminal state, no debounce).

83 FSM tests green.
This commit is contained in:
Russell Ballestrini 2026-06-02 11:39:05 -04:00
parent aa36b84a47
commit 9869e7861d
No known key found for this signature in database

View file

@ -1459,30 +1459,33 @@ function flushSfuStreams(){
* (transient network blip) we cancel the removal. */
function watchVideoTrackForRemoval(track, removeFn){
if (!track) return;
let timer = null, removed = false;
let timer = null, removed = false, hasFlowed = false;
const remove = () => {
if (removed) return;
removed = true;
if (timer) { clearTimeout(timer); timer = null; }
try { removeFn(); } catch(_){}
};
const onUnmute = () => {
/* RTP arrived; future mute events are meaningful (a flow that
* existed then stopped — publisher unshared or net dropped) */
hasFlowed = true;
if (timer){ clearTimeout(timer); timer = null; }
};
const onMute = () => {
if (removed || timer) return;
/* fresh remote tracks ALWAYS start muted until the first RTP packet
* arrives. If we've never seen data flow on this track, the mute is
* its initial state, not a publisher unshare — don't prune. */
if (!hasFlowed) return;
timer = setTimeout(() => {
timer = null;
if (!removed && track.muted) remove();
}, 1500);
};
const onUnmute = () => {
if (timer){ clearTimeout(timer); timer = null; }
}, 3000);
};
track.addEventListener('ended', remove);
track.addEventListener('mute', onMute);
track.addEventListener('unmute', onUnmute);
/* defensive: if the track is already muted at attach time (the
* 'mute' event may have fired before we got here), schedule the
* pruning right away */
if (track.muted) onMute();
}
/* Spotlight model — every tile keeps its thumbnail permanently in the
@ -3360,8 +3363,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-02</span><br>
md5 <span class="stamp-md5">e889a0a868e23e2be985f28db80ff031</span><br>
sha256 <span class="stamp-sha">e2d9440bd38fc3081eaafe68d972d3ac8a856ace24da919c6eb6b44ec9a0a7d2</span><br>
md5 <span class="stamp-md5">f42670fad4ce383bc903910aa43e99a3</span><br>
sha256 <span class="stamp-sha">3519283eb1041cef47bab75c9924c4d58702b64c2681aa66ccd71c5d2a9f3cbb</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>