zebra-spaces: 120s mute window for screen + game (static content), 15s stays for camera
Screen shares and game shares can sit static for long stretches — a still desktop, a paused video, a code editor with no caret movement. The encoder genuinely stops emitting RTP, the subscriber's track goes muted, and the 15s camera window would falsely reap the live tile. watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A genuine unshare still resolves through the 'ended' path within a frame, so the longer window only affects the slow-failure case. Tests bumped to 18: new screen-window assertions + invalid-windowMs fallback to the default rather than disabling removal entirely.
This commit is contained in:
parent
9419a415bf
commit
0878996e96
2 changed files with 78 additions and 21 deletions
|
|
@ -40,13 +40,17 @@ function extract(re){
|
||||||
|
|
||||||
const watchSrc = extract(/function watchVideoTrackForRemoval\(/);
|
const watchSrc = extract(/function watchVideoTrackForRemoval\(/);
|
||||||
|
|
||||||
/* The shipped constant. Read it from source so the test always validates
|
/* Shipped constants. Read from source so the tests always validate
|
||||||
* what's actually live — if the window changes the assertion below
|
* what's actually live — if either window changes the assertions below
|
||||||
* catches an accidental shorten. */
|
* catch an accidental shorten. */
|
||||||
const winM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)\s*;/);
|
const winM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)\s*;/);
|
||||||
if (!winM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_MS not found in source');
|
if (!winM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_MS not found in source');
|
||||||
const SHIPPED_WINDOW_MS = parseInt(winM[1], 10);
|
const SHIPPED_WINDOW_MS = parseInt(winM[1], 10);
|
||||||
|
|
||||||
|
const winScreenM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)\s*;/);
|
||||||
|
if (!winScreenM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS not found in source');
|
||||||
|
const SHIPPED_WINDOW_SCREEN_MS = parseInt(winScreenM[1], 10);
|
||||||
|
|
||||||
/* ============================ fake clock ============================ */
|
/* ============================ fake clock ============================ */
|
||||||
let fakeNow = 0;
|
let fakeNow = 0;
|
||||||
let pending = new Map(); // id -> { fireAt, fn }
|
let pending = new Map(); // id -> { fireAt, fn }
|
||||||
|
|
@ -82,11 +86,13 @@ function resetLogs(){ logs = []; }
|
||||||
/* harness — Function-constructor scope so the function's free
|
/* harness — Function-constructor scope so the function's free
|
||||||
* references resolve to our fakes, not the host's real globals. */
|
* references resolve to our fakes, not the host's real globals. */
|
||||||
const harness = new Function(
|
const harness = new Function(
|
||||||
'setTimeout', 'clearTimeout', 'logLine', 'VIDEO_REMOVE_MUTE_WINDOW_MS',
|
'setTimeout', 'clearTimeout', 'logLine',
|
||||||
|
'VIDEO_REMOVE_MUTE_WINDOW_MS', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS',
|
||||||
watchSrc + '\nreturn watchVideoTrackForRemoval;'
|
watchSrc + '\nreturn watchVideoTrackForRemoval;'
|
||||||
);
|
);
|
||||||
const watchVideoTrackForRemoval = harness(
|
const watchVideoTrackForRemoval = harness(
|
||||||
fakeSetTimeout, fakeClearTimeout, logLine, SHIPPED_WINDOW_MS,
|
fakeSetTimeout, fakeClearTimeout, logLine,
|
||||||
|
SHIPPED_WINDOW_MS, SHIPPED_WINDOW_SCREEN_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ============================ fake track ============================ */
|
/* ============================ fake track ============================ */
|
||||||
|
|
@ -127,6 +133,41 @@ test('shipped mute-window is at least 10s — anything less is too aggressive',
|
||||||
truthy(SHIPPED_WINDOW_MS >= 10000, 'window ' + SHIPPED_WINDOW_MS + ' < 10000');
|
truthy(SHIPPED_WINDOW_MS >= 10000, 'window ' + SHIPPED_WINDOW_MS + ' < 10000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shipped screen-window is at least 60s — static screens stay quiet', () => {
|
||||||
|
truthy(SHIPPED_WINDOW_SCREEN_MS >= 60000,
|
||||||
|
'screen window ' + SHIPPED_WINDOW_SCREEN_MS + ' < 60000');
|
||||||
|
truthy(SHIPPED_WINDOW_SCREEN_MS > SHIPPED_WINDOW_MS,
|
||||||
|
'screen window must be longer than camera window');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callers can override the window per-kind (screen window honored)', () => {
|
||||||
|
const t = makeTrack();
|
||||||
|
let removed = 0;
|
||||||
|
watchVideoTrackForRemoval(t, () => removed++, SHIPPED_WINDOW_SCREEN_MS);
|
||||||
|
t.setMuted(false); t.fire('unmute');
|
||||||
|
t.setMuted(true); t.fire('mute');
|
||||||
|
/* the camera window has passed — but we passed the screen window, so
|
||||||
|
* the tile must survive */
|
||||||
|
advance(SHIPPED_WINDOW_MS + 1000);
|
||||||
|
eq(removed, 0, 'camera-window elapsed but screen-window in effect — must not remove');
|
||||||
|
/* still inside the screen window */
|
||||||
|
advance(SHIPPED_WINDOW_SCREEN_MS - SHIPPED_WINDOW_MS - 2000);
|
||||||
|
eq(removed, 0, 'still inside screen window');
|
||||||
|
/* now we cross the screen window */
|
||||||
|
advance(2000);
|
||||||
|
eq(removed, 1, 'screen window elapsed — removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid window argument falls back to camera default', () => {
|
||||||
|
const t = makeTrack();
|
||||||
|
let removed = 0;
|
||||||
|
watchVideoTrackForRemoval(t, () => removed++, 'not a number');
|
||||||
|
t.setMuted(false); t.fire('unmute');
|
||||||
|
t.setMuted(true); t.fire('mute');
|
||||||
|
advance(SHIPPED_WINDOW_MS + 100);
|
||||||
|
eq(removed, 1, 'bad windowMs should fall back to default, not disable removal');
|
||||||
|
});
|
||||||
|
|
||||||
test('initial mute (never flowed) does NOT schedule a removal', () => {
|
test('initial mute (never flowed) does NOT schedule a removal', () => {
|
||||||
const t = makeTrack();
|
const t = makeTrack();
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
|
|
|
||||||
|
|
@ -1601,21 +1601,37 @@ function flushSfuStreams(){
|
||||||
* subscriber's pc and renegotiates. Browsers don't reliably fire
|
* subscriber's pc and renegotiates. Browsers don't reliably fire
|
||||||
* 'ended' on remote tracks under this path (Chromium half-fires,
|
* 'ended' on remote tracks under this path (Chromium half-fires,
|
||||||
* Firefox stays silent). They DO fire 'mute' when RTP stops arriving.
|
* Firefox stays silent). They DO fire 'mute' when RTP stops arriving.
|
||||||
* Watch both: a sustained mute for >MUTE_WINDOW ms = the publisher is
|
* Watch both: a sustained mute for >windowMs = the publisher is
|
||||||
* (probably) gone and we remove the tile. If 'unmute' fires within
|
* (probably) gone and we remove the tile. If 'unmute' fires within
|
||||||
* the window — transient network blip, NACK retransmission gap, brief
|
* the window — transient network blip, NACK retransmission gap, brief
|
||||||
* CPU pressure on publisher, mobile network handoff — we cancel the
|
* CPU pressure on publisher, mobile network handoff, OR (for screen
|
||||||
* removal.
|
* shares) a long stretch of static content where the encoder simply
|
||||||
|
* isn't producing RTP — we cancel the removal.
|
||||||
|
*
|
||||||
|
* Window depends on the kind of content (caller passes via windowMs):
|
||||||
|
* - 15s for cameras: face/motion content keeps RTP flowing
|
||||||
|
* continuously, so a 15s gap is meaningful. Generous enough to
|
||||||
|
* weather Wi-Fi stalls and mobile network handoffs.
|
||||||
|
* - 120s for screen / gameshare: STATIC content (a still desktop, a
|
||||||
|
* paused video, a code editor with no caret motion) doesn't push
|
||||||
|
* new RTP for long stretches. The encoder genuinely stops emitting
|
||||||
|
* packets, the receiver's track goes muted, and the 15s window
|
||||||
|
* would falsely reap a live tile. 120s lets a static screen
|
||||||
|
* survive comfortably; a real unshare still resolves through the
|
||||||
|
* 'ended' path within a frame.
|
||||||
*
|
*
|
||||||
* Window history:
|
* Window history:
|
||||||
* - 3s: too aggressive — transient blips killed live tiles, the
|
* - 3s (all kinds): killed live tiles on any transient mute.
|
||||||
* 'host shares screen, other speaker loses it' regression.
|
* - 15s (all kinds): cameras ok, but static screens vanished after
|
||||||
* - 15s: enough to weather Wi-Fi stalls and mobile network handoffs
|
* 15s of no motion.
|
||||||
* without leaving frozen tiles around for ages. A genuine unshare
|
* - 15s camera / 120s screen+game (current). */
|
||||||
* still removes the tile within 15s. */
|
|
||||||
const VIDEO_REMOVE_MUTE_WINDOW_MS = 15000;
|
const VIDEO_REMOVE_MUTE_WINDOW_MS = 15000;
|
||||||
function watchVideoTrackForRemoval(track, removeFn){
|
const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
|
||||||
|
function watchVideoTrackForRemoval(track, removeFn, windowMs){
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
|
||||||
|
windowMs = VIDEO_REMOVE_MUTE_WINDOW_MS;
|
||||||
|
}
|
||||||
let timer = null, removed = false, hasFlowed = false;
|
let timer = null, removed = false, hasFlowed = false;
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
if (removed) return;
|
if (removed) return;
|
||||||
|
|
@ -1641,10 +1657,10 @@ function watchVideoTrackForRemoval(track, removeFn){
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
if (!removed && track.muted){
|
if (!removed && track.muted){
|
||||||
logLine('', 'video track muted >'+(VIDEO_REMOVE_MUTE_WINDOW_MS/1000)+'s — removing tile');
|
logLine('', 'video track muted >'+(windowMs/1000)+'s — removing tile');
|
||||||
remove();
|
remove();
|
||||||
}
|
}
|
||||||
}, VIDEO_REMOVE_MUTE_WINDOW_MS);
|
}, windowMs);
|
||||||
};
|
};
|
||||||
track.addEventListener('ended', remove);
|
track.addEventListener('ended', remove);
|
||||||
track.addEventListener('mute', onMute);
|
track.addEventListener('mute', onMute);
|
||||||
|
|
@ -2427,14 +2443,14 @@ async function sfuSubscribe(){
|
||||||
const s = ev.streams[0];
|
const s = ev.streams[0];
|
||||||
screenStreams.set(pubHex, s);
|
screenStreams.set(pubHex, s);
|
||||||
renderScreenTile(pubHex, s);
|
renderScreenTile(pubHex, s);
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); });
|
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'camera'){
|
if (kind === 'camera'){
|
||||||
const s = ev.streams[0];
|
const s = ev.streams[0];
|
||||||
cameraStreams.set(pubHex, s);
|
cameraStreams.set(pubHex, s);
|
||||||
renderCameraTile(pubHex, s);
|
renderCameraTile(pubHex, s);
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); });
|
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === 'game'){
|
if (kind === 'game'){
|
||||||
|
|
@ -2444,7 +2460,7 @@ async function sfuSubscribe(){
|
||||||
const s = ev.streams[0];
|
const s = ev.streams[0];
|
||||||
gameStreams.set(pubHex, s);
|
gameStreams.set(pubHex, s);
|
||||||
renderVideoTile('gameshare', pubHex, s);
|
renderVideoTile('gameshare', pubHex, s);
|
||||||
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); });
|
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (kind !== 'mic'){
|
if (kind !== 'mic'){
|
||||||
|
|
@ -3829,8 +3845,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">
|
<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> · built <span class="stamp-date">2026-06-03</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
||||||
md5 <span class="stamp-md5">202400eb71552609f46039301779f8ed</span><br>
|
md5 <span class="stamp-md5">e7d4557587a42469e48e836866e4cc56</span><br>
|
||||||
sha256 <span class="stamp-sha">684fb8eb60e8a4a30f7a9781f51a3dba088401b874262a84f735f332269f5758</span><br>
|
sha256 <span class="stamp-sha">027a8b232ffdcb7333dc18a1bf8f14ae68b582e3c7e9d9824288821ab80886dc</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue