Fox 2026-06-06: "closed firefox on both phones [they] are both still
in the list" — listener-roster entries persisting indefinitely after
mobile Firefox close.
Two facts collided:
1. SERVER (proxy.unturf.com main.go aliveJanitorTick ~line 1872):
listeners are fully exempt from the heartbeat-stall reaper.
Justified fox 2026-06-04 because the page's {type:"alive"}
timer throttles hard on backgrounded mobile tabs (1Hz on
Android, paused on iOS power-save) — without the exemption,
mobile listeners lost their seat every time they tab-switched.
2. CLIENT (this file, sendByeIfRealClose): pagehide with
event.persisted=true means the page is going into bfcache
(mobile app-switch / tab-close-to-bfcache / lock screen),
so we SKIPPED the 'bye' message to keep PCs warm for resume.
Justified fox 2026-06-03 because "the phone leaving and
coming back cannot hear the music" — bye-driven SFU eviction
killed the speaker's publish + subscribe PCs.
Net: a mobile Firefox close fires pagehide(persisted=true) →
no 'bye' → server has only readTimeout (120s) + hiccup grace
(8s) to detect the dead socket → ~128s of phantom listener
seat in every other client's roster.
The bfcache justification on the CLIENT side was always
specific to speakers (they have PCs to protect). LISTENERS:
- have no publisher PC
- finalizeLeave at main.go:1089 explicitly exempts them from
evictFromSFU (their subscriber PC stays alive through the
bye)
- re-handshake fresh on pageshow via POST /subscribe (same
path as a cold join)
So for listeners, sending 'bye' on persisted=true is
roster-only cleanup: peer-left broadcast, members.delete(uuid)
on every other client, brief disappearance from the room. On
pageshow they re-handshake and reappear — same UX as a cold
rejoin, which already works.
Fix: split the bfcache rule by role. Send 'bye' on pagehide
even when persisted=true if myRole === 'listener'. Speakers
keep the original bfcache skip exactly.
Server-side backstop (60s listener-specific TTL replacing the
full exemption) ships as a separate commit in proxy.unturf.com
so 'bye' losses (carrier NAT eating the TCP shutdown, abrupt
process kill, custom Firefox close paths) still get reaped
within the minute.
Pinned by test/sendbye-fsm.test.js — extracts
sendByeIfRealClose from this page and drives 9 scenarios
covering each (role, persisted) combination plus defensive
edges (no event, ws not open, post-demote listener state).
Wired into test-all via test-sendbye target.
141 lines
5.3 KiB
JavaScript
141 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/* sendByeIfRealClose state-machine tests.
|
|
*
|
|
* node test/sendbye-fsm.test.js
|
|
*
|
|
* Pins the listener / speaker split on pagehide(persisted=true):
|
|
*
|
|
* - speaker + persisted=true -> no bye (bfcache nap; keep PCs warm)
|
|
* - speaker + persisted=false -> bye (real unload)
|
|
* - listener + persisted=true -> bye (no PCs to protect; roster
|
|
* cleanup matters more than
|
|
* bfcache continuity)
|
|
* - listener + persisted=false -> bye (real unload)
|
|
*
|
|
* Background: fox 2026-06-03 hit "phone leaving and coming back cannot
|
|
* hear the music" when bye was always sent — the SFU eviction killed
|
|
* the speaker's PCs and the resumed page couldn't recover them. The
|
|
* fix was to skip bye on persisted=true. fox 2026-06-06 hit the
|
|
* opposite: "closed firefox on both phones [they] are both still in
|
|
* the list" because listeners are exempt from the server's
|
|
* aliveJanitor and the bfcache skip left them as 120s+8s zombies. The
|
|
* split below threads the needle. The function is extracted from
|
|
* web/zebra-spaces.html at test time so the spec can't drift. */
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
|
|
|
|
function extractFn(re){
|
|
const m = src.match(re);
|
|
if (!m) throw new Error('could not find ' + re);
|
|
let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i;
|
|
for (; j < src.length; j++){
|
|
if (src[j] === '{') depth++;
|
|
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
|
|
}
|
|
return src.slice(m.index, j);
|
|
}
|
|
|
|
const sendByeSrc = extractFn(/function sendByeIfRealClose\(/);
|
|
|
|
/* Build a sandbox where myRole + ws are mutable so each scenario can
|
|
* configure them, and ws.send is a spy that records every call. */
|
|
function makeHarness(initialRole){
|
|
const sent = [];
|
|
const ws = {
|
|
readyState: 1, /* OPEN — WebSocket.OPEN === 1 in browsers */
|
|
send(msg){ sent.push(msg); },
|
|
};
|
|
const factory = new Function(
|
|
'wsRef', 'WebSocket', 'initialRole',
|
|
'let myRole = initialRole;\n' +
|
|
'const ws = wsRef;\n' +
|
|
sendByeSrc + '\n' +
|
|
'return {\n' +
|
|
' setRole(r){ myRole = r; },\n' +
|
|
' setWsState(s){ ws.readyState = s; },\n' +
|
|
' sendByeIfRealClose,\n' +
|
|
'};\n'
|
|
);
|
|
return { sent, ws, api: factory(ws, { OPEN: 1 }, initialRole) };
|
|
}
|
|
|
|
let pass = 0, fail = 0;
|
|
function test(name, fn){
|
|
try { fn(); console.log(' ✓ ' + name); pass++; }
|
|
catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; }
|
|
}
|
|
function eq(a, b, msg){
|
|
if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b));
|
|
}
|
|
|
|
console.log('sendByeIfRealClose:');
|
|
|
|
test('speaker + persisted=true (bfcache): bye NOT sent (PCs must survive the nap)', () => {
|
|
const h = makeHarness('speaker');
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 0, 'no bye on bfcache');
|
|
});
|
|
|
|
test('speaker + persisted=false (real unload): bye sent', () => {
|
|
const h = makeHarness('speaker');
|
|
h.api.sendByeIfRealClose({ persisted: false });
|
|
eq(h.sent.length, 1, 'bye sent');
|
|
eq(JSON.parse(h.sent[0]).type, 'bye');
|
|
});
|
|
|
|
test('host + persisted=true (bfcache): bye NOT sent (host is a speaker role)', () => {
|
|
const h = makeHarness('host');
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 0);
|
|
});
|
|
|
|
test('cohost + persisted=true (bfcache): bye NOT sent', () => {
|
|
const h = makeHarness('cohost');
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 0);
|
|
});
|
|
|
|
test('listener + persisted=true (bfcache): bye SENT (no PCs to protect, roster cleanup wins)', () => {
|
|
/* Pins the fox 2026-06-06 fix: closed Firefox on phone leaves listener
|
|
* zombies because aliveJanitor exempts listeners + bfcache skip blocked
|
|
* bye. Listeners ride opposite the speaker bfcache rule. */
|
|
const h = makeHarness('listener');
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 1, 'bye sent on bfcache for listener');
|
|
eq(JSON.parse(h.sent[0]).type, 'bye');
|
|
});
|
|
|
|
test('listener + persisted=false (real unload): bye sent', () => {
|
|
const h = makeHarness('listener');
|
|
h.api.sendByeIfRealClose({ persisted: false });
|
|
eq(h.sent.length, 1);
|
|
});
|
|
|
|
test('any role + no event (defensive): bye sent', () => {
|
|
/* Some browsers call event handlers with undefined. Don't crash. */
|
|
const h = makeHarness('speaker');
|
|
h.api.sendByeIfRealClose(undefined);
|
|
eq(h.sent.length, 1, 'bye sent when event missing');
|
|
});
|
|
|
|
test('ws not open: no bye attempted (no throw, no send)', () => {
|
|
const h = makeHarness('listener');
|
|
h.api.setWsState(0); /* CONNECTING */
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 0, 'no send while ws not open');
|
|
});
|
|
|
|
test('role flips to listener mid-session, then bfcache fires: bye sent', () => {
|
|
/* a user promoted/demoted during the session — the role-at-pagehide
|
|
* is what matters, not the role-at-join. */
|
|
const h = makeHarness('speaker');
|
|
h.api.setRole('listener');
|
|
h.api.sendByeIfRealClose({ persisted: true });
|
|
eq(h.sent.length, 1, 'bye sent under post-demote listener state');
|
|
});
|
|
|
|
console.log('');
|
|
console.log((fail === 0 ? 'PASS' : 'FAIL') + ' — ' + pass + ' passed, ' + fail + ' failed');
|
|
process.exit(fail === 0 ? 0 : 1);
|