mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-27 19:17:11 +00:00
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:
+27
-8
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
@@ -93,14 +93,22 @@ const HOST_ONLY_GATED_ACTIONS = [
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
// Best-effort estimate of where the room (host) is right now, for guest snap-back.
|
||||
// Extrapolates from the host peer's last known state (±~1s). Used by content.js.
|
||||
// Extrapolates from the host peer's last known state. Used by content.js.
|
||||
function getHostSyncTarget() {
|
||||
if (!currentRoom || !Array.isArray(currentRoom.peers)) return null;
|
||||
const host = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === hostPeerId);
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
let targetTime = typeof host.currentTime === 'number' ? host.currentTime : null;
|
||||
if (targetTime !== null && host.playbackState === 'playing' && host.lastHeartbeat) {
|
||||
targetTime += Math.max(0, (Date.now() - host.lastHeartbeat) / 1000);
|
||||
// M-4: clamp extrapolation. lastHeartbeat is the *arrival* time of the host's
|
||||
// last heartbeat — beyond ~2 heartbeat intervals the host's true state is too
|
||||
// stale (they may have paused without the next heartbeat landing yet) and the
|
||||
// linear extrapolation would overshoot by tens of seconds. Cap it so the
|
||||
// guest snaps to a position within plausibility; the next heartbeat corrects.
|
||||
const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000;
|
||||
if (elapsedSec > 0 && elapsedSec <= 2 * HEARTBEAT_INTERVAL / 1000) {
|
||||
targetTime += elapsedSec;
|
||||
}
|
||||
}
|
||||
return { playbackState: host.playbackState || null, targetTime };
|
||||
}
|
||||
@@ -167,6 +175,11 @@ function ensureState() {
|
||||
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
|
||||
}
|
||||
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
|
||||
// L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true
|
||||
// is stale if our restored role is no longer "gated guest" (e.g. we became
|
||||
// the host, or the room is in 'everyone'). Without this, the first heartbeat
|
||||
// after SW restart would broadcast a bogus Solo flag for up to 15s.
|
||||
hcmEnforceDesyncInvariant();
|
||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||
|
||||
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
||||
@@ -1482,8 +1495,12 @@ function executeEpisodeLobby() {
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
|
||||
if (episodeLobby.readyPeers.length >= peerCount) {
|
||||
const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
|
||||
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
||||
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
||||
// completion: count only peers who actually participate.
|
||||
const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length;
|
||||
if (episodeLobby.readyPeers.length >= participatingCount) {
|
||||
executeEpisodeLobby();
|
||||
}
|
||||
}
|
||||
@@ -2174,9 +2191,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
||||
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
|
||||
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
||||
// host-only the host drives episode sync; the guest just follows / snaps back.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amHost()) {
|
||||
addLog(`Episode change ("${newTitle}") — host-only guest, not creating a lobby (host drives).`, 'info');
|
||||
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
|
||||
// guest just follows / snaps back. Use amController() for parity with the
|
||||
// CONTENT_EVENT gate and the server's controllers-based check.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
||||
addLog(`Episode change ("${newTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
sendResponse({ status: 'host_only_guest_skip' });
|
||||
return;
|
||||
}
|
||||
|
||||
+16
-3
@@ -961,6 +961,15 @@
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
// Host Control Mode: a desynced guest is watching on their own and must
|
||||
// not join the lobby flow. Otherwise they'd pause on title match, report
|
||||
// ready, but then ignore the host's FORCE_SYNC_* (hcmDesynced skip in
|
||||
// SERVER_COMMAND) and end up frozen in pause. They also can't be counted
|
||||
// toward lobby completion (background filters them out).
|
||||
if (hcmDesynced) {
|
||||
sendResponse({ status: 'ignored_desynced' });
|
||||
return true;
|
||||
}
|
||||
const expectedTitle = message.expectedTitle;
|
||||
if (expectedTitle) {
|
||||
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
|
||||
@@ -1504,9 +1513,13 @@
|
||||
chrome.runtime.sendMessage({ type: 'GET_HCM_STRINGS' }, (res) => {
|
||||
if (chrome.runtime.lastError || !res) return;
|
||||
Object.keys(hcmStrings).forEach(k => { if (res[k]) hcmStrings[k] = res[k]; });
|
||||
// If the badge is already showing (early desync), re-render it with the
|
||||
// localized text now that we have it.
|
||||
if (hcmBadgeHost) { hcmRemoveBadge(); hcmShowBadge(); }
|
||||
// If the badge is already showing (early desync), refresh its text in place.
|
||||
// Re-creating the host element nukes the click target mid-poll and can drop a
|
||||
// click that landed between remove() and the re-create (L-4).
|
||||
if (hcmBadgeHost) {
|
||||
const span = hcmBadgeHost.shadowRoot && hcmBadgeHost.shadowRoot.querySelector('span');
|
||||
if (span) span.textContent = '● ' + hcmStrings.badge;
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
+5
-1
@@ -1494,6 +1494,7 @@ elements.targetTab.addEventListener('change', () => {
|
||||
});
|
||||
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3)
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
@@ -1535,7 +1536,10 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
const peerCount = (status.peers || []).filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId).length;
|
||||
const syncTimeoutMs = peerCount === 0 ? 3000 : 12000;
|
||||
const forceSyncReset = () => {
|
||||
if (!forceSyncDone) {
|
||||
// Don't unlock a button that's locked because we became a guest mid-flight —
|
||||
// hcmGuestLocked is the source of truth for the lock state, and the next
|
||||
// CONTROL_MODE update restores the correct label.
|
||||
if (!forceSyncDone && !hcmGuestLocked) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user