fix(host-control-mode): hold desync invariant + stop Solo-badge flicker

Two issues surfaced by a holistic audit of the combined desync-persist +
reconcile-on-init changes:

1. Stale desync outside gated-guest state. hcmDesynced is persisted, but neither
   ROOM_DATA nor CONTROL_MODE cleared it when the local peer became host or the
   room switched to 'everyone'. Combined with reconcile-on-init, a desynced guest
   promoted to host (host-leave fallback) while content was reloading could
   re-adopt desynced=true — self-labelling "Solo" to all peers and ignoring host
   commands in 'everyone' mode. Enforce the invariant centrally
   (hcmEnforceDesyncInvariant: desynced ⟹ host-only && !host) on every role/mode
   change, and guard the content reconcile to only adopt when actually a gated guest.

2. Solo-badge flicker. The host-side PEER_STATUS handler set peer.desynced
   unconditionally, but the background-driven keepAlive heartbeat omits the field —
   so every ~30s it clobbered desynced to false, flickering the badge. Only update
   when present (matching the sibling fields), and include desynced in the
   background heartbeat so both heartbeat sources are consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-26 16:06:04 +02:00
parent 96e2207cf3
commit 95ff460856
2 changed files with 23 additions and 3 deletions
+20 -2
View File
@@ -680,6 +680,18 @@ async function connect() {
}
// Invariant: only a gated guest (host-only room AND not the host) can be
// "desynced". Any role/mode change that makes us the host, or switches the room
// to 'everyone', must clear the persisted flag — otherwise a stale value would
// mislabel us as "Solo" to peers and (in content) keep us ignoring host commands
// after the reason to is gone. Call after any controlMode/hostPeerId change.
function hcmEnforceDesyncInvariant() {
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amHost())) {
hcmDesynced = false;
if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false });
}
}
function broadcastControlMode() {
// Notify popup (role badge / host toggle) and the active content tab
// (so it can enable/disable the host-only guest gate).
@@ -943,6 +955,7 @@ function handleServerEvent(event, data) {
// Host Control Mode: adopt room role/mode on (re)join.
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = data.hostPeerId || null;
hcmEnforceDesyncInvariant();
broadcastControlMode();
markRoomPotentiallyIdle();
if (currentRoom && Array.isArray(currentRoom.peers)) {
@@ -1005,6 +1018,7 @@ function handleServerEvent(event, data) {
// Host Control Mode changed (toggle or host-leave fallback).
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = data.hostPeerId || null;
hcmEnforceDesyncInvariant();
if (currentRoom) {
currentRoom.controlMode = controlMode;
currentRoom.hostPeerId = hostPeerId;
@@ -1215,7 +1229,10 @@ function handleServerEvent(event, data) {
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
peer.desynced = data.desynced === true;
// Only update when present — the background-driven keepAlive
// heartbeat omits 'desynced', and clobbering it to false there
// would make the host's "Solo" badge flicker between heartbeats.
if (data.desynced !== undefined) peer.desynced = data.desynced === true;
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
const ignoreStatus = timeSinceReactive < 300;
@@ -1556,7 +1573,8 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
peerId,
status: 'heartbeat',
username: settings.username,
tabTitle: currentTabTitle
tabTitle: currentTabTitle,
desynced: hcmDesynced
});
}
}
+3 -1
View File
@@ -1440,7 +1440,9 @@
hcmHostPeerId = res.hostPeerId || null;
// Re-adopt persisted desync after a page reload so we don't start synced
// while background still relays us as "Solo" to the host (split-brain).
if (res.desynced && !hcmDesynced) {
// Only when we're actually a gated guest — never adopt a stale flag as the
// host or in 'everyone' mode (would self-label "Solo" / ignore commands).
if (res.desynced && res.controlMode === 'host-only' && !res.amHost && !hcmDesynced) {
hcmDesynced = true;
hcmShowBadge();
}