fix(host-control-mode): adversarial audit — force-sync stall, desync/lobby, gate parity, BC tests

Audit fixes (each verified against the actual code path):

H-1 (server): track force-sync initiator on PREPARE; let the demoted
initiator's EXECUTE through the host-only gate so mid-sync demotion no
longer strands the whole room paused. Clear on EXECUTE/peer-leave.

M-1 (background): episode-lobby gate now uses !amController() for parity
with CONTENT_EVENT and server gates — co-hosts can drive the room and
initiate lobbies, not just the owner.

M-2/M-3 (popup/content/background): forceSyncReset respects hcmGuestLocked;
desynced guest skips EPISODE_LOBBY so they don't get frozen in pause after
lobby completion, and checkEpisodeLobbyCompletion excludes desynced peers
from the required count so they don't block the lobby.

M-4 (background): getHostSyncTarget clamps extrapolation to 2x heartbeat
interval so a stale host heartbeat can't snap the guest tens of seconds
past the host's real position.

L-1..L-4 (server/content/background/popup): clarify dedup comment re:
network-blip window, enforce desync invariant on SW-restore, add
forceSyncBtn guest-locked backstop, refresh badge text in place.

Backward compatibility (verified by BC-1..BC-4 regression tests):
- Old client ↔ new server: server adds fields only, never requires; old
  heartbeats stripped of desynced; host-only enforced server-side even
  when the client has no awareness.
- New client ↔ old server: empty capabilities → host-control UI hidden,
  all gates default to everyone, behavior byte-identical to pre-HCM.
- Mixed rooms: every pre-HCM event type relays cleanly in both directions.
This commit is contained in:
KoalaDev
2026-06-28 05:54:50 +02:00
parent 1ec2396c41
commit 17ac5c0a6f
5 changed files with 226 additions and 18 deletions
+144
View File
@@ -226,6 +226,150 @@ try {
close();
resetConnectionRate();
// --- H-1: a demoted co-host's FORCE_SYNC_EXECUTE still relays when they
// initiated the in-flight PREPARE. Without the initiator exemption, the
// EXECUTE would be dropped by the host-only gate and every peer would be
// left stuck paused. ---
const h1rid = 'h1-'+Date.now();
const ho = await c(), hc = await c(), hg = await c(); // owner / co-host / guest
await j(ho, h1rid, 'owner'); await j(hc, h1rid, 'cohost'); await j(hg, h1rid, 'guest');
ho._m.length = hc._m.length = hg._m.length = 0;
s(ho,'set_control_mode',{controlMode:'host-only'});
await w(ho,'control_mode'); await w(hc,'control_mode'); await w(hg,'control_mode');
ho._m.length = hc._m.length = hg._m.length = 0;
// owner promotes co-host; co-host initiates force sync
s(ho,'set_peer_role',{peerId:'cohost',controller:true});
await w(hc,'control_mode');
ho._m.length = hc._m.length = hg._m.length = 0;
s(hc,'force_sync_prepare',{targetTime:0});
await w(ho,'force_sync_prepare'); await w(hg,'force_sync_prepare');
ho._m.length = hc._m.length = hg._m.length = 0;
// owner demotes the co-host mid-flight — the EXECUTE must still go through.
s(ho,'set_peer_role',{peerId:'cohost',controller:false});
await w(hc,'control_mode');
ho._m.length = hc._m.length = hg._m.length = 0;
s(hc,'force_sync_execute',{});
await w(ho,'force_sync_execute'); await w(hg,'force_sync_execute');
// After the EXECUTE, the initiator slot is cleared: a fresh EXECUTE from the
// (now plain guest) co-host is gated again, confirming the exemption is scoped.
ho._m.length = hc._m.length = hg._m.length = 0;
s(hc,'force_sync_execute',{});
let reGated=false; try { await w(ho,'force_sync_execute',500); } catch { reGated=true; }
assert.ok(reGated, 'initiator exemption is cleared after the EXECUTE relayes');
close();
resetConnectionRate();
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
const grid = 'h1b-'+Date.now();
const go = await c(), gg = await c();
await j(go, grid, 'own'); await j(gg, grid, 'gst');
go._m.length = gg._m.length = 0;
s(go,'set_control_mode',{controlMode:'host-only'});
await w(go,'control_mode'); await w(gg,'control_mode');
go._m.length = gg._m.length = 0;
s(gg,'force_sync_execute',{});
let uninitGated=false; try { await w(go,'force_sync_execute',500); } catch { uninitGated=true; }
assert.ok(uninitGated, 'guest FORCE_SYNC_EXECUTE without a matching PREPARE is gated');
close();
resetConnectionRate();
// =====================================================================
// BACKWARD COMPATIBILITY — old clients (pre-HCM build) against new server.
// These tests simulate an old client by deliberately omitting fields the
// new feature added (desynced in heartbeats, capabilities expectation)
// and by ignoring CONTROL_MODE broadcasts. The wire format for existing
// events must stay byte-compatible: the relay must accept old payloads
// and must not inject new fields old clients would misread.
// =====================================================================
// --- BC-1: Old-client heartbeat (no `desynced` field) is accepted and
// relayed without injecting `desynced`. Old clients never sent the
// field; the relay must strip it from the wire so we don't surprise
// them with unexpected keys. ---
const bcrid = 'bc-'+Date.now();
const bco = await c(), bcn = await c(); // bco = "old", bcn = "new"
await j(bco, bcrid, 'oldp'); await j(bcn, bcrid, 'newp');
bco._m.length = bcn._m.length = 0;
// Old-client heartbeat: every field an old build would send, NO `desynced`.
s(bco,'peer_status',{
status:'heartbeat', username:'old', tabTitle:'t', mediaTitle:'m',
playbackState:'playing', currentTime:42, volume:0.5, muted:false
});
let bcRelay = null; const bcStart = Date.now();
while (Date.now()-bcStart < 800 && !bcRelay) {
for (let i=0;i<bcn._m.length;i++){ const r=bcn._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ bcn._m.splice(i,1); bcRelay=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(bcRelay, 'old-style heartbeat relayed');
assert.ok(bcRelay.desynced === undefined, 'old-client heartbeat has no desynced on the wire (stripped)');
assert.equal(bcRelay.currentTime, 42, 'old-client currentTime preserved');
assert.equal(bcRelay.senderId, 'oldp', 'old-client senderId preserved');
close();
resetConnectionRate();
// --- BC-2: Old client in a host-only room — server still gates its events
// even though the client has no awareness of the mode. This is the
// key guarantee for mixed rooms during rollout: an old client can't
// drive a host-only room just because it ignores CONTROL_MODE. ---
const hmrid = 'hcmix-'+Date.now();
const hmo = await c(), hmg = await c(); // hmo = host (new), hmg = "old" guest
await j(hmo, hmrid, 'hmixhost'); await j(hmg, hmrid, 'hmixold');
hmo._m.length = hmg._m.length = 0;
s(hmo,'set_control_mode',{controlMode:'host-only'});
await w(hmo,'control_mode'); await w(hmg,'control_mode'); // old client's socket still receives it; old client would ignore
hmo._m.length = hmg._m.length = 0;
// "Old" guest tries to drive — server must drop. Host must NOT receive it.
s(hmg,'pause',{currentTime:5});
let oldGated=false; try { await w(hmo,'pause',600); } catch { oldGated=true; }
assert.ok(oldGated, 'old-client pause dropped in host-only (server enforces regardless of client awareness)');
// Host's own command still relays to the old client's socket — old client
// applies it via its existing PLAY/PAUSE handler.
s(hmo,'pause',{currentTime:7}); await w(hmg,'pause');
close();
resetConnectionRate();
// --- BC-3: Mixed room with old + new client in 'everyone' mode — every
// event flows identically to pre-HCM. Confirms no regression in the
// default-mode relay path that could fragment a rolling-update room. ---
const mxrid = 'mix-'+Date.now();
const mxo = await c(), mxn = await c(); // mxo = old, mxn = new
await j(mxo, mxrid, 'oldmx'); await j(mxn, mxrid, 'newmx');
mxo._m.length = mxn._m.length = 0;
// Old → new
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
// New → old
mxo._m.length = mxn._m.length = 0;
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
close();
resetConnectionRate();
// --- BC-4: New-client heartbeat WITH `desynced` does not break an old
// client's receive path. The field is appended but old clients ignore
// unknown keys — verify the relay preserves every pre-HCM field and
// only adds `desynced`. ---
const b4rid = 'bc4-'+Date.now();
const b4old = await c(), b4new = await c();
await j(b4old, b4rid, 'b4o'); await j(b4new, b4rid, 'b4n');
b4old._m.length = b4new._m.length = 0;
s(b4new,'peer_status',{status:'heartbeat', desynced:true, currentTime:7, playbackState:'paused', username:'newp'});
let b4Relay = null; const b4Start = Date.now();
while (Date.now()-b4Start < 800 && !b4Relay) {
for (let i=0;i<b4old._m.length;i++){ const r=b4old._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ b4old._m.splice(i,1); b4Relay=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(b4Relay, 'new-client heartbeat relayed to old client');
assert.equal(b4Relay.desynced, true, 'desynced preserved for new recipients');
assert.equal(b4Relay.currentTime, 7, 'old fields preserved on the same relay');
assert.equal(b4Relay.senderId, 'b4n', 'senderId preserved');
close();
resetConnectionRate();
// --- Password room ---
const prid = 'pw-'+Date.now();
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');