From 6a748fb3f5e577c8c776d8fca0e295619a0b3778 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:03:55 +0200 Subject: [PATCH] fix(sync): harden async session and lobby races --- extension/async-session-guards.test.mjs | 89 ++++ extension/background.js | 384 ++++++++++++++---- .../canonical-media-state-background.test.mjs | 8 + extension/canonical-media-state.js | 2 +- extension/canonical-media-state.test.mjs | 15 + extension/content.js | 64 ++- extension/episode-lobby-background.test.mjs | 38 +- .../offline-media-intent-background.test.mjs | 6 +- extension/target-tab-lifecycle.test.mjs | 4 +- scripts/test-server-ws.mjs | 33 +- server/index.js | 38 +- tests/e2e/extension.spec.mjs | 68 ++++ 12 files changed, 644 insertions(+), 105 deletions(-) create mode 100644 extension/async-session-guards.test.mjs diff --git a/extension/async-session-guards.test.mjs b/extension/async-session-guards.test.mjs new file mode 100644 index 0000000..01615b0 --- /dev/null +++ b/extension/async-session-guards.test.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const extensionDir = path.dirname(fileURLToPath(import.meta.url)); +const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); + +function sourceBetween(startNeedle, endNeedle) { + const start = backgroundSource.indexOf(startNeedle); + const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return backgroundSource.slice(start, end); +} + +describe('async room-session guards', () => { + it('normalizes persisted room, peer, lobby and Force Sync state before restoration', () => { + const restore = sourceBetween('function ensureState()', '// Start restoration immediately'); + expect(restore).toContain("typeof data.currentRoom.roomId === 'string'"); + expect(restore).toContain('.map(createPeerData)'); + expect(restore).toContain('data.currentRoom.activeLobby,'); + expect(restore).toContain('Array.isArray(data.forceSyncAcks)'); + expect(restore).toContain('currentRoom && Number.isFinite(data.forceSyncDeadline)'); + expect(restore).toContain('const restoredEpisodeLobby = currentRoom'); + expect(restore).toContain('data.episodeLobby,'); + }); + + it('revalidates ROOM_DATA after every asynchronous join boundary', () => { + const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:'); + expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2); + expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby('); + }); + + it('does not return chat context after its room or target changed', () => { + const handler = sourceBetween("message.type === 'GET_CHAT_CONTEXT'", "message.type === 'CHAT_SEND'"); + expect(handler).toContain('const roomId = currentRoom.roomId'); + expect(handler).toContain('const isCurrentSession = () =>'); + expect(handler.indexOf('if (!isCurrentSession() || settings.roomId !== roomId)')) + .toBeGreaterThan(handler.indexOf('await loadLocale')); + expect(handler).not.toContain('roomId: currentRoom.roomId'); + }); + + it('drops heartbeats and episode transitions that cross a room or target switch', () => { + const heartbeat = sourceBetween("message.type === 'HEARTBEAT'", "message.type === 'INJECT_CONTENT_SCRIPT'"); + expect(heartbeat).toContain('const heartbeatRoomId = currentRoom?.roomId || null'); + expect(heartbeat).toContain("status: 'ignored_stale_session'"); + expect(heartbeat.indexOf('currentRoom?.roomId !== heartbeatRoomId')) + .toBeLessThan(heartbeat.indexOf('emit(EVENTS.PEER_STATUS')); + + const episode = sourceBetween("message.type === 'EPISODE_CHANGED'", "message.type === 'EPISODE_READY_LOCAL'"); + expect(episode).toContain('const isCurrentEpisodeContext = () =>'); + expect(episode.match(/if \(!isCurrentEpisodeContext\(\)/g)?.length).toBeGreaterThanOrEqual(2); + + const ready = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); + expect(ready).toContain('settings.roomId !== lobbyRoomId'); + + const privacy = sourceBetween("message.type === 'TITLE_PRIVACY_CHANGED'", "message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'"); + expect(privacy).toContain('currentRoom?.roomId !== privacyRoomId'); + }); + + it('resolves content-event awaits before mutating canonical or room state', () => { + const handler = sourceBetween("message.type === 'CONTENT_EVENT'", "message.type === 'FORCE_SYNC_ACK'"); + const processEventIndex = handler.indexOf('const processEvent = async () =>'); + const videoStateIndex = handler.indexOf('await getReadyTabVideoState(tabId)', processEventIndex); + const settingsIndex = handler.indexOf('const settings = await getSettings()', videoStateIndex); + const contextGuardIndex = handler.indexOf("sendResponse({ status: 'ignored_stale_session' })", settingsIndex); + const supersedeIndex = handler.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)', contextGuardIndex); + expect(videoStateIndex).toBeGreaterThan(-1); + expect(settingsIndex).toBeGreaterThan(videoStateIndex); + expect(contextGuardIndex).toBeGreaterThan(settingsIndex); + expect(handler).toContain('(eventRoomId && settings.roomId !== eventRoomId)'); + expect(supersedeIndex).toBeGreaterThan(contextGuardIndex); + }); + + it('serializes new connection attempts behind terminal room teardown', () => { + const teardown = sourceBetween('async function endRoomSession', 'async function leaveRoomAfterIdleGrace'); + expect(teardown).toContain('if (roomTeardownPromise) return roomTeardownPromise'); + expect(teardown).toContain('performRoomSessionTeardown(options)'); + + for (const [start, end] of [ + ["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"], + ["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"], + ["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"] + ]) { + expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()'); + } + }); +}); diff --git a/extension/background.js b/extension/background.js index a62a7c7..84e5b80 100644 --- a/extension/background.js +++ b/extension/background.js @@ -346,6 +346,9 @@ function requestCanonicalMediaRecoveryAttempt() { function clearCanonicalMediaRecovery() { resetCanonicalMediaRecoveryRetries(); + // The content command itself may still finish, but it belongs to the room + // being cleared and must not block the first recovery attempt of a new room. + canonicalRecoveryApplyInProgress = null; canonicalMediaStateTracker.clear(); persistCanonicalMediaRecovery(); } @@ -466,7 +469,9 @@ function ensureState() { // snapshot after the worker already continued with defaults. if (restorationTimedOut) return; clearTimeout(storageTimeout); - if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; + if (Number.isSafeInteger(data.expectedAcksCount) && data.expectedAcksCount >= 0) { + expectedAcksCount = data.expectedAcksCount; + } if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); userSelectedTabId = normalizeTabId(data.selectedTabId); userSelectedTabTitle = userSelectedTabId !== null && typeof data.selectedTabTitle === 'string' @@ -495,10 +500,22 @@ function ensureState() { } // Merge data from storage with any early-arriving state // New entries (added during boot) must stay at the top (index 0) - if (data.logs) logs = [...logs, ...data.logs].slice(0, 200); - if (data.history) history = [...history, ...data.history].slice(0, 20); - if (data.currentRoom) { - currentRoom = data.currentRoom; + if (Array.isArray(data.logs)) logs = [...logs, ...data.logs].slice(0, 200); + if (Array.isArray(data.history)) history = [...history, ...data.history].slice(0, 20); + if (data.currentRoom + && typeof data.currentRoom === 'object' + && typeof data.currentRoom.roomId === 'string' + && data.currentRoom.roomId) { + currentRoom = { ...data.currentRoom }; + currentRoom.peers = (Array.isArray(data.currentRoom.peers) ? data.currentRoom.peers : []) + .map(createPeerData) + .filter(candidate => candidate.peerId); + const restoredPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId)); + currentRoom.activeLobby = normalizeEpisodeLobby( + data.currentRoom.activeLobby, + Date.now(), + restoredPeerIds + ); // Host Control Mode: restore role/mode/capabilities from persisted room. controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = currentRoom.hostPeerId || null; @@ -516,12 +533,14 @@ function ensureState() { data.canonicalMediaRecovery, currentRoom?.roomId || null ); - if (data.lastActionState) lastActionState = data.lastActionState; - - if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) { - isForceSyncInitiator = data.isForceSyncInitiator; + if (data.lastActionState && typeof data.lastActionState === 'object') { + lastActionState = data.lastActionState; } - if (data.forceSyncAcks) { + + if (currentRoom && data.isForceSyncInitiator === true && isForceSyncInitiator === false) { + isForceSyncInitiator = true; + } + if (currentRoom && isForceSyncInitiator && Array.isArray(data.forceSyncAcks)) { const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]); forceSyncAcks = mergedAcks; } @@ -532,7 +551,7 @@ function ensureState() { if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt; // Recover Force Sync Timeout - if (data.forceSyncDeadline) { + if (currentRoom && Number.isFinite(data.forceSyncDeadline)) { const remaining = data.forceSyncDeadline - Date.now(); if (remaining > 0 && isForceSyncInitiator) { forceSyncTimeout = setTimeout(() => { @@ -547,8 +566,15 @@ function ensureState() { } // Recover Episode Lobby - if (data.episodeLobby && !episodeLobby) { - episodeLobby = data.episodeLobby; + const restoredEpisodeLobby = currentRoom + ? normalizeEpisodeLobby( + data.episodeLobby, + Date.now(), + new Set(currentRoom.peers.map(candidate => candidate.peerId)) + ) + : null; + if (restoredEpisodeLobby && !episodeLobby) { + episodeLobby = restoredEpisodeLobby; const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now(); if (lobbyRemaining > 0) { episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining); @@ -601,6 +627,7 @@ let currentServerUrl = null; let roomIdleSince = null; let lastContentHeartbeatAt = null; let connectIntent = false; +let roomTeardownPromise = null; const MAX_RECONNECT_ATTEMPTS = 20; // Backoff tuned so that at most ~8 connection attempts land in any 60s window, // keeping a single client comfortably under the server's per-IP connection @@ -644,20 +671,50 @@ let episodeLobbyTimeout = null; * @returns {object} Normalized peer data object. */ function createPeerData(raw) { + const source = raw && typeof raw === 'object' + ? raw + : (typeof raw === 'string' ? { peerId: raw } : {}); return { - peerId: raw.peerId || null, - username: raw.username || null, - tabTitle: raw.tabTitle || null, - mediaTitle: raw.mediaTitle || null, - playbackState: raw.playbackState || null, - currentTime: raw.currentTime != null ? raw.currentTime : null, - volume: raw.volume != null ? raw.volume : null, - muted: raw.muted != null ? raw.muted : null, - desynced: raw.desynced === true, // HCM: peer is watching on their own + peerId: typeof source.peerId === 'string' && source.peerId ? source.peerId.substring(0, 16) : null, + username: typeof source.username === 'string' ? source.username.substring(0, 30) : null, + tabTitle: typeof source.tabTitle === 'string' ? source.tabTitle.substring(0, 100) : null, + mediaTitle: typeof source.mediaTitle === 'string' ? source.mediaTitle.substring(0, 100) : null, + playbackState: source.playbackState === 'playing' || source.playbackState === 'paused' ? source.playbackState : null, + currentTime: Number.isFinite(source.currentTime) ? source.currentTime : null, + volume: Number.isFinite(source.volume) ? source.volume : null, + muted: typeof source.muted === 'boolean' ? source.muted : null, + desynced: source.desynced === true, // HCM: peer is watching on their own lastHeartbeat: Date.now() }; } +function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const expectedTitle = typeof value.expectedTitle === 'string' + ? value.expectedTitle.substring(0, 100) + : ''; + const initiatorPeerId = typeof value.initiatorPeerId === 'string' + ? value.initiatorPeerId.substring(0, 16) + : ''; + if (!expectedTitle + || !initiatorPeerId + || !Array.isArray(value.readyPeers) + || (allowedPeerIds && !allowedPeerIds.has(initiatorPeerId))) return null; + const readyPeers = [...new Set(value.readyPeers + .filter(candidate => typeof candidate === 'string' && candidate) + .map(candidate => candidate.substring(0, 16)) + .filter(candidate => !allowedPeerIds || allowedPeerIds.has(candidate)))]; + if (!readyPeers.includes(initiatorPeerId)) readyPeers.unshift(initiatorPeerId); + return { + expectedTitle, + initiatorPeerId, + readyPeers, + createdAt: Number.isFinite(value.createdAt) && value.createdAt > 0 + ? value.createdAt + : fallbackCreatedAt + }; +} + /** * Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI. * Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state. @@ -739,9 +796,13 @@ function withTitlePrivacy(payload, settings, keys) { function emitEpisodeLobbyForCurrentPrivacy() { if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; + const lobby = episodeLobby; + const roomId = currentRoom?.roomId || null; getSettings().then(settings => { - if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; - const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode); + if (episodeLobby !== lobby + || currentRoom?.roomId !== roomId + || settings.roomId !== roomId) return; + const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode); if (expectedTitle) { emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle }); } @@ -1113,7 +1174,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) return true; } -async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) { +async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left Room' } = {}) { webJoinCoordinator.invalidate(); connectIntent = false; reconnectFailed = false; @@ -1182,6 +1243,22 @@ async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = { updateBadgeStatus(); } +async function endRoomSession(options = {}) { + if (roomTeardownPromise) return roomTeardownPromise; + + const teardown = performRoomSessionTeardown(options); + roomTeardownPromise = teardown; + try { + return await teardown; + } finally { + if (roomTeardownPromise === teardown) roomTeardownPromise = null; + } +} + +async function waitForRoomTeardown() { + if (roomTeardownPromise) await roomTeardownPromise; +} + async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; await endRoomSession({ notifyServer: true, reason }); @@ -1883,7 +1960,7 @@ function markCanonicalMediaStateHandled(roomId, revision) { return true; } -function supersedeCanonicalMediaRecovery(reason) { +function supersedeCanonicalMediaRecovery(reason, action = null, payload = null) { const roomId = currentRoom?.roomId; const pending = canonicalMediaStateTracker.getPending(roomId); if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) { @@ -1894,7 +1971,9 @@ function supersedeCanonicalMediaRecovery(reason) { // a newer local or remote control is allowed to win. sendMessageToCurrentContent({ type: 'CANCEL_CANONICAL_MEDIA_STATE', - reason + reason, + action, + payload }).catch(() => {}); addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info'); return true; @@ -2055,6 +2134,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } switch (event) { case EVENTS.ROOM_DATA: { + if (typeof data.roomId !== 'string' || !data.roomId) { + addLog('Ignored malformed ROOM_DATA without a room ID', 'warn'); + return; + } if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) { addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn'); return; @@ -2074,7 +2157,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con broadcastControlMode(); markRoomPotentiallyIdle(); if (currentRoom && Array.isArray(currentRoom.peers)) { - currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() }); + currentRoom.peers = currentRoom.peers + .map(createPeerData) + .filter(candidate => candidate.peerId); // Clear sequence tracking for peers that are no longer in the room const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p)); @@ -2095,24 +2180,31 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con entry?.event === EVENTS.EPISODE_LOBBY && (!entry.roomId || entry.roomId === data.roomId) ); - if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby) { + const authoritativeLobby = normalizeEpisodeLobby( + data.activeLobby, + Date.now(), + new Set(currentRoom.peers.map(candidate => candidate.peerId)) + ); + if (data.activeLobby && !authoritativeLobby) { + addLog('Ignored malformed active Episode Lobby in ROOM_DATA', 'warn'); + } + currentRoom.activeLobby = authoritativeLobby; + if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby) { clearEpisodeLobbyState(); addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info'); - } else if (data?.activeLobby) { + } else if (authoritativeLobby) { const sameLobby = episodeLobby - && episodeLobby.expectedTitle === data.activeLobby.expectedTitle - && episodeLobby.initiatorPeerId === data.activeLobby.initiatorPeerId; + && episodeLobby.expectedTitle === authoritativeLobby.expectedTitle + && episodeLobby.initiatorPeerId === authoritativeLobby.initiatorPeerId; if (!sameLobby && episodeLobbyTimeout) { clearTimeout(episodeLobbyTimeout); episodeLobbyTimeout = null; } episodeLobby = { - expectedTitle: data.activeLobby.expectedTitle, - initiatorPeerId: data.activeLobby.initiatorPeerId, - readyPeers: data.activeLobby.readyPeers, + ...authoritativeLobby, createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt) ? episodeLobby.createdAt - : Date.now() + : authoritativeLobby.createdAt }; persistEpisodeLobby(); broadcastLobbyUpdate(); @@ -2143,7 +2235,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); - if (expectedConnectionGeneration !== connectionGeneration) return; + if (expectedConnectionGeneration !== connectionGeneration + || currentRoom?.roomId !== data.roomId) return; // Resolve replay privacy before declaring ROOM_DATA complete. Local // commands remain queued during this await, and any intervening // role/lobby event is reflected by the policy below before the @@ -2151,7 +2244,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con const replaySettings = eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null; - if (expectedConnectionGeneration !== connectionGeneration) return; + if (expectedConnectionGeneration !== connectionGeneration + || currentRoom?.roomId !== data.roomId) return; awaitingRoomData = false; pendingRoomDataRoomId = null; @@ -2162,7 +2256,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con canControl: !lostRoomAuthority, activeLobby: !!episodeLobby, desynced: hcmDesynced, - authoritativeLobby: !!data.activeLobby + authoritativeLobby: !!authoritativeLobby }, lostRoomAuthority ? 'Host Control role changed while offline' : (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy')); @@ -2296,7 +2390,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con _persistLastSeq(); } if (isCanonicalSupersedingControl(event, data)) { - supersedeCanonicalMediaRecovery(`newer ${event}`); + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); } if (data.senderId) { addToHistory(event, data.senderId); @@ -2360,7 +2454,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } - supersedeCanonicalMediaRecovery(`newer ${event}`); + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -2495,28 +2589,40 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } break; case EVENTS.EPISODE_LOBBY: - if (data.senderId && data.expectedTitle) { + if (typeof data.senderId === 'string' + && typeof data.expectedTitle === 'string' + && data.expectedTitle + && currentRoom?.peers?.some(peer => (typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) { + const expectedTitle = data.expectedTitle.substring(0, 100); + const incomingLobby = normalizeEpisodeLobby({ + expectedTitle, + initiatorPeerId: data.senderId, + readyPeers: data.authoritative === true && Array.isArray(data.readyPeers) + ? data.readyPeers + : [data.senderId], + createdAt: Date.now() + }, Date.now(), new Set(currentRoom.peers.map(peer => peer.peerId))); + if (!incomingLobby) { + addLog(`Ignored malformed Episode lobby from ${data.senderId}`, 'warn'); + break; + } supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { - currentRoom.activeLobby = { - expectedTitle: data.expectedTitle, - initiatorPeerId: data.senderId, - readyPeers: [data.senderId] - }; + currentRoom.activeLobby = incomingLobby; if (storageInitialized) chrome.storage.session.set({ currentRoom }); } - addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); + addLog(`Episode lobby from ${data.senderId}: "${expectedTitle}"`, 'info'); // If we already have a lobby for this same title, treat as dedup - if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) { + if (episodeLobby + && episodeLobby.initiatorPeerId === data.senderId + && sameEpisode(episodeLobby.expectedTitle, expectedTitle)) { break; // Already tracking this lobby } // Cancel any existing lobby before starting a new one if (episodeLobby) clearEpisodeLobbyState(); episodeLobby = { - expectedTitle: data.expectedTitle, - initiatorPeerId: data.senderId, - readyPeers: [data.senderId], // Initiator is already ready + ...incomingLobby, createdAt: Date.now() }; persistEpisodeLobby(); @@ -2531,7 +2637,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con if (!isNaN(tabId)) { sendMessageToCurrentContent({ type: 'EPISODE_LOBBY', - expectedTitle: data.expectedTitle + expectedTitle }).catch(() => {}); } } @@ -2541,6 +2647,15 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con { const lobby = episodeLobby; if (!lobby || !data.senderId) break; + const senderPresent = currentRoom?.peers?.some(peer => + (typeof peer === 'object' ? peer.peerId : peer) === data.senderId + ); + if (!senderPresent + || (data.expectedTitle + && !sameEpisode(data.expectedTitle, lobby.expectedTitle))) { + addLog(`Ignored stale Episode ready from ${data.senderId}`, 'warn'); + break; + } let readyAdded = false; if (!lobby.readyPeers.includes(data.senderId)) { lobby.readyPeers.push(data.senderId); @@ -2558,6 +2673,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } break; case EVENTS.EPISODE_LOBBY_CANCEL: + if (typeof data.senderId !== 'string' || !currentRoom?.peers?.some(peer => + (typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) { + addLog(`Ignored Episode lobby cancellation from unknown peer ${data.senderId || 'unknown'}`, 'warn'); + break; + } supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { currentRoom.activeLobby = null; @@ -2591,7 +2711,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } function executeForceSync() { - supersedeCanonicalMediaRecovery('local force_sync_execute'); + supersedeCanonicalMediaRecovery('local force_sync_execute', EVENTS.FORCE_SYNC_EXECUTE); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); @@ -2749,8 +2869,13 @@ function checkEpisodeLobbyCompletion() { // 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) { + const participatingPeerIds = new Set(peers + .filter(candidate => !(typeof candidate === 'object' && candidate.desynced)) + .map(candidate => typeof candidate === 'object' ? candidate.peerId : candidate) + .filter(Boolean)); + const readyParticipatingCount = episodeLobby.readyPeers + .filter(candidate => participatingPeerIds.has(candidate)).length; + if (readyParticipatingCount >= participatingPeerIds.size) { executeEpisodeLobby(); } } @@ -4384,6 +4509,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } if (message.type === 'CONNECT') { + await waitForRoomTeardown(); webJoinCoordinator.invalidate(); const settings = await getSettings(); connectIntent = !!settings.roomId; @@ -4422,6 +4548,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'RETRY_CONNECT') { + await waitForRoomTeardown(); connectIntent = true; reconnectFailed = false; reconnectStartTime = null; @@ -4508,9 +4635,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ supported: false, hasKey: false }); return; } + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const isCurrentSession = () => generation === chatSessionGeneration + && currentRoom?.roomId === roomId + && Number(currentTabId) === tabId + && isCurrentContentSender(sender); const settings = await getSettings(); const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']); await loadLocale(localeData.locale || getSystemLanguage()); + if (!isCurrentSession() || settings.roomId !== roomId) { + sendResponse({ supported: false, hasKey: false, status: 'session_changed' }); + return; + } const translated = key => { const value = getMessage(key); return value === key ? '' : value; @@ -4522,7 +4660,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), eventNotifications: localeData.browserNotifications === true, peerId, - roomId: currentRoom.roomId, + roomId, activity: chatActivityStore.snapshot(), strings: { title: translated('CHAT_TITLE'), @@ -4697,6 +4835,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { addLog(`Canonical media state r${pending.mediaState.revision} skipped: local guest chose desynced mode`, 'info'); } } + if (episodeLobby) { + if (hcmDesynced && episodeLobby.initiatorPeerId === peerId) { + cancelEpisodeLobby('Initiator entered solo mode'); + } else { + checkEpisodeLobbyCompletion(); + } + } sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { await endRoomSession({ notifyServer: true, reason: 'Left Room' }); @@ -4712,6 +4857,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { emit(EVENTS.GET_ROOMS, {}); sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { + await waitForRoomTeardown(); const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; const roomId = normalizeRoomId(rawRoomId); const chatKey = validateChatSecret(rawChatKey); @@ -4843,6 +4989,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { } } const processEvent = async () => { + const eventRoomId = currentRoom?.roomId || pendingRoomDataRoomId || null; + const eventTabId = normalizeTabId(currentTabId); + const isCurrentEventContext = () => (currentRoom?.roomId || pendingRoomDataRoomId || null) === eventRoomId + && normalizeTabId(currentTabId) === eventTabId + && (!senderIsContent || isCurrentContentSender(sender)); // Host Control Mode (sender-side): a non-controller in host-only mode must // not drive the room. Don't broadcast; hand the action back to content.js so // it can snap the local player back / offer desync. @@ -4866,7 +5017,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // event (the list is updated synchronously on PEER_STATUS join/leave), // never cached, so the instant a peer joins we resume sending. const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0; - const hasOtherPeers = otherCount > 0; + let hasOtherPeers = otherCount > 0; // Force Sync only makes sense with other peers. Solo it is a no-op: // skip the pause/seek + ACK-wait entirely (no freeze, no server traffic). @@ -4894,14 +5045,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { payload.targetTime = targetTime; } - if (isCanonicalSupersedingControl(message.action, payload)) { - supersedeCanonicalMediaRecovery(`local ${message.action}`); - } - const timestamp = Date.now(); - localSeq++; - chrome.storage.session.set({ localSeq }); - updateLastAction(message.action, 'You', timestamp); - const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime); if (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) { const tabId = currentTabId ? parseInt(currentTabId) : NaN; @@ -4912,6 +5055,36 @@ async function handleAsyncMessage(message, sender, sendResponse) { } } } + if (!isCurrentEventContext()) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + + const isNonEssentialEvent = message.action === EVENTS.PLAY + || message.action === EVENTS.PAUSE + || message.action === EVENTS.SEEK; + const settings = await getSettings(); + if (!isCurrentEventContext() + || (eventRoomId && settings.roomId !== eventRoomId)) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + const currentOtherCount = currentRoom && Array.isArray(currentRoom.peers) + ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length + : 0; + hasOtherPeers = currentOtherCount > 0; + const shouldEmit = !(isNonEssentialEvent + && !hasOtherPeers + && !serverSupports(CAPABILITIES.MEDIA_STATE_V1)); + + if (isCanonicalSupersedingControl(message.action, payload)) { + supersedeCanonicalMediaRecovery(`local ${message.action}`); + } + const timestamp = Date.now(); + localSeq++; + chrome.storage.session.set({ localSeq }); + updateLastAction(message.action, 'You', timestamp); + lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); @@ -4955,15 +5128,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { addToHistory(message.action, 'You'); sendChatActivity(message.action, peerId, timestamp); - const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK; - if (isNonEssentialEvent - && !hasOtherPeers - && !serverSupports(CAPABILITIES.MEDIA_STATE_V1)) { + if (!shouldEmit) { sendResponse({ status: 'ok_solo' }); return; } - - const settings = await getSettings(); + const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']); emit(message.action, { ...outboundPayload, peerId }); sendResponse({ status: 'ok' }); @@ -5049,10 +5218,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { requestCanonicalMediaRecoveryAttempt(); markRoomUseful(); + const heartbeatRoomId = currentRoom?.roomId || null; + const heartbeatPayload = message.payload && typeof message.payload === 'object' + ? { ...message.payload } + : {}; getSettings().then(settings => { - const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle); + if ((sender.tab && !isCurrentContentSender(sender)) + || currentRoom?.roomId !== heartbeatRoomId + || settings.roomId !== heartbeatRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + const sharedTitles = getSharedTitleFields(settings, heartbeatPayload.mediaTitle); const statusPayload = { - ...message.payload, + ...heartbeatPayload, peerId, username: settings.username, tabTitle: sharedTitles.tabTitle, @@ -5068,10 +5247,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { me.tabTitle = sharedTitles.tabTitle; me.username = settings.username; me.mediaTitle = sharedTitles.mediaTitle; - me.playbackState = message.payload?.playbackState; - me.currentTime = message.payload?.currentTime; - me.volume = message.payload?.volume; - me.muted = message.payload?.muted; + me.playbackState = heartbeatPayload.playbackState; + me.currentTime = heartbeatPayload.currentTime; + me.volume = heartbeatPayload.volume; + me.muted = heartbeatPayload.muted; me.lastHeartbeat = Date.now(); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); @@ -5167,6 +5346,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } } + const episodeRoomId = currentRoom?.roomId || null; + const isCurrentEpisodeContext = () => currentRoom?.roomId === episodeRoomId + && (!sender.tab || isCurrentContentSender(sender)); const newTitle = message.payload && message.payload.newTitle; if (newTitle && extractEpisodeId(newTitle) === null) { @@ -5180,6 +5362,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { } const settings = await getSettings(); + if (!isCurrentEpisodeContext() || settings.roomId !== episodeRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode); if (!lobbyTitle) { addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info'); @@ -5189,12 +5375,22 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Check setting const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']); + if (!isCurrentEpisodeContext()) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } if (epSettings.autoSyncNextEpisode === false) { addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info'); sendResponse({ status: 'disabled' }); return; } + if (hcmDesynced) { + addLog(`Episode change ("${lobbyTitle}") — intentional solo mode, not creating a lobby.`, 'info'); + sendResponse({ status: 'desynced_skip' }); + return; + } + // 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 @@ -5223,7 +5419,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { episodeLobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); - emit(EVENTS.EPISODE_READY, { peerId, title: lobbyTitle }); + emit(EVENTS.EPISODE_READY, { + peerId, + title: lobbyTitle, + expectedTitle: episodeLobby.expectedTitle + }); checkEpisodeLobbyCompletion(); } sendResponse({ status: 'ready_sent' }); @@ -5234,7 +5434,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (episodeLobby) clearEpisodeLobbyState(); // Create new lobby - supersedeCanonicalMediaRecovery('local episode_lobby'); + supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE); episodeLobby = { expectedTitle: lobbyTitle, initiatorPeerId: peerId, @@ -5273,10 +5473,14 @@ async function handleAsyncMessage(message, sender, sendResponse) { } // Content script confirmed it loaded the lobby episode const lobby = episodeLobby; + const lobbyRoomId = currentRoom?.roomId || null; + const isCurrentLobbyContext = () => episodeLobby === lobby + && currentRoom?.roomId === lobbyRoomId + && (!sender.tab || isCurrentContentSender(sender)); if (lobby && message.payload && sameEpisode(message.payload.title, lobby.expectedTitle)) { if (!lobby.readyPeers.includes(peerId)) { const settings = await getSettings(); - if (episodeLobby !== lobby) { + if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId) { sendResponse({ status: 'ignored_stale_lobby' }); return; } @@ -5288,17 +5492,27 @@ async function handleAsyncMessage(message, sender, sendResponse) { lobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); - emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle }); + emit(EVENTS.EPISODE_READY, { + peerId, + title: readyTitle, + expectedTitle: lobby.expectedTitle + }); addLog(`Local episode ready: "${readyTitle || lobby.expectedTitle}"`, 'success'); checkEpisodeLobbyCompletion(); } } sendResponse({ status: 'ok' }); } else if (message.type === 'TITLE_PRIVACY_CHANGED') { + const privacyRoomId = currentRoom?.roomId || null; + const privacyLobby = episodeLobby; const settings = await getSettings(); - if (episodeLobby && episodeLobby.initiatorPeerId === peerId) { - const nextLobbyTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode); - if (!nextLobbyTitle || nextLobbyTitle !== episodeLobby.expectedTitle) { + if (currentRoom?.roomId !== privacyRoomId || settings.roomId !== privacyRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + if (episodeLobby === privacyLobby && privacyLobby?.initiatorPeerId === peerId) { + const nextLobbyTitle = sanitizeSharedTitle(privacyLobby.expectedTitle, settings.mediaTitlePrivacyMode); + if (!nextLobbyTitle || nextLobbyTitle !== privacyLobby.expectedTitle) { cancelEpisodeLobby('Title privacy changed'); } } diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index f02c7b2..09af527 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -73,6 +73,8 @@ describe('canonical ROOM_DATA recovery contract', () => { const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt'); expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length'); expect(retry).toContain('latest?.mediaState.revision !== expectedRevision'); + const clear = functionBody(backgroundSource, 'clearCanonicalMediaRecovery', 'invalidateChatSession'); + expect(clear).toContain('canonicalRecoveryApplyInProgress = null'); }); it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => { @@ -91,6 +93,8 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)'); expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)'); expect(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'"); + expect(supersede).toContain('action,'); + expect(supersede).toContain('payload'); expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)'); @@ -118,6 +122,10 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(contentSource.slice(contentHandlerStart, serverCommandStart)) .toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)'); expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'"); + expect(contentSource).toContain('cancelCanonicalMediaApply(action, findVideo(), false, payload)'); + expect(contentSource).toContain('restorationGeneration !== canonicalMediaApplyGeneration'); + expect(contentSource).toContain('holdCanonicalRestorePlaySuppression()'); + expect(contentSource).toContain('consumeCanonicalRestorePlaySuppression()'); expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)'); }); }); diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js index 55b7898..2daf2f5 100644 --- a/extension/canonical-media-state.js +++ b/extension/canonical-media-state.js @@ -157,7 +157,7 @@ export function createCanonicalMediaStateTracker() { const restoredPending = validateCanonicalMediaState(value.pending?.mediaState); if (value.pending?.roomId === roomId && restoredPending - && restoredPending.revision >= appliedRevision + && restoredPending.revision > appliedRevision && restoredPending.revision >= knownRevision) { pending = { roomId, diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs index eac7af1..1d88bb5 100644 --- a/extension/canonical-media-state.test.mjs +++ b/extension/canonical-media-state.test.mjs @@ -164,4 +164,19 @@ describe('canonical media state tracker', () => { expect(otherRoom.restore(stored, 'room-b')).toBe(false); expect(otherRoom.getPending('room-b')).toBeNull(); }); + + it('does not resurrect a pending snapshot that was already applied', () => { + const tracker = createCanonicalMediaStateTracker(); + expect(tracker.restore({ + roomId: 'room-a', + knownRevision: 7, + appliedRevision: 7, + pending: { + roomId: 'room-a', + mediaState: state(7), + receivedAt: 1_000 + } + }, 'room-a')).toBe(true); + expect(tracker.getPending('room-a')).toBeNull(); + }); }); diff --git a/extension/content.js b/extension/content.js index 048defe..4fdfbad 100644 --- a/extension/content.js +++ b/extension/content.js @@ -91,6 +91,7 @@ // While a timer exists, matching native events are consumed and not relayed. // Timers self-clean after 300ms if the native event never fires. let _suppressTimers = {}; + const canonicalRestorePlaySuppressions = new Set(); let canonicalMediaApplyGeneration = 0; let canonicalSupersedingLocalState = null; @@ -108,6 +109,25 @@ } } + function holdCanonicalRestorePlaySuppression() { + const hold = { timeout: null }; + hold.timeout = setTimeout(() => canonicalRestorePlaySuppressions.delete(hold), 5000); + canonicalRestorePlaySuppressions.add(hold); + return hold; + } + + function releaseCanonicalRestorePlaySuppression(hold) { + if (!canonicalRestorePlaySuppressions.delete(hold)) return; + clearTimeout(hold.timeout); + } + + function consumeCanonicalRestorePlaySuppression() { + const hold = canonicalRestorePlaySuppressions.values().next().value; + if (!hold) return false; + releaseCanonicalRestorePlaySuppression(hold); + return true; + } + // --- Seek Relay Filtering --- // Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks // from being relayed to peers as user-initiated seeks. @@ -1237,14 +1257,25 @@ return canonicalMediaApplyGeneration; } - function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) { + function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) { canonicalMediaApplyGeneration++; - if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) { + const storesPlaybackIntent = action === EVENTS.PLAY + || action === EVENTS.PAUSE + || action === EVENTS.SEEK + || action === EVENTS.FORCE_SYNC_PREPARE + || action === EVENTS.FORCE_SYNC_EXECUTE; + if (storesPlaybackIntent) { + const payloadTime = Number.isFinite(data?.targetTime) + ? data.targetTime + : (Number.isFinite(data?.currentTime) ? data.currentTime : null); + const videoTime = video ? getSyncCurrentTime(video) : null; canonicalSupersedingLocalState = { - playbackState: action === EVENTS.PLAY + playbackState: action === EVENTS.PLAY || action === EVENTS.FORCE_SYNC_EXECUTE ? 'playing' - : (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')), - currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null + : (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE + ? 'paused' + : (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)), + currentTime: payloadTime ?? videoTime }; } else if (!preserveLocalState) { canonicalSupersedingLocalState = null; @@ -1257,8 +1288,9 @@ } async function restoreSupersedingLocalState(video) { + const restorationGeneration = canonicalMediaApplyGeneration; const state = canonicalSupersedingLocalState; - if (!state || !video) return; + if (!state || !video || destroyed || video.isConnected === false) return; if (state.playbackState === 'paused' && !video.paused) { _setSuppress('paused'); @@ -1273,13 +1305,21 @@ } if (state.playbackState === 'playing' && video.paused) { _setSuppress('playing'); + const playSuppression = holdCanonicalRestorePlaySuppression(); try { await video.play(); } catch (error) { _clearSuppress('playing'); reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn'); + } finally { + releaseCanonicalRestorePlaySuppression(playSuppression); } } + // A delayed play() can settle after an even newer command already ran. + // Re-assert that newest intent so the old promise cannot win last. + if (!destroyed && restorationGeneration !== canonicalMediaApplyGeneration) { + await restoreSupersedingLocalState(video); + } } function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) { @@ -1500,7 +1540,12 @@ const preserveLocalState = message.reason === `local ${EVENTS.PLAY}` || message.reason === `local ${EVENTS.PAUSE}` || message.reason === `local ${EVENTS.SEEK}`; - cancelCanonicalMediaApply(null, null, preserveLocalState); + cancelCanonicalMediaApply( + message.action, + findVideo(), + preserveLocalState, + message.payload + ); sendResponse({ status: 'cancelled' }); return true; } @@ -1552,7 +1597,7 @@ } if (syncActions.includes(action)) { - cancelCanonicalMediaApply(); + cancelCanonicalMediaApply(action, findVideo(), false, payload); } if (action === EVENTS.PLAY) { @@ -1863,6 +1908,7 @@ _clearSuppress(eventState); return; } + if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return; if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { cancelCanonicalMediaApply(action, video); @@ -2327,6 +2373,8 @@ for (const timer of Object.values(_suppressTimers)) clearTimeout(timer); _suppressTimers = {}; + for (const hold of canonicalRestorePlaySuppressions) clearTimeout(hold.timeout); + canonicalRestorePlaySuppressions.clear(); for (const timer of lifecycleTimeouts) clearTimeout(timer); lifecycleTimeouts.clear(); for (const timer of seekPollTimers) clearInterval(timer); diff --git a/extension/episode-lobby-background.test.mjs b/extension/episode-lobby-background.test.mjs index c8e3035..de4a02b 100644 --- a/extension/episode-lobby-background.test.mjs +++ b/extension/episode-lobby-background.test.mjs @@ -30,10 +30,11 @@ describe('episode lobby completion races', () => { it('revalidates the lobby after awaiting settings for a local ready', () => { const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); const awaitIndex = handler.indexOf('const settings = await getSettings()'); - const guardIndex = handler.indexOf('if (episodeLobby !== lobby)'); + const guardIndex = handler.indexOf('if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId)'); const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)'); expect(handler).toContain('const lobby = episodeLobby'); + expect(handler).toContain('const isCurrentLobbyContext = () => episodeLobby === lobby'); expect(awaitIndex).toBeGreaterThan(-1); expect(guardIndex).toBeGreaterThan(awaitIndex); expect(mutationIndex).toBeGreaterThan(guardIndex); @@ -48,4 +49,39 @@ describe('episode lobby completion races', () => { expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex); expect(execute).toContain('chrome.storage.session.set({ currentRoom })'); }); + + it('validates restored and authoritative lobby state before using readyPeers', () => { + expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)'); + expect(backgroundSource).toContain('data.currentRoom.activeLobby,'); + expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby('); + expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))'); + }); + + it('rejects stale or unknown ready senders and correlates new ready frames to the lobby', () => { + const remoteReady = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:'); + expect(remoteReady).toContain('const senderPresent = currentRoom?.peers?.some'); + expect(remoteReady).toContain('!sameEpisode(data.expectedTitle, lobby.expectedTitle)'); + + const localReady = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); + expect(localReady).toContain('expectedTitle: lobby.expectedTitle'); + }); + + it('adopts authoritative correction data after the relay rejects a competing lobby', () => { + const remoteLobby = sourceBetween('case EVENTS.EPISODE_LOBBY:', 'case EVENTS.EPISODE_READY:'); + expect(remoteLobby).toContain('data.authoritative === true && Array.isArray(data.readyPeers)'); + expect(remoteLobby).toContain('currentRoom.activeLobby = incomingLobby'); + }); + + it('counts only ready peers who still participate in lobby completion', () => { + const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()'); + expect(completion).toContain('const participatingPeerIds = new Set(peers'); + expect(completion).toContain('participatingPeerIds.has(candidate)'); + expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size'); + }); + + it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => { + const desync = sourceBetween("message.type === 'HCM_DESYNC_STATE'", "message.type === 'LEAVE_ROOM'"); + expect(desync).toContain("cancelEpisodeLobby('Initiator entered solo mode')"); + expect(desync).toContain('checkEpisodeLobbyCompletion()'); + }); }); diff --git a/extension/offline-media-intent-background.test.mjs b/extension/offline-media-intent-background.test.mjs index 99154d8..2fe02a7 100644 --- a/extension/offline-media-intent-background.test.mjs +++ b/extension/offline-media-intent-background.test.mjs @@ -46,8 +46,8 @@ describe('offline media intent background integration', () => { expect(canonicalIndex).toBeLessThan(flushIndex); expect(roomData).toContain('activeLobby: !!episodeLobby'); expect(roomData).toContain('desynced: hcmDesynced'); - expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)'); - expect(roomData).toContain('authoritativeLobby: !!data.activeLobby'); + expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)'); + expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby'); }); it('clears queued room intent on failed join, leave and room switch paths', () => { @@ -59,7 +59,7 @@ describe('offline media intent background integration', () => { backgroundSource.indexOf("message.type === 'CLEAR_LOGS'") ); expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })"); - expect(functionBody('endRoomSession', 'leaveRoomAfterIdleGrace')).toContain('forceDisconnect()'); + expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()'); const retryHandler = backgroundSource.slice( backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"), backgroundSource.indexOf("message.type === 'GET_STATUS'") diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 79918fd..0bda1e4 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -99,8 +99,8 @@ describe('target tab lifecycle', () => { }); it('routes every terminal room exit through the full target unhook', () => { - const teardownStart = backgroundSource.indexOf('async function endRoomSession'); - const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart); + const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown'); + const teardownEnd = backgroundSource.indexOf('async function endRoomSession', teardownStart); const teardownSource = backgroundSource.slice(teardownStart, teardownEnd); expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())'); expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())')) diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index bdf2d0f..ffffb7a 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -49,10 +49,11 @@ try { // --- Pool: 2 peers in 1 room, test everything --- const rid = 't-'+Date.now(); - const p1 = await c(), p2 = await c(); + const p1 = await c(), p2 = await c(), p3 = await c(); // Room + join - await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0; + await j(p1, rid, 'a'); await j(p2, rid, 'b'); await j(p3, rid, 'c'); + p1._m.length = p2._m.length = p3._m.length = 0; // Relay s(p1,'play',{currentTime:10}); await w(p2,'play'); @@ -68,7 +69,33 @@ try { s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack'); // Lobby - s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby'); + s(p1,'episode_lobby',{expectedTitle:'S01E01'}); + await w(p2,'episode_lobby'); await w(p3,'episode_lobby'); + s(p3,'episode_lobby',{expectedTitle:'S01E02'}); + const authoritativeLobby = await w(p3, 'episode_lobby'); + assert.equal(authoritativeLobby.authoritative, true, + 'competing initiator receives an authoritative lobby correction'); + assert.equal(authoritativeLobby.expectedTitle, 'S01E01'); + assert.deepEqual(authoritativeLobby.readyPeers, ['a']); + let competingLobbyDropped = false; + try { await w(p2, 'episode_lobby', 500); } catch { competingLobbyDropped = true; } + assert.ok(competingLobbyDropped, 'relay drops a competing lobby while one is active'); + assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01'); + + s(p3,'episode_ready',{expectedTitle:'S01E02',title:'S01E02'}); + let staleReadyDropped = false; + try { await w(p2, 'episode_ready', 500); } catch { staleReadyDropped = true; } + assert.ok(staleReadyDropped, 'relay drops ready frames for an obsolete lobby'); + assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a']); + + // Missing expectedTitle remains accepted for old-extension compatibility. + s(p2,'episode_ready',{title:'S01E01'}); await w(p1,'episode_ready'); + assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a', 'b']); + + s(p3,'leave_room',{}); await w(p1,'peer_status'); + assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01', + 'an unrelated departure does not dissolve a lobby with two peers left'); + p1._m.length = p2._m.length = 0; // Leave s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left'); diff --git a/server/index.js b/server/index.js index 561dc61..9f3ffbf 100644 --- a/server/index.js +++ b/server/index.js @@ -284,7 +284,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t // 3.5. Clean up active lobby if a peer leaves if (room.activeLobby) { room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId); - if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) { + if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) { room.activeLobby = null; // Dissolve lobby } } @@ -415,6 +415,7 @@ io.on('connection', (socket) => { const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities); if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization + if (!socket.connected) return; try { // Protocol check @@ -450,6 +451,7 @@ io.on('connection', (socket) => { let lockPromise = roomCreationLocks.get(roomId); if (lockPromise) { await lockPromise; + if (!socket.connected) return; room = rooms.get(roomId); } if (!room) { @@ -511,6 +513,7 @@ io.on('connection', (socket) => { let peerLockPromise = peerJoinLocks.get(peerId); if (peerLockPromise) { await peerLockPromise; + if (!socket.connected) return; room = rooms.get(roomId); if (!room) { socket.emit(EVENTS.ERROR, { message: "Room no longer exists" }); @@ -521,6 +524,7 @@ io.on('connection', (socket) => { peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; }); peerJoinLocks.set(peerId, peerLockPromise); try { + if (!socket.connected) return; if (!createdByMe) { if (room.passwordHash) { if (!password || hashPassword(password) !== room.passwordHash) { @@ -710,6 +714,36 @@ io.on('connection', (socket) => { // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); + // The first live lobby owns the room until completion or + // cancellation. Drop concurrent lobby starts and stale ready + // frames instead of letting clients build divergent lobbies. + if (eventName === EVENTS.EPISODE_LOBBY && room.activeLobby) { + log('ROOM', `Dropped competing episode lobby from ${mapping.peerId}`); + socket.emit(EVENTS.EPISODE_LOBBY, { + senderId: room.activeLobby.initiatorPeerId, + peerId: room.activeLobby.initiatorPeerId, + expectedTitle: room.activeLobby.expectedTitle, + readyPeers: [...room.activeLobby.readyPeers], + authoritative: true + }); + return; + } + if (eventName === EVENTS.EPISODE_LOBBY && !relayPayload.expectedTitle) { + log('ROOM', `Dropped malformed episode lobby from ${mapping.peerId}`); + return; + } + if (eventName === EVENTS.EPISODE_READY) { + if (!room.activeLobby) { + log('ROOM', `Dropped stale episode ready from ${mapping.peerId}`); + return; + } + if (relayPayload.expectedTitle + && relayPayload.expectedTitle !== room.activeLobby.expectedTitle) { + log('ROOM', `Dropped episode ready for an obsolete lobby from ${mapping.peerId}`); + return; + } + } + const mediaStateNow = Date.now(); // Canonical Media State v1: mutate only after rate limiting, @@ -792,7 +826,7 @@ io.on('connection', (socket) => { socket.to(mapping.roomId).emit(eventName, relayPayload); // --- Side-effects: Server-side Episode Lobby Tracking --- - if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) { + if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) { room.activeLobby = { expectedTitle: relayPayload.expectedTitle, initiatorPeerId: mapping.peerId, diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 75331ab..f6be081 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -570,6 +570,74 @@ test('newer server command clears local recovery state before a delayed apply re expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false); }); +test('newer remote pause wins after a stale restoration play settles late', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/simple-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId } = await selectTargetTab(context, extensionId, url); + await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true'); + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => { + await chrome.scripting.executeScript({ + target: { tabId: selectedTabId }, + world: 'ISOLATED', + func: () => { + const video = document.querySelector('#player'); + if (!video) throw new Error('late restoration fixture video missing'); + video.pause(); + video.currentTime = 0; + const nativePlay = video.play.bind(video); + video.dataset.koalaDelayedPlayAttempts = '0'; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; + video.dataset.koalaDelayedPlayAttempts = String(attempt); + if (attempt === 1) { + return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200))); + } + return new Promise((resolve, reject) => setTimeout(() => { + nativePlay().then(resolve, reject); + }, 400)); + } + }); + } + }); + }, tabId)); + + const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, { + revision: 22, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1); + + // Capture a superseding play intent while leaving the fixture paused, so + // stale recovery has to enter its delayed restoration play path. + await page.locator('#player').evaluate(video => { + video.pause(); + }); + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate( + selectedTabId => chrome.tabs.sendMessage(selectedTabId, { + type: 'CANCEL_CANONICAL_MEDIA_STATE', + reason: 'local play', + action: 'play', + payload: { currentTime: 0 } + }), + tabId + )); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2); + + await sendContentServerCommand(context, extensionId, tabId, 'pause'); + await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' }); + await page.waitForTimeout(500); + expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true); +}); + test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => { test.setTimeout(45_000); const relay = await import('../../server/index.js');