diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f894cf..3ff67cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,70 @@ Ensures all peers are buffered and synchronized before resuming: > **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections. 4. **Resume**: All peers call `play()` simultaneously. +## 3.1 Canonical Media State v1 + +The relay keeps one optional, in-memory canonical playback state per active room. +Accepted `PLAY`, `PAUSE`, and `SEEK` commands advance a server-owned revision. +Playing positions advance lazily from the server update time; paused positions do +not. Heartbeats remain observational and do not mutate canonical state. + +`ROOM_DATA` materializes the playing position at snapshot creation and advertises +the optional `media-state-v1` capability. A joining/reconnecting client validates +the room/revision and optional privacy-sanitized media title, respects Host Control +solo mode and Episode Lobby, then queues an internal +`APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer +live commands. +That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, +the 2-second drift tolerance, and programmatic-event suppression. Recovery only +completes after playback state and position verification. Transient failures +retry after 250, 750, 1500, and 3000 ms, while target, heartbeat, and content-boot +signals can retrigger a pending attempt within that bound. A pending playing +snapshot advances from its local receipt time while waiting for a target, and +the apply creates no action history, notification, command ACK, or relay media +event. + +Current clients announce `media-state-v1` as an optional client capability and +continue sending accepted media controls while alone on a capable relay. If a +room instead falls back to one legacy client that suppresses solo controls, the +relay clears canonical state so a future joiner receives no snapshot rather than +known-unreliable playback truth. + +Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary +room-wide choreography; the next authorized `EXECUTE` commits the latest target +visible to peers to canonical state. Delayed execution is logged but remains +valid until newer accepted playback or lobby state explicitly supersedes it; an +untracked post-restart execute retains legacy relay liveness without inventing a +canonical target. The +offline queue replays an adjacent `PREPARE`/`EXECUTE` pair in one paced batch and +retains both if delivery fails. Per-sender +`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. The +relay rejects duplicate/regressing current-client media sequences before they +can diverge canonical truth from live receivers. + +## 3.2 Offline Media Intent + +Canonical Media State and Offline Media Intent have different ownership: + +- Canonical Media State is the relay's last accepted shared playback truth. +- Offline Media Intent is one room-scoped client representation of local + `PLAY`/`PAUSE`/`SEEK` commands that have not reached the relay yet. + +Contiguous offline controls merge into a bounded logical queue entry. Every +retained coordination event, including Force Sync and Episode Lobby events, is +an ordering barrier. Stale offline `PING`, `PONG`, heartbeat `PEER_STATUS`, and +`EVENT_ACK` frames are not persisted because they are no longer meaningful after +reconnect. Force Sync ACK remains transactional and is not dropped or merged. + +Media intent waits for the reconnecting room's `ROOM_DATA`. An authorized intent +takes precedence over the older canonical snapshot, materializes into the +minimum ordered legacy `SEEK` plus `PLAY`/`PAUSE` frames needed by old peers, and +thereby advances relay canonical state normally. With no intent, canonical +recovery is unchanged. Role loss discards room-driving intent before recovery; +intentional Host Control solo mode and an active Episode Lobby remain +authoritative. MV3 session restoration migrates the previous raw queue format, +preserves barriers, repairs `localSeq` monotonically, and rejects another room's +intent. + ## 4. Episode Auto-Sync Maintains continuous synchronized viewing when watching series: 1. **Detection**: `content.js` monitors the Media Session API for title changes. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9d119eb..b26bcf8 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -54,7 +54,7 @@ Payload: "password": "string, max 128, optional", "tabTitle": "string, max 100, optional", "mediaTitle": "string, max 100, optional", - "clientCapabilities": ["chat-v1"], + "clientCapabilities": ["chat-v1", "media-state-v1"], "protocolVersion": "string, max 16" } ``` @@ -83,13 +83,110 @@ Payload: "hostPeerId": "string or null", "controlMode": "everyone | host-only", "controllers": ["peerId"], - "capabilities": ["host-control", "co-host", "chat", "chat-v1"] + "mediaState": "canonical media state object or null", + "capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1"] } ``` `room_data` is sent to the joining socket. It is not the general broadcast used for every later room update. +## Canonical Media State v1 + +Relays advertise this optional recovery primitive with `"media-state-v1"` in +`room_data.capabilities`. Each active room stores at most one state: + +```json +{ + "revision": 42, + "playbackState": "playing", + "currentTime": 1234.5, + "updatedAt": 1787234425123, + "updatedBy": "peer-id" +} +``` + +The internal `currentTime` is the media position at server-owned `updatedAt`. +Playing state advances lazily when a snapshot is requested; paused state stays +fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot +spoof them. The wire snapshot contains the already-projected `currentTime`, +`revision`, `playbackState`, `updatedBy`, and an optional privacy-sanitized +`mediaTitle`, so clients never compare client and server wall clocks and retain +the existing cross-episode guard during recovery. + +Only accepted, sanitized room controls update canonical state: + +- `play` uses its valid `currentTime`, or an existing effective canonical position. +- `pause` uses its valid `currentTime`, or freezes an existing effective position. +- `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the + established playback state. +- a valid `force_sync_prepare` records only temporary coordination state. The + next authorized `force_sync_execute` commits the latest room-wide prepared + target as playing. A target older than `FORCE_SYNC_TARGET_DELAY_WARNING` is + logged but remains executable until newer room playback supersedes it, because + receivers are already paused. An execute without retained target still uses + the legacy wire fallback after relay restart but cannot invent canonical state. + The latest valid prepare is also the only post-demotion or reconnect execute + exemption in Host Control mode. + +`peer_status` heartbeats are observations and never rewrite canonical intent. +For current clients, the relay drops invalid, duplicate, or regressing `seq` +values on room-moving media commands before relay/canonical mutation. Legacy +clients without `seq` retain their existing behavior. Canonical `revision` +orders server-accepted room transitions; it does not replace per-sender order. + +On join/reconnect, a capable extension attempts to apply a valid snapshot +through an extension-internal recovery message. Recovery is only marked handled +after playback state and position verification. Transient failures use bounded +retries after 250, 750, 1500, and 3000 ms and can also be retriggered by target, +heartbeat, or content-boot signals. Existing seek/page-API and native-event +suppression prevent `play`, `pause`, or `seek` echoes. Pending recovery is scoped +to the room/revision in `chrome.storage.session`, waits for the selected media +target lifecycle, and projects a still-playing snapshot from its local receipt +time before a delayed apply. It is cleared on leave/switch. Intentional host-only +guest desync and an active Episode Lobby take precedence over snapshot recovery. + +Compatibility is additive: new clients use old behavior with a relay that omits +the capability; old clients ignore the extra `room_data` field from a new relay. +A new relay canonicalizes every accepted legacy `play`, `pause`, `seek`, and +matching Force Sync command regardless of `join_room.clientCapabilities`, while +relaying the established event names, payloads, and order unchanged. This makes +server-first rollout safe: old clients populate recovery state without needing to +understand or acknowledge it, and new clients consume it only when the relay +advertises the capability. No protocol-version or minimum-version bump is +required. New clients also announce `"media-state-v1"` in optional +`join_room.clientCapabilities`; this only tells a capable relay that the client +keeps canonical state current while alone. When a room falls back to one legacy +client, the relay clears potentially stale canonical state instead of recovering +future joiners to unverified solo playback. Offline `play`/`pause`/`seek` +compaction remains the separate +client-owned layer described below rather than part of the relay capability. + +### Offline media intent + +Offline media intent is client-side queue state, not a relay protocol feature. +An updated extension coalesces contiguous unsent `play`, `pause`, and `seek` +commands for one room. Retained non-media events are ordering barriers. On a +successful rejoin, the extension first reads `room_data` so current Host Control +and Episode Lobby authority can be applied, then replays an authorized intent as +the minimum existing legacy media-event sequence. Actual wire frames, rather +than logical queue entries, consume the paced reconnect budget. + +Pending authorized local intent takes precedence over an older canonical +snapshot because it has not yet been accepted by the relay. Its legacy replay +then updates canonical state like any other accepted control. Without pending +intent, canonical recovery proceeds normally. Intent made stale by a room switch, +role loss, intentional solo mode, or an active Episode Lobby is discarded and +cannot suppress server recovery. + +This requires no event, capability, ACK, protocol-version, or minimum-version +change. Old relays receive ordinary `play`/`pause`/`seek`; old peers see only the +same existing relayed events. + +Queued adjacent `force_sync_prepare` and `force_sync_execute` entries replay in +one paced batch. If either send fails, the full pair remains queued so a later +retry refreshes the prepared target before executing it. + ## Ephemeral encrypted chat Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep @@ -253,9 +350,14 @@ them with the same sanitized relay envelope as other room events, including ### `force_sync_execute` -Payload includes `targetTime`. In `host-only` mode, only controllers may send it. -The relay also allows a matching initiator's execute event after that initiator -started the prepare step, even if their controller state changed before execute. +The current extension sends sequence/action metadata but no target; the relay uses +the latest validated room target retained from `force_sync_prepare`. In +`host-only` mode, only controllers may send it. +The relay also allows that latest valid initiator's execute event after their +controller state changed or their socket reconnected before execute. Invalid +prepares are dropped and grant no exemption. Newer accepted playback/lobby state +explicitly supersedes the prepared target, so its delayed execute is dropped. +Otherwise, even a delayed execute is relayed to release paused receivers. ## Episode Lobby 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 c747bbe..84e5b80 100644 --- a/extension/background.js +++ b/extension/background.js @@ -8,6 +8,20 @@ import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChat import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js'; import { createChatActivityStore } from './chat-activity.js'; +import { canonicalMediaStateFromRoomData, createCanonicalMediaStateTracker } from './canonical-media-state.js'; +import { + drainQueuedBatch, + enqueueQueuedEvent, + isMediaQueueEvent, + isQueuedMediaIntent, + maxQueuedSequence, + mediaIntentNeedsSequenceReservation, + normalizePersistedEventQueue, + queuedMediaIntentCount, + queuedWireCount, + reconcileQueuedRoomIntent, + reserveLatestMediaIntentSequence +} from './offline-media-intent.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import { MEDIA_FRAME_ACCESS_REQUIRED, @@ -66,6 +80,7 @@ chrome.runtime.onStartup.addListener(() => { // --- State Management --- let socket = null; +let connectionGeneration = 0; let isConnecting = false; let peerId = null; // initialized via getPeerId() let currentRoom = null; @@ -233,11 +248,28 @@ let storageInitialized = false; let pendingLogs = []; let pendingHistory = []; let eventQueue = []; +let eventQueueVersion = 0; let flushTimer = null; // paces draining of eventQueue after (re)connect +let flushInProgress = null; let isNamespaceJoined = false; +let awaitingRoomData = false; +let pendingRoomDataRoomId = null; let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] }; let localSeq = 0; // Monotonically increasing command sequence for this peer const lastSeqBySender = {}; // senderId → last received seq (stale command guard) +const canonicalMediaStateTracker = createCanonicalMediaStateTracker(); +const CANONICAL_RECOVERY_RETRY_DELAYS = Object.freeze([250, 750, 1500, 3000]); +const CANONICAL_RECOVERY_RETRYABLE = new Set([ + 'no_video', + 'apply_failed', + 'no_response', + 'pending_unreachable', + 'stale_target', + 'superseded' +]); +let canonicalRecoveryRetryTimer = null; +let canonicalRecoveryRetryAttempt = 0; +let canonicalRecoveryApplyInProgress = null; // --- Host Control Mode --- let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only' @@ -256,7 +288,70 @@ function serverSupports(cap) { return Array.isArray(serverCapabilities) && serve function serverSupportsChat() { return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT); } -const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]); +const CLIENT_CAPABILITIES = Object.freeze([ + CAPABILITIES.CHAT_V1, + CAPABILITIES.MEDIA_STATE_V1 +]); + +function persistCanonicalMediaRecovery() { + if (!storageInitialized) return; + chrome.storage.session.set({ + canonicalMediaRecovery: canonicalMediaStateTracker.snapshot() + }).catch(() => {}); +} + +function resetCanonicalMediaRecoveryRetries() { + if (canonicalRecoveryRetryTimer) { + clearTimeout(canonicalRecoveryRetryTimer); + canonicalRecoveryRetryTimer = null; + } + canonicalRecoveryRetryAttempt = 0; +} + +function scheduleCanonicalMediaRecoveryRetry(reason) { + const roomId = currentRoom?.roomId; + const pending = canonicalMediaStateTracker.getPending(roomId); + if (!pending + || canonicalRecoveryRetryTimer + || canonicalRecoveryApplyInProgress + || canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length) { + return false; + } + const expectedRevision = pending.mediaState.revision; + const delay = CANONICAL_RECOVERY_RETRY_DELAYS[canonicalRecoveryRetryAttempt++]; + canonicalRecoveryRetryTimer = setTimeout(() => { + canonicalRecoveryRetryTimer = null; + const latest = canonicalMediaStateTracker.getPending(roomId); + if (currentRoom?.roomId !== roomId || latest?.mediaState.revision !== expectedRevision) return; + addLog(`Retrying canonical media state r${expectedRevision} after ${reason}`, 'info'); + tryApplyPendingCanonicalMediaState().catch(error => { + addLog(`Canonical media state retry failed: ${error.message}`, 'warn'); + }); + }, delay); + return true; +} + +function requestCanonicalMediaRecoveryAttempt() { + if (canonicalRecoveryRetryTimer + || canonicalRecoveryApplyInProgress + || canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length + || !canonicalMediaStateTracker.getPending(currentRoom?.roomId)) { + return false; + } + tryApplyPendingCanonicalMediaState().catch(error => { + addLog(`Canonical media state retry failed: ${error.message}`, 'warn'); + }); + return true; +} + +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(); +} function invalidateChatSession() { chatSessionGeneration++; @@ -274,6 +369,8 @@ function clearChatActivity() { async function clearFailedJoinCredentials() { webJoinCoordinator.invalidate(); connectIntent = false; + clearEventQueue(); + clearCanonicalMediaRecovery(); chatSecretGuard = ''; invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); @@ -349,9 +446,11 @@ function ensureState() { if (!restorationTask) { restorationTask = new Promise(resolve => { let resolved = false; + let restorationTimedOut = false; const done = () => { if (!resolved) { resolved = true; resolve(); } }; const storageTimeout = setTimeout(() => { + restorationTimedOut = true; addLog('Storage restoration timed out, continuing with defaults', 'warn'); storageInitialized = true; done(); @@ -364,10 +463,15 @@ function ensureState() { 'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo', 'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', - 'hcmDesynced', 'chatActivityTimeline' + 'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery' ], (data) => { + // A late callback must not resurrect a room, queue or canonical + // 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' @@ -396,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; @@ -413,13 +529,18 @@ function ensureState() { // 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); - if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) { - isForceSyncInitiator = data.isForceSyncInitiator; + canonicalMediaStateTracker.restore( + data.canonicalMediaRecovery, + currentRoom?.roomId || null + ); + 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; } @@ -430,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(() => { @@ -445,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); @@ -455,10 +583,19 @@ function ensureState() { } } - if (data.localSeq !== undefined && !isNaN(data.localSeq)) localSeq = data.localSeq; + if (Number.isSafeInteger(data.localSeq) && data.localSeq >= 0) localSeq = data.localSeq; + eventQueue = normalizePersistedEventQueue( + [...eventQueue, ...(Array.isArray(data.eventQueue) ? data.eventQueue : [])], + currentRoom?.roomId || null + ); + eventQueueVersion++; + // An MV3 restart can restore queue and sequence writes in either + // order. Never let the sender sequence fall behind persisted work. + localSeq = Math.max(localSeq, maxQueuedSequence(eventQueue)); if (data.lastSeqBySender && typeof data.lastSeqBySender === 'object') Object.assign(lastSeqBySender, data.lastSeqBySender); storageInitialized = true; + chrome.storage.session.set({ eventQueue, localSeq }).catch(() => {}); // Process any early logs/history that weren't captured in the spread if (pendingLogs.length > 0) { @@ -490,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 @@ -533,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. @@ -628,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 }); } @@ -675,7 +847,9 @@ function resolveServerUrl(settings) { return (settings.serverUrl && settings.useCustomServer) ? settings.serverUrl : OFFICIAL_SERVER_URL; } -function forceDisconnect() { +function forceDisconnect({ preserveEventQueue = false } = {}) { + connectionGeneration++; + resetCanonicalMediaRecoveryRetries(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -701,6 +875,8 @@ function forceDisconnect() { currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; + awaitingRoomData = false; + pendingRoomDataRoomId = null; invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; @@ -708,13 +884,17 @@ function forceDisconnect() { lastContentHeartbeatAt = null; forceSyncAcks.clear(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } - eventQueue = []; + flushInProgress = null; + if (!preserveEventQueue) { + eventQueue = []; + eventQueueVersion++; + } chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null, expectedAcksCount: 0, - eventQueue: [], + eventQueue, episodeLobby: null, roomIdleSince: null, lastContentHeartbeatAt: null @@ -883,7 +1063,12 @@ function adoptReportingFrame(sender) { const senderFrameId = normalizeFrameId(sender.frameId); if (senderFrameId === normalizeFrameId(currentTargetFrameId) && (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId)) { - return false; + if (currentTargetHasVideo === true) return false; + currentTargetHasVideo = true; + stopMediaDiscoveryPoll(); + chrome.storage.session.set({ currentTargetHasVideo }).catch(() => {}); + requestCanonicalMediaRecoveryAttempt(); + return true; } currentTargetFrameId = senderFrameId; @@ -897,6 +1082,7 @@ function adoptReportingFrame(sender) { currentTargetDocumentId, currentTargetHasVideo }).catch(() => {}); + requestCanonicalMediaRecoveryAttempt(); return true; } @@ -988,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; @@ -1002,6 +1188,7 @@ async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = { // it is still available, regardless of who initiated the exit. clearEpisodeLobbyState(); currentRoom = null; + clearCanonicalMediaRecovery(); clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; @@ -1056,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 }); @@ -1064,6 +1267,9 @@ async function leaveRoomAfterIdleGrace(reason) { async function connect() { if (isConnecting) return; isConnecting = true; + const startingGeneration = connectionGeneration; + let attemptGeneration = startingGeneration; + let handedOffToSocket = false; let finalUrl = ''; try { @@ -1072,6 +1278,8 @@ async function connect() { try { if (!peerId) peerId = await getPeerId(); settings = await getSettings(); + if (startingGeneration !== connectionGeneration) return; + pendingRoomDataRoomId = settings.roomId || currentRoom?.roomId || null; } catch (e) { throw new Error(`[Storage Error] ${e.message}`); } @@ -1128,6 +1336,8 @@ async function connect() { // --- Phase 4: WebSocket Init --- try { + canonicalMediaStateTracker.beginRecovery(settings.roomId || currentRoom?.roomId || null); + persistCanonicalMediaRecovery(); const url = new URL(finalUrl); url.pathname = '/socket.io/'; url.searchParams.set('EIO', '4'); @@ -1135,107 +1345,126 @@ async function connect() { url.searchParams.set('version', chrome.runtime.getManifest().version); url.searchParams.set('token', OFFICIAL_SERVER_TOKEN); - socket = new WebSocket(url.toString()); + const generation = ++connectionGeneration; + attemptGeneration = generation; + const connectionSocket = new WebSocket(url.toString()); + socket = connectionSocket; + + // --- Phase 5: Event Listeners --- + connectionSocket.onopen = () => { + if (generation !== connectionGeneration || socket !== connectionSocket) return; + reconnectAttempts = 0; + reconnectStartTime = null; + reconnectFailed = false; + addLog('WebSocket Connection Opened', 'success'); + chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {}); + isNamespaceJoined = false; + connectionSocket.send('40'); + }; + + connectionSocket.onmessage = async (event) => { + if (generation !== connectionGeneration || socket !== connectionSocket) return; + await ensureState(); + if (generation !== connectionGeneration || socket !== connectionSocket) return; + const msg = event.data; + if (msg === '2') { + connectionSocket.send('3'); + return; + } + if (msg.startsWith('0')) { + addLog(`Socket.IO Handshake: ${msg}`, 'info'); + } else if (msg.startsWith('40')) { + isConnecting = false; + isNamespaceJoined = true; + broadcastConnectionStatus('connected'); + startPing(); + addLog('Joined Namespace /', 'success'); + const joinedSettings = await getSettings(); + if (generation !== connectionGeneration || socket !== connectionSocket) return; + if (joinedSettings.roomId) { + awaitingRoomData = true; + pendingRoomDataRoomId = joinedSettings.roomId; + const sharedTitles = getSharedTitleFields(joinedSettings); + emit(EVENTS.JOIN_ROOM, { + roomId: joinedSettings.roomId, + password: joinedSettings.password, + peerId, + username: joinedSettings.username, + tabTitle: sharedTitles.tabTitle, + clientCapabilities: CLIENT_CAPABILITIES, + protocolVersion: PROTOCOL_VERSION + }); + } else { + awaitingRoomData = false; + pendingRoomDataRoomId = null; + flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn')); + } + } else if (msg.startsWith('42')) { + try { + const payload = JSON.parse(msg.substring(2)); + try { + await handleServerEvent(payload[0], payload[1], generation); + } catch (handlerErr) { + addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error'); + } + } catch (_e) { + addLog(`Failed to parse message: ${msg}`, 'error'); + } + } + }; + + connectionSocket.onclose = () => { + if (generation !== connectionGeneration || socket !== connectionSocket) return; + // Invalidate any async message handler that began before the + // close event and is still suspended at an await boundary. + connectionGeneration++; + isConnecting = false; + isNamespaceJoined = false; + awaitingRoomData = false; + pendingRoomDataRoomId = null; + invalidateChatSession(); + stopPing(); + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + + if (!connectIntent && !currentRoom) { + isForceSyncInitiator = false; + forceSyncAcks.clear(); + if (forceSyncTimeout) clearTimeout(forceSyncTimeout); + chrome.storage.session.set({ + isForceSyncInitiator: false, + forceSyncAcks: [], + forceSyncDeadline: null + }).catch(() => {}); + } + + if (currentRoom && !connectIntent) { + currentRoom.peers = []; + if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); + } + broadcastConnectionStatus('disconnected'); + socket = null; + if (currentRoom || connectIntent) { + addLog('Disconnected. Scheduling reconnect...', 'warn'); + scheduleReconnect(); + } else { + addLog('Disconnected. No active session — staying disconnected.', 'info'); + } + }; + + connectionSocket.onerror = () => { + if (generation !== connectionGeneration || socket !== connectionSocket) return; + broadcastConnectionStatus('disconnected'); + const logType = reconnectAttempts > 1 ? 'error' : 'warn'; + addLog('WebSocket Error: Connection failed', logType); + }; + handedOffToSocket = true; } catch (e) { throw new Error(`[Connection Error] ${e.message}`); } - // --- Phase 5: Event Listeners --- - socket.onopen = () => { - reconnectAttempts = 0; - reconnectStartTime = null; - reconnectFailed = false; - addLog('WebSocket Connection Opened', 'success'); - chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {}); - isNamespaceJoined = false; - socket.send('40'); - }; - - socket.onmessage = async (event) => { - await ensureState(); - const msg = event.data; - if (msg === '2') { - socket.send('3'); - return; - } - if (msg.startsWith('0')) { - addLog(`Socket.IO Handshake: ${msg}`, 'info'); - } else if (msg.startsWith('40')) { - isConnecting = false; - isNamespaceJoined = true; - broadcastConnectionStatus('connected'); - startPing(); - addLog('Joined Namespace /', 'success'); - const settings = await getSettings(); - if (settings.roomId) { - const sharedTitles = getSharedTitleFields(settings); - emit(EVENTS.JOIN_ROOM, { - roomId: settings.roomId, - password: settings.password, - peerId, - username: settings.username, - tabTitle: sharedTitles.tabTitle, - clientCapabilities: CLIENT_CAPABILITIES, - protocolVersion: PROTOCOL_VERSION - }); - } - flushEventQueue(); - } else if (msg.startsWith('42')) { - try { - const payload = JSON.parse(msg.substring(2)); - try { - await handleServerEvent(payload[0], payload[1]); - } catch (handlerErr) { - addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error'); - } - } catch (_e) { - addLog(`Failed to parse message: ${msg}`, 'error'); - } - } - }; - - socket.onclose = () => { - isConnecting = false; - isNamespaceJoined = false; - invalidateChatSession(); - stopPing(); - if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } - - if (!connectIntent && !currentRoom) { - isForceSyncInitiator = false; - forceSyncAcks.clear(); - if (forceSyncTimeout) clearTimeout(forceSyncTimeout); - chrome.storage.session.set({ - isForceSyncInitiator: false, - forceSyncAcks: [], - forceSyncDeadline: null - }).catch(() => {}); - } - - - if (currentRoom && !connectIntent) { - currentRoom.peers = []; - if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {}); - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); - } - broadcastConnectionStatus('disconnected'); - if (currentRoom || connectIntent) { - addLog('Disconnected. Scheduling reconnect...', 'warn'); - socket = null; - scheduleReconnect(); - } else { - addLog('Disconnected. No active session — staying disconnected.', 'info'); - socket = null; - } - }; - - socket.onerror = () => { - broadcastConnectionStatus('disconnected'); - const logType = reconnectAttempts > 1 ? 'error' : 'warn'; - addLog('WebSocket Error: Connection failed', logType); - }; - } catch (e) { + if (attemptGeneration !== connectionGeneration) return; isConnecting = false; const logType = reconnectAttempts > 1 ? 'error' : 'warn'; const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error'); @@ -1244,6 +1473,8 @@ async function connect() { if (currentRoom || connectIntent) { scheduleReconnect(); } + } finally { + if (!handedOffToSocket) isConnecting = false; } } @@ -1472,7 +1703,13 @@ function scheduleReconnect() { // Slow reconnect logic is now handled in the keepAlive alarm function emit(event, data) { - if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { + const mustWaitForRoomData = awaitingRoomData + && event !== EVENTS.JOIN_ROOM + && event !== EVENTS.GET_ROOMS; + if (socket && socket.readyState === WebSocket.OPEN + && isNamespaceJoined + && !mustWaitForRoomData + && !flushInProgress) { try { const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); @@ -1504,41 +1741,150 @@ function emitLive(event, data) { } function queueEvent(event, data) { - eventQueue.push({ event, data }); - if (eventQueue.length > 50) { - eventQueue.shift(); + const queueRoomId = currentRoom?.roomId || pendingRoomDataRoomId; + const queued = enqueueQueuedEvent(eventQueue, event, data, { roomId: queueRoomId }); + eventQueue = queued.queue; + eventQueueVersion++; + + if (isMediaQueueEvent(event)) { + const latest = eventQueue.at(-1); + if (mediaIntentNeedsSequenceReservation(latest)) { + const nextSequence = Math.max(localSeq, maxQueuedSequence(eventQueue)) + 1; + const reserved = reserveLatestMediaIntentSequence(eventQueue, queueRoomId, nextSequence); + eventQueue = reserved.queue; + if (reserved.reserved) { + localSeq = nextSequence; + chrome.storage.session.set({ localSeq }).catch(() => {}); + } + } + const sourceCount = isQueuedMediaIntent(eventQueue.at(-1)) + ? eventQueue.at(-1).intent.sourceEventCount + : 0; + if (sourceCount === 1) { + addLog('Offline media intent queued; replay deferred until ROOM_DATA', 'info'); + } else if ([10, 50, 100, 500, 1000].includes(sourceCount)) { + addLog(`Collapsed ${sourceCount} queued media commands into one intent`, 'info'); + } + } + if (queued.dropped > 0) { addLog('Event queue cap reached, dropping oldest event', 'warn'); } - chrome.storage.session.set({ eventQueue }); + chrome.storage.session.set({ eventQueue }).catch(() => {}); +} + +function clearEventQueue() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + eventQueue = []; + eventQueueVersion++; + chrome.storage.session.set({ eventQueue: [] }).catch(() => {}); +} + +function applyQueuedRoomPolicy(roomId, policy, reason) { + const result = reconcileQueuedRoomIntent(eventQueue, { roomId, ...policy }); + eventQueue = result.queue; + eventQueueVersion++; + const { discarded } = result; + if (discarded > 0) { + chrome.storage.session.set({ eventQueue }).catch(() => {}); + addLog(`${reason}: discarded ${discarded} queued room action${discarded === 1 ? '' : 's'}`, 'warn'); + } + return result; } /** - * Drain the offline event queue in paced batches. A reconnect after a long - * outage can leave up to 50 queued events; dumping them in one tick would - * exceed the server's per-socket event budget and get us disconnected right - * after rejoining. We send FLUSH_BATCH_SIZE events, then wait - * FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across - * subsequent batches; if the connection drops mid-drain, the rest stay queued. + * Drain logical queue entries only after ROOM_DATA confirms the current room, + * role and lobby. Pacing counts materialized wire frames, not logical entries. + * A two-frame media intent is kept whole at a batch boundary and retained in + * full if either send fails. Replaying its first legacy frame can repeat a + * canonical revision/activity side effect, but the ordered full retry remains + * final-state recoverable without a new delivery-ACK protocol. */ -function flushEventQueue() { - if (flushTimer) return; // a drain is already in progress - const drainBatch = () => { - flushTimer = null; - if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { - return; // lost the connection — leave the rest queued for next connect +async function flushEventQueue(replaySettingsOverride = undefined) { + if (flushTimer || flushInProgress || awaitingRoomData) return; + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return; + const flushToken = {}; + const flushConnectionGeneration = connectionGeneration; + const flushSocket = socket; + const flushRoomId = currentRoom?.roomId || null; + flushInProgress = flushToken; + try { + // Resolve privacy settings before taking queue ownership. If new work + // arrives during the drain, keep it behind the original logical entries; + // final-state retry is safer than losing concurrent intent. + const replaySettings = replaySettingsOverride !== undefined + ? replaySettingsOverride + : (eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null); + if (flushConnectionGeneration !== connectionGeneration + || socket !== flushSocket + || currentRoom?.roomId !== flushRoomId + || awaitingRoomData) { + return; } - let sent = 0; - while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) { - const queuedMsg = eventQueue.shift(); - emit(queuedMsg.event, queuedMsg.data); - sent++; + applyQueuedRoomPolicy(flushRoomId, { + canControl: !(controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController()), + activeLobby: !!episodeLobby, + desynced: hcmDesynced, + authoritativeLobby: !!currentRoom?.activeLobby + }, 'Queue replay authority changed'); + const drainSource = eventQueue; + const drainVersion = eventQueueVersion; + const result = await drainQueuedBatch(drainSource, { + roomId: currentRoom?.roomId || null, + maxWireEvents: FLUSH_BATCH_SIZE, + sendFrame: async (frame, entry) => { + if (flushConnectionGeneration !== connectionGeneration + || socket !== flushSocket + || currentRoom?.roomId !== flushRoomId + || awaitingRoomData) { + return false; + } + let payload = frame.data && typeof frame.data === 'object' ? { ...frame.data } : {}; + if (isQueuedMediaIntent(entry)) { + payload = withTitlePrivacy(payload, replaySettings, ['mediaTitle']); + payload.peerId = peerId; + } + return emitLive(frame.event, payload); + } + }); + if (eventQueueVersion === drainVersion) { + eventQueue = result.queue; + eventQueueVersion++; + } else { + // enqueueQueuedEvent preserves object identity for untouched queue + // entries. Remove only entries the drain completed, leaving any + // concurrently appended or merged work in place. A partial intent + // failure returns the complete entry in result.queue, so it is not + // included in consumedEntries and remains retryable as a unit. + const consumedCount = drainSource.length - result.queue.length; + const consumedEntries = new Set(drainSource.slice(0, consumedCount)); + eventQueue = eventQueue.filter(entry => !consumedEntries.has(entry)); + eventQueueVersion++; + addLog('Queue changed during replay; reconciled completed and concurrent work', 'info'); } chrome.storage.session.set({ eventQueue }).catch(() => {}); - if (eventQueue.length > 0) { - flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS); + if (result.droppedStaleIntents > 0) { + addLog(`Dropped ${result.droppedStaleIntents} stale media intent${result.droppedStaleIntents === 1 ? '' : 's'} for a previous room`, 'warn'); } - }; - drainBatch(); + if (result.sentWireEvents > 0) { + addLog(`Replayed ${result.sentWireEvents} queued wire event${result.sentWireEvents === 1 ? '' : 's'}`, 'info'); + } + if (eventQueue.length > 0 + && flushConnectionGeneration === connectionGeneration + && socket === flushSocket + && socket?.readyState === WebSocket.OPEN + && isNamespaceJoined + && !awaitingRoomData) { + flushTimer = setTimeout(() => { + flushTimer = null; + flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn')); + }, FLUSH_BATCH_INTERVAL_MS); + } + } finally { + if (flushInProgress === flushToken) flushInProgress = null; + } } function addToHistory(action, senderId) { @@ -1607,8 +1953,168 @@ function stopPing() { missedPongs = 0; } +function markCanonicalMediaStateHandled(roomId, revision) { + if (!canonicalMediaStateTracker.markHandled(roomId, revision)) return false; + resetCanonicalMediaRecoveryRetries(); + persistCanonicalMediaRecovery(); + return true; +} + +function supersedeCanonicalMediaRecovery(reason, action = null, payload = null) { + const roomId = currentRoom?.roomId; + const pending = canonicalMediaStateTracker.getPending(roomId); + if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) { + return false; + } + // Tracking cancellation alone is insufficient: content.js may still be + // awaiting an asynchronous player action. Invalidate that operation before + // a newer local or remote control is allowed to win. + sendMessageToCurrentContent({ + type: 'CANCEL_CANONICAL_MEDIA_STATE', + reason, + action, + payload + }).catch(() => {}); + addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info'); + return true; +} + +function isCanonicalSupersedingControl(event, data) { + if (event === EVENTS.SEEK) { + return Number.isFinite(data?.targetTime) || Number.isFinite(data?.currentTime); + } + if (event === EVENTS.FORCE_SYNC_PREPARE) { + return Number.isFinite(data?.targetTime); + } + return event === EVENTS.PLAY || event === EVENTS.PAUSE || event === EVENTS.FORCE_SYNC_EXECUTE; +} + +async function performPendingCanonicalMediaStateApply() { + const roomId = currentRoom?.roomId; + const pending = canonicalMediaStateTracker.getPendingProjected(roomId); + if (!pending || !roomId) return { status: 'none' }; + + const { mediaState } = pending; + if (hcmDesynced) { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: local guest is desynced`, 'info'); + return { status: 'ignored_desynced' }; + } + if (episodeLobby) { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: episode lobby is active`, 'info'); + return { status: 'ignored_episode_lobby' }; + } + + const tabId = normalizeTabId(currentTabId); + if (tabId === null || currentTargetHasVideo !== true) return { status: 'pending_no_target' }; + const targetGeneration = targetActivationGeneration; + const targetFrameId = normalizeFrameId(currentTargetFrameId); + const targetDocumentId = currentTargetDocumentId; + + try { + const response = await enqueueContentCommand(async () => { + if (currentRoom?.roomId !== roomId) return { status: 'stale_room' }; + if (!isCurrentTargetIdentity(tabId, targetGeneration) + || normalizeFrameId(currentTargetFrameId) !== targetFrameId + || currentTargetDocumentId !== targetDocumentId) { + return { status: 'stale_target' }; + } + const latest = canonicalMediaStateTracker.getPending(roomId); + if (latest?.mediaState.revision !== mediaState.revision) { + return { status: 'superseded' }; + } + return sendMessageToContentTab(tabId, { + type: 'APPLY_CANONICAL_MEDIA_STATE', + mediaState + }); + }); + if (currentRoom?.roomId !== roomId) return { status: 'stale_room' }; + if (!isCurrentTargetIdentity(tabId, targetGeneration) + || normalizeFrameId(currentTargetFrameId) !== targetFrameId + || currentTargetDocumentId !== targetDocumentId) { + return { status: 'stale_target' }; + } + const latestPending = canonicalMediaStateTracker.getPending(roomId); + if (latestPending?.mediaState.revision !== mediaState.revision) return { status: 'superseded' }; + + if (response?.status === 'applied') { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + const drift = Number.isFinite(response.drift) ? ` (drift ${response.drift.toFixed(2)}s)` : ''; + addLog(`Applied canonical media state r${mediaState.revision}${drift}`, 'success'); + } else if (response?.status === 'ignored_desynced') { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: content is desynced`, 'info'); + } else if (response?.status === 'ignored_episode_mismatch') { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: content is on a different episode`, 'info'); + } else if (response?.status === 'invalid') { + markCanonicalMediaStateHandled(roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} rejected by content validation`, 'warn'); + } + return response || { status: 'no_response' }; + } catch (error) { + addLog(`Canonical media state r${mediaState.revision} pending: ${error.message}`, 'warn'); + return { status: 'pending_unreachable' }; + } +} + +async function tryApplyPendingCanonicalMediaState() { + if (canonicalRecoveryApplyInProgress) return canonicalRecoveryApplyInProgress; + const applyTask = performPendingCanonicalMediaStateApply(); + canonicalRecoveryApplyInProgress = applyTask; + let result; + try { + result = await applyTask; + } finally { + if (canonicalRecoveryApplyInProgress === applyTask) canonicalRecoveryApplyInProgress = null; + } + if (CANONICAL_RECOVERY_RETRYABLE.has(result?.status)) { + scheduleCanonicalMediaRecoveryRetry(result.status); + } + return result; +} + +async function handleCanonicalRoomData(data, hasPendingLocalIntent) { + canonicalMediaStateTracker.adoptRoom(data?.roomId || null); + const canonicalSnapshot = canonicalMediaStateFromRoomData(data); + if (canonicalSnapshot.status === 'unsupported' || canonicalSnapshot.status === 'empty') { + persistCanonicalMediaRecovery(); + return; + } + + if (canonicalSnapshot.status === 'invalid') { + addLog('Ignored invalid canonical media state in ROOM_DATA', 'warn'); + persistCanonicalMediaRecovery(); + return; + } + const { mediaState } = canonicalSnapshot; + + const received = canonicalMediaStateTracker.receive(data.roomId, mediaState); + if (received.status === 'stale') { + addLog(`Canonical media state ignored: stale revision ${mediaState.revision}`, 'info'); + return; + } + if (received.status !== 'pending') return; + + resetCanonicalMediaRecoveryRetries(); + persistCanonicalMediaRecovery(); + addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info'); + + if (hasPendingLocalIntent) { + markCanonicalMediaStateHandled(data.roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: authorized queued local intent takes precedence`, 'info'); + return; + } + const result = await tryApplyPendingCanonicalMediaState(); + if (result.status === 'pending_no_target') { + addLog(`Canonical media state r${mediaState.revision} pending: no media target`, 'info'); + } +} + // --- Event Handlers --- -async function handleServerEvent(event, data) { +async function handleServerEvent(event, data, expectedConnectionGeneration = connectionGeneration) { + if (expectedConnectionGeneration !== connectionGeneration) return; if (!data) { addLog(`Ignored server event ${event} due to empty payload`, 'warn'); return; @@ -1627,7 +2133,15 @@ async function handleServerEvent(event, data) { return; } switch (event) { - case EVENTS.ROOM_DATA: + 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; + } if (currentRoom?.roomId !== data.roomId) { invalidateChatSession(); clearChatActivity(); @@ -1643,7 +2157,9 @@ async function handleServerEvent(event, data) { 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)); @@ -1657,20 +2173,47 @@ async function handleServerEvent(event, data) { currentRoom.peers = []; } - // Recover server-tracked active Episode Lobby if present - if (data && data.activeLobby && !episodeLobby) { + // ROOM_DATA is authoritative for an already-active server lobby, + // but a locally-created offline lobby has not reached the relay + // yet and must remain owned by its initiator until queued replay. + const hasQueuedLocalLobby = eventQueue.some(entry => + entry?.event === EVENTS.EPISODE_LOBBY + && (!entry.roomId || entry.roomId === data.roomId) + ); + 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 (authoritativeLobby) { + const sameLobby = episodeLobby + && 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, - createdAt: Date.now() + ...authoritativeLobby, + createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt) + ? episodeLobby.createdAt + : authoritativeLobby.createdAt }; persistEpisodeLobby(); broadcastLobbyUpdate(); - addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info'); + if (!sameLobby) { + addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info'); + } // Notify content script to start polling - if (currentTabId) { + if (!sameLobby && currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { sendMessageToCurrentContent({ @@ -1692,8 +2235,40 @@ async function handleServerEvent(event, data) { // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); + 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 + // canonical precedence decision is made. + const replaySettings = eventQueue.some(isQueuedMediaIntent) + ? await getSettings() + : null; + if (expectedConnectionGeneration !== connectionGeneration + || currentRoom?.roomId !== data.roomId) return; + awaitingRoomData = false; + pendingRoomDataRoomId = null; + + const lostRoomAuthority = controlMode === CONTROL_MODES.HOST_ONLY + && hostPeerId + && !amController(); + const queuePolicy = applyQueuedRoomPolicy(data.roomId, { + canControl: !lostRoomAuthority, + activeLobby: !!episodeLobby, + desynced: hcmDesynced, + authoritativeLobby: !!authoritativeLobby + }, lostRoomAuthority + ? 'Host Control role changed while offline' + : (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy')); + + await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent); + await flushEventQueue(replaySettings); break; + } case EVENTS.CONTROL_MODE: + // Terminal room teardown is authoritative. Older/custom relays may + // still emit a trailing role update after ROOM_CLOSED. + if (!currentRoom) break; // Host Control Mode changed (toggle or host-leave fallback). controlMode = data.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = data.hostPeerId || null; @@ -1761,7 +2336,7 @@ async function handleServerEvent(event, data) { await chatReceiveQueue; break; } - case EVENTS.ERROR: + case EVENTS.ERROR: { isConnecting = false; const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED || data.code === ERROR_CODES.PEER_TIMED_OUT @@ -1796,10 +2371,15 @@ async function handleServerEvent(event, data) { const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message }; await broadcastJoinStatus(errStatusMsg); break; + } case EVENTS.PLAY: case EVENTS.PAUSE: case EVENTS.SEEK: case EVENTS.FORCE_SYNC_PREPARE: + if (event === EVENTS.FORCE_SYNC_PREPARE && episodeLobby) { + if (currentRoom) currentRoom.activeLobby = null; + clearEpisodeLobbyState(); + } if (data.senderId && typeof data.seq === 'number') { const lastSeq = lastSeqBySender[data.senderId]; if (lastSeq !== undefined && data.seq <= lastSeq) { @@ -1809,6 +2389,9 @@ async function handleServerEvent(event, data) { lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } + if (isCanonicalSupersedingControl(event, data)) { + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); + } if (data.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -1861,12 +2444,17 @@ async function handleServerEvent(event, data) { } break; case EVENTS.FORCE_SYNC_EXECUTE: + if (episodeLobby) { + if (currentRoom) currentRoom.activeLobby = null; + clearEpisodeLobbyState(); + } if (data?.senderId && typeof data.seq === 'number') { const lastSeq = lastSeqBySender[data.senderId]; if (lastSeq !== undefined && data.seq <= lastSeq) break; lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -2001,19 +2589,40 @@ async function handleServerEvent(event, data) { } break; case EVENTS.EPISODE_LOBBY: - if (data.senderId && data.expectedTitle) { - addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); + 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 = incomingLobby; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } + 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(); @@ -2028,24 +2637,52 @@ async function handleServerEvent(event, data) { if (!isNaN(tabId)) { sendMessageToCurrentContent({ type: 'EPISODE_LOBBY', - expectedTitle: data.expectedTitle + expectedTitle }).catch(() => {}); } } } break; case EVENTS.EPISODE_READY: - if (episodeLobby && data.senderId) { - if (!episodeLobby.readyPeers.includes(data.senderId)) { - episodeLobby.readyPeers.push(data.senderId); + { + 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); + readyAdded = true; persistEpisodeLobby(); broadcastLobbyUpdate(); - addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info'); - checkEpisodeLobbyCompletion(); + addLog(`Episode ready from ${data.senderId} (${lobby.readyPeers.length})`, 'info'); } + const readyPeers = [...lobby.readyPeers]; + if (currentRoom?.activeLobby) { + currentRoom.activeLobby.readyPeers = readyPeers; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } + if (readyAdded) checkEpisodeLobbyCompletion(); } 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; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } if (episodeLobby) { const title = episodeLobby.expectedTitle; clearEpisodeLobbyState(); @@ -2074,6 +2711,7 @@ async function handleServerEvent(event, data) { } function executeForceSync() { + supersedeCanonicalMediaRecovery('local force_sync_execute', EVENTS.FORCE_SYNC_EXECUTE); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); @@ -2147,6 +2785,7 @@ function clearEpisodeLobbyState() { function cancelEpisodeLobby(reason) { if (!episodeLobby) return; const title = episodeLobby.expectedTitle; + supersedeCanonicalMediaRecovery('local episode_lobby_cancel'); // Broadcast cancellation to room emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId }); @@ -2188,6 +2827,10 @@ function cancelEpisodeLobby(reason) { function executeEpisodeLobby() { if (!episodeLobby) return; const title = episodeLobby.expectedTitle; + if (currentRoom) { + currentRoom.activeLobby = null; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } clearEpisodeLobbyState(); addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success'); @@ -2226,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(); } } @@ -2276,6 +2924,10 @@ function routeToContent(action, payload) { commandSenderId, 0 ); + return enqueueContentCommand(deliver); +} + +function enqueueContentCommand(deliver) { const queued = contentCommandQueue.catch(() => {}).then(deliver); contentCommandQueue = queued; return queued; @@ -3310,6 +3962,9 @@ async function activateTargetTab(tabId, tabTitle, { return { status: 'superseded' }; } updateBadgeStatus(); + if (currentTargetHasVideo) { + await tryApplyPendingCanonicalMediaState(); + } return { status: 'ok', tabId: selectedTabId, @@ -3731,6 +4386,7 @@ function leaveOldRoomIfSwitching(newRoomId) { addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; + clearCanonicalMediaRecovery(); clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; @@ -3853,6 +4509,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } if (message.type === 'CONNECT') { + await waitForRoomTeardown(); webJoinCoordinator.invalidate(); const settings = await getSettings(); connectIntent = !!settings.roomId; @@ -3891,12 +4548,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'RETRY_CONNECT') { + await waitForRoomTeardown(); connectIntent = true; reconnectFailed = false; reconnectStartTime = null; reconnectAttempts = 0; chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); - forceDisconnect(); + forceDisconnect({ preserveEventQueue: true }); connect(); sendResponse({ status: 'ok' }); } else if (message.type === 'GET_STATUS') { @@ -3953,6 +4611,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { episodeLobby: episodeLobby, reconnectAttempts, reconnectSlowMode: reconnectFailed, + queuedLogicalEvents: eventQueue.length, + queuedMediaIntents: queuedMediaIntentCount(eventQueue, currentRoom?.roomId || pendingRoomDataRoomId), + queuedWireEvents: queuedWireCount(eventQueue), roomId: currentRoom ? currentRoom.roomId : null, serverUrl: currentServerUrl, version: chrome.runtime.getManifest().version, @@ -3974,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; @@ -3988,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'), @@ -4155,6 +4827,21 @@ async function handleAsyncMessage(message, sender, sendResponse) { // heartbeat survives SW restarts (idle timeout, crash). hcmDesynced = !!message.desynced; if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); + if (hcmDesynced) { + applyQueuedRoomPolicy(currentRoom?.roomId, { desynced: true }, 'Intentional solo mode'); + const pending = canonicalMediaStateTracker.getPending(currentRoom?.roomId); + if (pending) { + markCanonicalMediaStateHandled(pending.roomId, pending.mediaState.revision); + 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' }); @@ -4170,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); @@ -4301,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. @@ -4324,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). @@ -4352,11 +5045,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { payload.targetTime = targetTime; } - 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; @@ -4367,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 }); @@ -4410,13 +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) { + 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' }); @@ -4500,11 +5216,22 @@ async function handleAsyncMessage(message, sender, sendResponse) { updateBadgeStatus(); } + 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, @@ -4520,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(() => {}); @@ -4619,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) { @@ -4632,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'); @@ -4641,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 @@ -4675,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' }); @@ -4686,6 +5434,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (episodeLobby) clearEpisodeLobbyState(); // Create new lobby + supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE); episodeLobby = { expectedTitle: lobbyTitle, initiatorPeerId: peerId, @@ -4723,24 +5472,47 @@ async function handleAsyncMessage(message, sender, sendResponse) { } } // Content script confirmed it loaded the lobby episode - if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) { - if (!episodeLobby.readyPeers.includes(peerId)) { + 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 (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId) { + sendResponse({ status: 'ignored_stale_lobby' }); + return; + } + if (lobby.readyPeers.includes(peerId)) { + sendResponse({ status: 'ok' }); + return; + } const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode); - episodeLobby.readyPeers.push(peerId); + lobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); - emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle }); - addLog(`Local episode ready: "${readyTitle || episodeLobby.expectedTitle}"`, 'success'); + 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'); } } @@ -4826,6 +5598,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } } + requestCanonicalMediaRecoveryAttempt(); // Content script re-injected, check if there's an active lobby if (episodeLobby) { sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle }); diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs new file mode 100644 index 0000000..09af527 --- /dev/null +++ b/extension/canonical-media-state-background.test.mjs @@ -0,0 +1,131 @@ +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'); +const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8'); + +function functionBody(source, name, nextName) { + const start = source.indexOf(`function ${name}(`); + const end = source.indexOf(`function ${nextName}(`, start + 1); + expect(start).toBeGreaterThan(-1); + return source.slice(start, end === -1 ? source.length : end); +} + +describe('canonical ROOM_DATA recovery contract', () => { + it('is capability-gated and treats absent/null media state as the old-relay path', () => { + const handler = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent'); + expect(handler).toContain('canonicalMediaStateFromRoomData(data)'); + expect(handler).toContain("canonicalSnapshot.status === 'unsupported'"); + expect(handler).toContain("canonicalSnapshot.status === 'empty'"); + expect(handler.indexOf('canonicalMediaStateFromRoomData(data)')) + .toBeLessThan(handler.indexOf('canonicalMediaStateTracker.receive')); + expect(backgroundSource).toMatch(/CLIENT_CAPABILITIES[\s\S]{0,160}CAPABILITIES\.MEDIA_STATE_V1/); + expect(backgroundSource).toContain('!serverSupports(CAPABILITIES.MEDIA_STATE_V1)'); + }); + + it('keeps legacy PLAY/PAUSE/SEEK, Force Sync, Episode Lobby, and Host Control handlers independent of canonical recovery', () => { + const serverHandler = functionBody(backgroundSource, 'handleServerEvent', 'executeForceSync'); + expect(serverHandler).toContain('case EVENTS.PLAY:'); + expect(serverHandler).toContain('case EVENTS.PAUSE:'); + expect(serverHandler).toContain('case EVENTS.SEEK:'); + expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_PREPARE:'); + expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_EXECUTE:'); + expect(serverHandler).toContain('case EVENTS.EPISODE_LOBBY:'); + expect(serverHandler).toContain('case EVENTS.CONTROL_MODE:'); + expect(serverHandler).not.toMatch(/case EVENTS\.(?:PLAY|PAUSE|SEEK):[\s\S]{0,500}MEDIA_STATE_V1/); + }); + + it('uses a dedicated internal apply message without action/history/ACK machinery', () => { + const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); + expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'"); + expect(apply).toContain('enqueueContentCommand(async () =>'); + expect(apply).toContain('canonicalMediaStateTracker.getPending(roomId)'); + expect(apply).not.toContain('routeToContent('); + expect(apply).not.toContain('emit('); + expect(apply).not.toContain('addToHistory('); + expect(apply).not.toContain('EVENT_ACK'); + + const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'"); + const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart); + const internalHandler = contentSource.slice(contentHandlerStart, serverCommandStart); + expect(internalHandler).toContain('const applyGeneration = beginCanonicalMediaApply()'); + expect(internalHandler).toContain('applyCanonicalMediaState(message.mediaState, applyGeneration)'); + expect(internalHandler).not.toContain('CMD_ACK'); + expect(internalHandler).not.toContain('CONTENT_EVENT'); + }); + + it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => { + expect(backgroundSource).toContain("'canonicalMediaRecovery'"); + expect(backgroundSource).toContain('canonicalMediaStateTracker.restore('); + expect(backgroundSource).toContain('CANONICAL_RECOVERY_RETRY_DELAYS'); + expect(backgroundSource).toContain('requestCanonicalMediaRecoveryAttempt()'); + expect(backgroundSource).toMatch(/message\.type === 'HEARTBEAT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/); + expect(backgroundSource).toMatch(/message\.type === 'CONTENT_BOOT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/); + expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/); + expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4); + const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); + expect(apply).toContain('getPendingProjected(roomId)'); + expect(apply).toContain('targetActivationGeneration'); + expect(apply).toContain("return { status: 'stale_target' }"); + 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', () => { + const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); + const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent'); + expect(apply).toContain('if (hcmDesynced)'); + expect(apply).toContain('if (episodeLobby)'); + expect(roomData).toContain('if (hasPendingLocalIntent)'); + expect(backgroundSource).toContain('awaitingRoomData = true'); + expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'); + expect(backgroundSource).toContain('await flushEventQueue(replaySettings)'); + }); + + it('supersedes pending recovery only after newer local or accepted remote room control', () => { + const supersede = functionBody(backgroundSource, 'supersedeCanonicalMediaRecovery', 'performPendingCanonicalMediaStateApply'); + 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}`)'); + expect(backgroundSource.indexOf("sendResponse({ status: 'blocked_host_only' })")) + .toBeLessThan(backgroundSource.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)')); + }); + + it('awaits media actions and verifies playback plus drift before acknowledging recovery', () => { + const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady'); + expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA'); + expect(apply).toContain("_setSuppress('seek')"); + expect(apply).toContain('await tryMediaAction(EVENTS.SEEK'); + expect(apply).toContain('await tryMediaAction(EVENTS.PAUSE)'); + expect(apply).toContain('await tryMediaAction(EVENTS.PLAY)'); + expect(apply).toContain('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)'); + expect(apply.indexOf("status: 'applied'")) + .toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)')); + expect(apply).toContain('isCanonicalMediaApplyCurrent(applyGeneration)'); + expect(apply).toContain('restoreSupersedingLocalState(video)'); + expect(apply).toContain('if (hcmDesynced)'); + expect(apply).toContain('isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)'); + expect(apply).toContain("status: 'ignored_episode_mismatch'"); + const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'"); + const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart); + 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 new file mode 100644 index 0000000..2daf2f5 --- /dev/null +++ b/extension/canonical-media-state.js @@ -0,0 +1,188 @@ +import { CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js'; + +export function validateCanonicalMediaState(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null; + if (value.playbackState !== 'playing' && value.playbackState !== 'paused') return null; + if (typeof value.currentTime !== 'number' + || !Number.isFinite(value.currentTime) + || value.currentTime < 0 + || value.currentTime > MAX_MEDIA_TIME) { + return null; + } + if (value.mediaTitle !== undefined + && value.mediaTitle !== null + && typeof value.mediaTitle !== 'string') { + return null; + } + const normalized = { + revision: value.revision, + playbackState: value.playbackState, + currentTime: value.currentTime + }; + if (typeof value.updatedBy === 'string' && value.updatedBy) { + normalized.updatedBy = value.updatedBy.substring(0, 16); + } + if (typeof value.mediaTitle === 'string' && value.mediaTitle) { + normalized.mediaTitle = value.mediaTitle.substring(0, 100); + } + return normalized; +} + +export function canonicalMediaStateFromRoomData(roomData) { + const capabilities = Array.isArray(roomData?.capabilities) ? roomData.capabilities : []; + if (!capabilities.includes(CAPABILITIES.MEDIA_STATE_V1)) { + return { status: 'unsupported', mediaState: null }; + } + if (roomData.mediaState === null || roomData.mediaState === undefined) { + return { status: 'empty', mediaState: null }; + } + const mediaState = validateCanonicalMediaState(roomData.mediaState); + return mediaState + ? { status: 'available', mediaState } + : { status: 'invalid', mediaState: null }; +} + +export function projectCanonicalMediaState(mediaState, receivedAt, now = Date.now()) { + const validated = validateCanonicalMediaState(mediaState); + if (!validated) return null; + if (validated.playbackState !== 'playing') return validated; + const received = typeof receivedAt === 'number' && Number.isFinite(receivedAt) + ? receivedAt + : now; + return { + ...validated, + currentTime: Math.min( + MAX_MEDIA_TIME, + validated.currentTime + Math.max(0, now - received) / 1000 + ) + }; +} + +function normalizeRoomId(roomId) { + return typeof roomId === 'string' && roomId ? roomId : null; +} + +export function createCanonicalMediaStateTracker() { + let roomId = null; + let knownRevision = 0; + let appliedRevision = 0; + let pending = null; + + function adoptRoom(nextRoomId) { + const normalizedRoomId = normalizeRoomId(nextRoomId); + if (normalizedRoomId === roomId) return false; + roomId = normalizedRoomId; + knownRevision = 0; + appliedRevision = 0; + pending = null; + return true; + } + + return { + adoptRoom, + + beginRecovery(nextRoomId) { + adoptRoom(nextRoomId); + // A relay restart or an empty-room recreation starts a new in-memory + // revision epoch. ROOM_DATA belongs to this fresh connection, so a + // lower revision is current truth rather than a stale packet from + // the previous epoch. + knownRevision = 0; + appliedRevision = 0; + pending = null; + }, + + receive(nextRoomId, value, receivedAt = Date.now()) { + adoptRoom(nextRoomId); + const mediaState = validateCanonicalMediaState(value); + if (!roomId || !mediaState) return { status: 'invalid' }; + if (mediaState.revision < knownRevision) return { status: 'stale' }; + if (mediaState.revision === knownRevision + && (appliedRevision === mediaState.revision || pending?.mediaState.revision === mediaState.revision)) { + return { status: 'duplicate' }; + } + knownRevision = Math.max(knownRevision, mediaState.revision); + pending = { + roomId, + mediaState, + receivedAt: typeof receivedAt === 'number' && Number.isFinite(receivedAt) + ? receivedAt + : Date.now() + }; + return { status: 'pending', mediaState }; + }, + + getPending(nextRoomId = roomId) { + return pending && pending.roomId === normalizeRoomId(nextRoomId) + ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } + : null; + }, + + getPendingProjected(nextRoomId = roomId, now = Date.now()) { + if (!pending || pending.roomId !== normalizeRoomId(nextRoomId)) return null; + const mediaState = projectCanonicalMediaState( + pending.mediaState, + pending.receivedAt, + now + ); + return mediaState ? { roomId: pending.roomId, mediaState } : null; + }, + + markHandled(nextRoomId, revision) { + if (normalizeRoomId(nextRoomId) !== roomId + || !Number.isSafeInteger(revision) + || revision < 1) { + return false; + } + knownRevision = Math.max(knownRevision, revision); + appliedRevision = Math.max(appliedRevision, revision); + if (pending?.mediaState.revision <= revision) pending = null; + return true; + }, + + clear() { + adoptRoom(null); + }, + + restore(value, currentRoomId) { + adoptRoom(currentRoomId); + if (!value || typeof value !== 'object' || value.roomId !== roomId || !roomId) return false; + knownRevision = Number.isSafeInteger(value.knownRevision) && value.knownRevision >= 0 + ? value.knownRevision + : 0; + appliedRevision = Number.isSafeInteger(value.appliedRevision) && value.appliedRevision >= 0 + ? Math.min(value.appliedRevision, knownRevision) + : 0; + const restoredPending = validateCanonicalMediaState(value.pending?.mediaState); + if (value.pending?.roomId === roomId + && restoredPending + && restoredPending.revision > appliedRevision + && restoredPending.revision >= knownRevision) { + pending = { + roomId, + mediaState: restoredPending, + receivedAt: typeof value.pending.receivedAt === 'number' + && Number.isFinite(value.pending.receivedAt) + ? value.pending.receivedAt + : Date.now() + }; + knownRevision = restoredPending.revision; + } + return true; + }, + + snapshot() { + return { + roomId, + knownRevision, + appliedRevision, + pending: pending ? { + roomId: pending.roomId, + mediaState: { ...pending.mediaState }, + receivedAt: pending.receivedAt + } : null + }; + } + }; +} diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs new file mode 100644 index 0000000..1d88bb5 --- /dev/null +++ b/extension/canonical-media-state.test.mjs @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + canonicalMediaStateFromRoomData, + createCanonicalMediaStateTracker, + projectCanonicalMediaState, + validateCanonicalMediaState +} from './canonical-media-state.js'; + +const state = (revision, currentTime = revision * 10, playbackState = 'playing') => ({ + revision, + currentTime, + playbackState, + updatedBy: 'peer-a' +}); + +describe('canonical media state validation', () => { + it('accepts a bounded canonical snapshot', () => { + expect(validateCanonicalMediaState(state(2))).toEqual(state(2)); + }); + + it('preserves an optional bounded media title and rejects invalid title types', () => { + expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'Series S01E02' })) + .toMatchObject({ mediaTitle: 'Series S01E02' }); + expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'x'.repeat(120) }).mediaTitle) + .toHaveLength(100); + expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 42 })).toBeNull(); + }); + + it.each([ + null, + [], + {}, + { revision: 0, playbackState: 'playing', currentTime: 1 }, + { revision: 1.5, playbackState: 'playing', currentTime: 1 }, + { revision: 1, playbackState: 'buffering', currentTime: 1 }, + { revision: 1, playbackState: 'playing', currentTime: NaN }, + { revision: 1, playbackState: 'playing', currentTime: Infinity }, + { revision: 1, playbackState: 'playing', currentTime: -1 }, + { revision: 1, playbackState: 'playing', currentTime: 86401 }, + { revision: 1, playbackState: 'playing', currentTime: '1' } + ])('rejects malformed snapshot %#', value => { + expect(validateCanonicalMediaState(value)).toBeNull(); + }); +}); + +describe('ROOM_DATA capability compatibility', () => { + const legacyRoomData = { + roomId: 'room-a', + peers: [], + activeLobby: null, + hostPeerId: 'peer-a', + controlMode: 'everyone', + controllers: ['peer-a'], + capabilities: ['host-control', 'co-host', 'chat-v1'] + }; + + it('treats an old relay without capability or mediaState as the unchanged fallback', () => { + expect(canonicalMediaStateFromRoomData(legacyRoomData)).toEqual({ + status: 'unsupported', + mediaState: null + }); + expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, capabilities: undefined })).toEqual({ + status: 'unsupported', + mediaState: null + }); + }); + + it('does not consume a stray mediaState unless the relay advertises support', () => { + expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, mediaState: state(4) })).toEqual({ + status: 'unsupported', + mediaState: null + }); + }); + + it('accepts null as a valid capable-relay state without creating pending recovery', () => { + expect(canonicalMediaStateFromRoomData({ + ...legacyRoomData, + capabilities: [...legacyRoomData.capabilities, 'media-state-v1'], + mediaState: null + })).toEqual({ status: 'empty', mediaState: null }); + }); + + it('returns a validated snapshot only for a capable relay', () => { + expect(canonicalMediaStateFromRoomData({ + ...legacyRoomData, + capabilities: [...legacyRoomData.capabilities, 'media-state-v1'], + mediaState: state(4) + })).toEqual({ status: 'available', mediaState: state(4) }); + }); +}); + +describe('canonical media state tracker', () => { + it('projects a deferred playing snapshot from local receipt time and clamps it', () => { + expect(projectCanonicalMediaState(state(1, 100, 'playing'), 1_000, 31_000)) + .toMatchObject({ currentTime: 130, playbackState: 'playing' }); + expect(projectCanonicalMediaState(state(1, 100, 'paused'), 1_000, 31_000)) + .toMatchObject({ currentTime: 100, playbackState: 'paused' }); + expect(projectCanonicalMediaState(state(1, 86_390, 'playing'), 1_000, 31_000).currentTime) + .toBe(86_400); + }); + + it('accepts a valid snapshot and applies each revision once', () => { + const tracker = createCanonicalMediaStateTracker(); + expect(tracker.receive('room-a', state(1)).status).toBe('pending'); + expect(tracker.markHandled('room-a', 1)).toBe(true); + expect(tracker.receive('room-a', state(1)).status).toBe('duplicate'); + }); + + it('ignores stale revisions and lets a newer snapshot replace pending state', () => { + const tracker = createCanonicalMediaStateTracker(); + tracker.receive('room-a', state(3)); + expect(tracker.receive('room-a', state(2)).status).toBe('stale'); + expect(tracker.receive('room-a', state(4)).status).toBe('pending'); + expect(tracker.getPending('room-a').mediaState).toEqual(state(4)); + }); + + it('never exposes room A state after switching to room B or leaving', () => { + const tracker = createCanonicalMediaStateTracker(); + tracker.receive('room-a', state(5)); + tracker.adoptRoom('room-b'); + expect(tracker.getPending('room-a')).toBeNull(); + expect(tracker.getPending('room-b')).toBeNull(); + tracker.receive('room-b', state(1)); + tracker.clear(); + expect(tracker.snapshot()).toEqual({ roomId: null, knownRevision: 0, appliedRevision: 0, pending: null }); + }); + + it('allows the same revision once in a new reconnect recovery cycle', () => { + const tracker = createCanonicalMediaStateTracker(); + tracker.receive('room-a', state(8, 80)); + tracker.markHandled('room-a', 8); + tracker.beginRecovery('room-a'); + expect(tracker.receive('room-a', state(8, 100)).status).toBe('pending'); + expect(tracker.getPending().mediaState.currentTime).toBe(100); + }); + + it('accepts a lower revision from a new relay or room epoch after reconnect', () => { + const tracker = createCanonicalMediaStateTracker(); + tracker.receive('room-a', state(12), 1_000); + tracker.markHandled('room-a', 12); + tracker.beginRecovery('room-a'); + expect(tracker.receive('room-a', state(1), 2_000).status).toBe('pending'); + expect(tracker.getPending('room-a').mediaState.revision).toBe(1); + }); + + it('persists receipt time so MV3 recovery projects only playing snapshots', () => { + const first = createCanonicalMediaStateTracker(); + first.receive('room-a', state(4, 50, 'playing'), 10_000); + const restored = createCanonicalMediaStateTracker(); + expect(restored.restore(first.snapshot(), 'room-a')).toBe(true); + expect(restored.getPendingProjected('room-a', 15_000).mediaState.currentTime).toBe(55); + }); + + it('restores only room-scoped session state', () => { + const first = createCanonicalMediaStateTracker(); + first.receive('room-a', state(9)); + const stored = first.snapshot(); + + const sameRoom = createCanonicalMediaStateTracker(); + expect(sameRoom.restore(stored, 'room-a')).toBe(true); + expect(sameRoom.getPending('room-a').mediaState.revision).toBe(9); + + const otherRoom = createCanonicalMediaStateTracker(); + 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 ebef395..4fdfbad 100644 --- a/extension/content.js +++ b/extension/content.js @@ -83,6 +83,7 @@ EPISODE_LOBBY: "episode_lobby", EPISODE_READY: "episode_ready" }; + const MAX_MEDIA_TIME = 86400; // --- SHARED_EVENTS_INJECT_END --- // Suppresses native event reporting after a programmatic action. @@ -90,6 +91,9 @@ // 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; function _setSuppress(state) { if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]); @@ -105,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. @@ -1184,13 +1207,13 @@ // --- Helper: site-specific player actions, then native HTML5 fallback --- function tryMediaAction(action, data) { const video = findVideo(); - if (!video) return; + if (!video) return false; if (action === EVENTS.SEEK) { const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined; if (!Number.isFinite(target)) { reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error'); - return; + return false; } data = { ...data, targetTime: target }; } @@ -1198,24 +1221,218 @@ try { const actionFix = getActivePlayerActionFix(); if (tryPlayerActionFix(actionFix, action, video, data)) { - return; + return true; } // Fallback for native HTML5 if (action === EVENTS.PLAY) { _setSuppress('playing'); - video.play().catch((e) => { - reportLog(`Playback prevented: ${e.message}`, 'warn'); - _clearSuppress('playing'); - }); + const playResult = video.play(); + if (playResult && typeof playResult.then === 'function') { + return playResult.then(() => true).catch((e) => { + reportLog(`Playback prevented: ${e.message}`, 'warn'); + _clearSuppress('playing'); + return false; + }); + } + return true; } else if (action === EVENTS.PAUSE) { _setSuppress('paused'); video.pause(); + return true; } else if (action === EVENTS.SEEK) { seekVideo(video, data.targetTime, data.delta); + return true; } - } catch (e) { + return false; + } catch (e) { reportLog(`Media Action Error: ${e.message}`, 'error'); + return false; + } + } + + function beginCanonicalMediaApply() { + canonicalMediaApplyGeneration++; + canonicalSupersedingLocalState = null; + return canonicalMediaApplyGeneration; + } + + function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) { + canonicalMediaApplyGeneration++; + 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 || action === EVENTS.FORCE_SYNC_EXECUTE + ? 'playing' + : (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE + ? 'paused' + : (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)), + currentTime: payloadTime ?? videoTime + }; + } else if (!preserveLocalState) { + canonicalSupersedingLocalState = null; + } + return canonicalMediaApplyGeneration; + } + + function isCanonicalMediaApplyCurrent(generation) { + return !destroyed && generation === canonicalMediaApplyGeneration; + } + + async function restoreSupersedingLocalState(video) { + const restorationGeneration = canonicalMediaApplyGeneration; + const state = canonicalSupersedingLocalState; + if (!state || !video || destroyed || video.isConnected === false) return; + + if (state.playbackState === 'paused' && !video.paused) { + _setSuppress('paused'); + video.pause(); + } + if (Number.isFinite(state.currentTime)) { + const currentTime = getSyncCurrentTime(video); + if (currentTime === null || Math.abs(currentTime - state.currentTime) >= MIN_SEEK_DELTA) { + _setSuppress('seek'); + seekVideo(video, state.currentTime); + } + } + 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) { + return new Promise((resolve) => { + const interval = 100; + const finishAt = Date.now() + timeoutMs; + const timer = setInterval(() => { + if (!isCanonicalMediaApplyCurrent(applyGeneration)) { + clearInterval(timer); + seekPollTimers.delete(timer); + resolve(null); + return; + } + const video = findVideo(); + const currentTime = video ? getSyncCurrentTime(video) : null; + const projectedTime = mediaState.currentTime + + (mediaState.playbackState === 'playing' + ? Math.max(0, Date.now() - startedAt) / 1000 + : 0); + const playbackMatches = video + && (mediaState.playbackState === 'playing' ? !video.paused : video.paused); + const drift = currentTime === null ? null : projectedTime - currentTime; + if (playbackMatches && drift !== null && Math.abs(drift) < MIN_SEEK_DELTA) { + clearInterval(timer); + seekPollTimers.delete(timer); + resolve({ currentTime, drift }); + } else if (Date.now() >= finishAt) { + clearInterval(timer); + seekPollTimers.delete(timer); + resolve(null); + } + }, interval); + seekPollTimers.add(timer); + }); + } + + async function applyCanonicalMediaState(mediaState, applyGeneration) { + if (!mediaState || typeof mediaState !== 'object' + || !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1 + || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused') + || typeof mediaState.currentTime !== 'number' + || !Number.isFinite(mediaState.currentTime) + || mediaState.currentTime < 0 + || mediaState.currentTime > MAX_MEDIA_TIME + || (mediaState.mediaTitle !== undefined + && mediaState.mediaTitle !== null + && typeof mediaState.mediaTitle !== 'string')) { + return { status: 'invalid' }; + } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return { status: 'superseded' }; + if (hcmDesynced) return { status: 'ignored_desynced' }; + const localMediaTitle = getMediaTitle(); + if (_autoSyncEnabled && isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)) { + reportLog(`Canonical media state ignored: sender="${mediaState.mediaTitle || '?'}" vs mine="${localMediaTitle || '?'}"`, 'warn'); + return { status: 'ignored_episode_mismatch' }; + } + + const video = findVideo(); + if (!video) return { status: 'no_video' }; + + const currentTime = getSyncCurrentTime(video); + const drift = currentTime === null ? null : mediaState.currentTime - currentTime; + const shouldSeek = drift === null || Math.abs(drift) >= MIN_SEEK_DELTA; + const startedAt = Date.now(); + const superseded = async () => { + await restoreSupersedingLocalState(video); + return { status: 'superseded', revision: mediaState.revision }; + }; + + try { + // Paused recovery pauses before seeking; playing recovery seeks before + // starting. Both paths reuse the same site/page-API abstractions and + // native-event suppression as ordinary remote commands. + if (mediaState.playbackState === 'paused' && !video.paused) { + const pauseApplied = await tryMediaAction(EVENTS.PAUSE); + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (!pauseApplied) { + return { status: 'apply_failed', reason: 'pause_action_failed' }; + } + } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (shouldSeek) { + _setSuppress('seek'); + const seekApplied = await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime }); + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (!seekApplied) { + return { status: 'apply_failed', reason: 'seek_action_failed' }; + } + } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (mediaState.playbackState === 'playing' && video.paused) { + const playApplied = await tryMediaAction(EVENTS.PLAY); + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (!playApplied) { + return { status: 'apply_failed', reason: 'play_action_failed' }; + } + } + const verified = await pollCanonicalMediaState(mediaState, startedAt, applyGeneration); + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (!verified) { + reportLog(`Canonical media state r${mediaState.revision} could not be verified`, 'warn'); + return { status: 'apply_failed', reason: 'verification_timeout' }; + } + scheduleProactiveHeartbeat(); + return { + status: 'applied', + revision: mediaState.revision, + drift: verified.drift, + sought: shouldSeek + }; + } catch (error) { + reportLog(`Canonical media state apply failed: ${error.message}`, 'warn'); + return { status: 'apply_failed' }; } } @@ -1319,6 +1536,29 @@ return true; } + if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') { + const preserveLocalState = message.reason === `local ${EVENTS.PLAY}` + || message.reason === `local ${EVENTS.PAUSE}` + || message.reason === `local ${EVENTS.SEEK}`; + cancelCanonicalMediaApply( + message.action, + findVideo(), + preserveLocalState, + message.payload + ); + sendResponse({ status: 'cancelled' }); + return true; + } + + if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') { + const applyGeneration = beginCanonicalMediaApply(); + applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse).catch(error => { + reportLog(`Canonical media state apply failed: ${error.message}`, 'warn'); + sendResponse({ status: 'apply_failed', reason: 'unexpected_error' }); + }); + return true; + } + if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; @@ -1355,6 +1595,10 @@ return; } } + + if (syncActions.includes(action)) { + cancelCanonicalMediaApply(action, findVideo(), false, payload); + } if (action === EVENTS.PLAY) { tryMediaAction(EVENTS.PLAY); @@ -1664,6 +1908,11 @@ _clearSuppress(eventState); return; } + if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return; + + if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { + cancelCanonicalMediaApply(action, video); + } // Suppress only SEEK during visibility grace period (tab re-focus ghost jump). // Play/Pause pass through — user may want to immediately pause after tabbing back. @@ -1818,6 +2067,13 @@ return; } + // `seeking` normally captured the local state synchronously. Keep this + // as a fallback for players that emit only `seeked`, without replacing + // the earlier paused/playing state after an async stale action resolves. + if (!canonicalSupersedingLocalState) { + cancelCanonicalMediaApply(EVENTS.SEEK, video); + } + // Step 4: Debounce rapid consecutive seeks (e.g. scrubbing) // — wait 300ms for the user to settle before relaying if (seekDebounceTimer) clearTimeout(seekDebounceTimer); @@ -1835,6 +2091,18 @@ }, 300); }; + const handleSeeking = event => { + if (!isCurrentVideoEvent(event)) return; + const video = event.currentTarget; + const current = getSyncCurrentTime(video); + if (current === null) return; + if (expectedSeekTime !== null && Math.abs(current - expectedSeekTime) < 1.0) return; + if (Date.now() < visibilityGraceUntil) return; + const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null; + if (delta !== null && delta < MIN_SEEK_DELTA) return; + cancelCanonicalMediaApply(EVENTS.SEEK, video); + }; + let lastVideoSrc = undefined; @@ -1849,6 +2117,7 @@ if (handlers) { video.removeEventListener('play', handlers.play); video.removeEventListener('pause', handlers.pause); + if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking); video.removeEventListener('seeked', handlers.seeked); video.removeEventListener('loadeddata', handlers.loadeddata); if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting); @@ -1881,9 +2150,17 @@ const existing = video._koalaHandlers; if (existing) detachVideoListeners(video); activeVideo = video; - video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting }; + video._koalaHandlers = { + play: handlePlay, + pause: handlePause, + seeking: handleSeeking, + seeked: handleSeeked, + loadeddata: handleLoadedData, + waiting: handleWaiting + }; video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); + video.addEventListener('seeking', handleSeeking); video.addEventListener('seeked', handleSeeked); video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('waiting', handleWaiting); @@ -2096,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 new file mode 100644 index 0000000..de4a02b --- /dev/null +++ b/extension/episode-lobby-background.test.mjs @@ -0,0 +1,87 @@ +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('episode lobby completion races', () => { + it('does not read the cleared lobby after a remote ready completes it', () => { + const handler = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:'); + const snapshotIndex = handler.indexOf('const readyPeers = [...lobby.readyPeers]'); + const roomUpdateIndex = handler.indexOf('currentRoom.activeLobby.readyPeers = readyPeers'); + const completionIndex = handler.indexOf('checkEpisodeLobbyCompletion()'); + + expect(snapshotIndex).toBeGreaterThan(-1); + expect(roomUpdateIndex).toBeGreaterThan(snapshotIndex); + expect(completionIndex).toBeGreaterThan(roomUpdateIndex); + expect(handler.slice(completionIndex)).not.toContain('episodeLobby.readyPeers'); + }); + + 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 (!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); + }); + + it('clears the persisted room lobby when completion starts Force Sync', () => { + const execute = sourceBetween('function executeEpisodeLobby()', 'function checkEpisodeLobbyCompletion()'); + const roomClearIndex = execute.indexOf('currentRoom.activeLobby = null'); + const lobbyClearIndex = execute.indexOf('clearEpisodeLobbyState()'); + + expect(roomClearIndex).toBeGreaterThan(-1); + 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 new file mode 100644 index 0000000..2fe02a7 --- /dev/null +++ b/extension/offline-media-intent-background.test.mjs @@ -0,0 +1,96 @@ +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 functionBody(name, nextName) { + const start = backgroundSource.indexOf(`function ${name}(`); + const end = backgroundSource.indexOf(`function ${nextName}(`, start + 1); + expect(start).toBeGreaterThan(-1); + return backgroundSource.slice(start, end === -1 ? backgroundSource.length : end); +} + +describe('offline media intent background integration', () => { + it('keeps online sends immediate and defers reconnect work only while ROOM_DATA is pending', () => { + const emit = functionBody('emit', 'emitLive'); + expect(emit).toContain('mustWaitForRoomData'); + expect(emit).toContain('&& !flushInProgress'); + expect(emit).toContain('socket.send(msg)'); + expect(emit).toContain('queueEvent(event, data)'); + expect(emit).not.toContain('setTimeout'); + expect(backgroundSource).toMatch(/awaitingRoomData = true;[\s\S]*emit\(EVENTS\.JOIN_ROOM/); + }); + + it('restores and migrates the persisted MV3 queue with a monotonic local sequence', () => { + expect(backgroundSource).toContain('normalizePersistedEventQueue('); + expect(backgroundSource).toContain('localSeq = Math.max(localSeq, maxQueuedSequence(eventQueue))'); + expect(backgroundSource).toContain('chrome.storage.session.set({ eventQueue, localSeq })'); + expect(backgroundSource).not.toContain('storage.sync.set({ eventQueue'); + expect(backgroundSource).toContain('if (restorationTimedOut) return'); + }); + + it('reconciles Host Control, Episode Lobby and solo mode before canonical recovery and replay', () => { + const roomDataStart = backgroundSource.indexOf('case EVENTS.ROOM_DATA:'); + const roomDataEnd = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:', roomDataStart); + const roomData = backgroundSource.slice(roomDataStart, roomDataEnd); + const policyIndex = roomData.indexOf('applyQueuedRoomPolicy(data.roomId'); + const canonicalIndex = roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'); + const flushIndex = roomData.indexOf('flushEventQueue(replaySettings)'); + expect(policyIndex).toBeGreaterThan(-1); + expect(canonicalIndex).toBeGreaterThan(-1); + expect(flushIndex).toBeGreaterThan(-1); + expect(policyIndex).toBeLessThan(canonicalIndex); + expect(canonicalIndex).toBeLessThan(flushIndex); + expect(roomData).toContain('activeLobby: !!episodeLobby'); + expect(roomData).toContain('desynced: hcmDesynced'); + 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', () => { + expect(functionBody('clearFailedJoinCredentials', 'invalidateChatSession')).toContain('clearEventQueue()'); + expect(functionBody('forceDisconnect', 'persistRoomIdleState')).toContain('eventQueue = []'); + expect(functionBody('leaveOldRoomIfSwitching', 'resetAudioProcessingInTab')).toContain('forceDisconnect()'); + const leaveHandler = backgroundSource.slice( + backgroundSource.indexOf("message.type === 'LEAVE_ROOM'"), + backgroundSource.indexOf("message.type === 'CLEAR_LOGS'") + ); + expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })"); + expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()'); + const retryHandler = backgroundSource.slice( + backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"), + backgroundSource.indexOf("message.type === 'GET_STATUS'") + ); + expect(retryHandler).toContain('forceDisconnect({ preserveEventQueue: true })'); + }); + + it('paces actual frames through a failure-retaining logical drain', () => { + const flush = functionBody('flushEventQueue', 'addToHistory'); + expect(flush).toContain('drainQueuedBatch(drainSource'); + expect(flush).toContain('maxWireEvents: FLUSH_BATCH_SIZE'); + expect(flush).toContain('return emitLive(frame.event, payload)'); + expect(flush).toContain('if (eventQueueVersion === drainVersion)'); + expect(flush).toContain('const consumedEntries = new Set(drainSource.slice(0, consumedCount))'); + expect(flush).toContain('eventQueue = eventQueue.filter(entry => !consumedEntries.has(entry))'); + expect(flush).toContain('flushConnectionGeneration !== connectionGeneration'); + expect(flush).not.toMatch(/eventQueue\.shift\(\)[\s\S]*emit\(/); + }); + + it('generation-scopes socket callbacks and stale ROOM_DATA work', () => { + expect(backgroundSource).toContain('let connectionGeneration = 0'); + expect(backgroundSource).toContain('socket !== connectionSocket'); + expect(backgroundSource).toContain('handleServerEvent(payload[0], payload[1], generation)'); + expect(backgroundSource).toContain('expectedConnectionGeneration !== connectionGeneration'); + expect(backgroundSource).toContain('data.roomId !== pendingRoomDataRoomId'); + }); + + it('exposes bounded queue diagnostics without media-title content', () => { + expect(backgroundSource).toContain('queuedLogicalEvents: eventQueue.length'); + expect(backgroundSource).toContain('queuedMediaIntents: queuedMediaIntentCount'); + expect(backgroundSource).toContain('queuedWireEvents: queuedWireCount(eventQueue)'); + expect(backgroundSource).not.toMatch(/Offline media intent[^`'\n]*mediaTitle/); + }); +}); diff --git a/extension/offline-media-intent.js b/extension/offline-media-intent.js new file mode 100644 index 0000000..ac8d12b --- /dev/null +++ b/extension/offline-media-intent.js @@ -0,0 +1,508 @@ +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; + +export const MEDIA_INTENT_KIND = 'media-intent'; +export const MAX_LOGICAL_QUEUE_SIZE = 50; + +const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]); +const KNOWN_EVENTS = new Set(Object.values(EVENTS)); +const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]); +const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]); +const HOST_GATED_EVENTS = new Set([ + ...MEDIA_EVENTS, + ...FORCE_SYNC_EVENTS, + EVENTS.EPISODE_LOBBY, + EVENTS.EPISODE_LOBBY_CANCEL +]); + +function validSequence(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function validTimestamp(value) { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + +function mediaTime(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(MAX_MEDIA_TIME, value)); +} + +function playbackStateFor(event, data) { + if (event === EVENTS.PLAY) return 'playing'; + if (event === EVENTS.PAUSE) return 'paused'; + return data?.playbackState === 'playing' || data?.playbackState === 'paused' + ? data.playbackState + : null; +} + +function mediaPositionFor(event, data) { + if (event === EVENTS.SEEK) { + return mediaTime(data?.targetTime) ?? mediaTime(data?.currentTime); + } + return mediaTime(data?.currentTime); +} + +function sanitizedMediaTitle(value) { + return typeof value === 'string' && value ? value.substring(0, 100) : null; +} + +function trimQueue(queue, maxEntries) { + const trimmed = queue.slice(); + let dropped = 0; + while (trimmed.length > maxEntries) { + if (trimmed[0]?.event === EVENTS.FORCE_SYNC_PREPARE) { + const executeIndex = trimmed.findIndex(entry => entry?.event === EVENTS.FORCE_SYNC_EXECUTE); + if (executeIndex >= 0) { + trimmed.splice(0, executeIndex + 1); + dropped += executeIndex + 1; + continue; + } + // Preserve an incomplete oldest Force Sync transaction. Evict the + // next-oldest work until EXECUTE arrives, at which point the whole + // transaction can be evicted atomically if pressure continues. + if (trimmed.length > 1) { + trimmed.splice(1, 1); + dropped++; + continue; + } + } + trimmed.shift(); + dropped++; + } + return { queue: trimmed, dropped }; +} + +function createIntentEntry(event, data, roomId) { + const currentTime = mediaPositionFor(event, data); + const playbackState = playbackStateFor(event, data); + if (event === EVENTS.SEEK && currentTime === null) return null; + if (playbackState === null && currentTime === null) return null; + return { + kind: MEDIA_INTENT_KIND, + roomId, + intent: { + playbackState, + currentTime, + latestEvent: event, + previousSeq: null, + latestSeq: validSequence(data?.seq), + actionTimestamp: validTimestamp(data?.actionTimestamp), + mediaTitle: sanitizedMediaTitle(data?.mediaTitle), + sourceEventCount: 1 + } + }; +} + +function mergeIntentEntry(entry, event, data) { + const incomingSeq = validSequence(data?.seq); + const previousLatestSeq = validSequence(entry.intent.latestSeq); + if (incomingSeq !== null && previousLatestSeq !== null && incomingSeq <= previousLatestSeq) { + return null; + } + + const incomingPosition = mediaPositionFor(event, data); + if (event === EVENTS.SEEK && incomingPosition === null) return entry; + const incomingState = playbackStateFor(event, data); + const merged = { + ...entry, + intent: { + ...entry.intent, + playbackState: incomingState ?? entry.intent.playbackState, + currentTime: incomingPosition ?? entry.intent.currentTime, + latestEvent: event, + actionTimestamp: validTimestamp(data?.actionTimestamp) ?? entry.intent.actionTimestamp, + mediaTitle: sanitizedMediaTitle(data?.mediaTitle) ?? entry.intent.mediaTitle, + sourceEventCount: entry.intent.sourceEventCount + 1 + } + }; + if (incomingSeq !== null) { + merged.intent.previousSeq = previousLatestSeq ?? validSequence(entry.intent.previousSeq); + merged.intent.latestSeq = incomingSeq; + } + return merged; +} + +export function isMediaQueueEvent(event) { + return MEDIA_EVENTS.has(event); +} + +export function isQueuedMediaIntent(entry) { + return entry?.kind === MEDIA_INTENT_KIND + && typeof entry.roomId === 'string' + && entry.roomId + && entry.intent + && typeof entry.intent === 'object'; +} + +export function enqueueQueuedEvent(queue, event, data, { + roomId, + maxEntries = MAX_LOGICAL_QUEUE_SIZE +} = {}) { + const next = Array.isArray(queue) ? queue.slice() : []; + let collapsed = 0; + + if (STALE_OFFLINE_EVENTS.has(event)) { + return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 }; + } + if (!KNOWN_EVENTS.has(event)) { + return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 }; + } + if (!isMediaQueueEvent(event)) { + next.push({ + kind: 'event', + roomId: typeof roomId === 'string' && roomId ? roomId : null, + event, + data + }); + } else if (typeof roomId === 'string' && roomId) { + const last = next.at(-1); + const hasMergeTarget = isQueuedMediaIntent(last) && last.roomId === roomId; + const merged = hasMergeTarget + ? mergeIntentEntry(last, event, data) + : null; + if (merged) { + next[next.length - 1] = merged; + collapsed = 1; + } else if (!hasMergeTarget) { + const entry = createIntentEntry(event, data, roomId); + if (entry) next.push(entry); + } else { + return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 }; + } + } + + const trimmed = trimQueue(next, maxEntries); + return { queue: trimmed.queue, collapsed, dropped: trimmed.dropped, droppedStale: 0 }; +} + +function normalizeIntentEntry(entry, roomId) { + if (!isQueuedMediaIntent(entry) || entry.roomId !== roomId) return null; + const intent = entry.intent; + const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused' + ? intent.playbackState + : null; + const currentTime = mediaTime(intent.currentTime); + const latestEvent = isMediaQueueEvent(intent.latestEvent) ? intent.latestEvent : null; + if (!latestEvent || (playbackState === null && currentTime === null)) return null; + return { + kind: MEDIA_INTENT_KIND, + roomId, + intent: { + playbackState, + currentTime, + latestEvent, + previousSeq: validSequence(intent.previousSeq), + latestSeq: validSequence(intent.latestSeq), + actionTimestamp: validTimestamp(intent.actionTimestamp), + mediaTitle: sanitizedMediaTitle(intent.mediaTitle), + sourceEventCount: Number.isSafeInteger(intent.sourceEventCount) && intent.sourceEventCount > 0 + ? intent.sourceEventCount + : 1 + } + }; +} + +function repairIntentSequences(entry, minimumSequence) { + const repaired = { + ...entry, + intent: { ...entry.intent } + }; + const hasState = repaired.intent.playbackState !== null; + const hasPosition = repaired.intent.currentTime !== null; + const previousSeq = validSequence(repaired.intent.previousSeq); + const latestSeq = validSequence(repaired.intent.latestSeq); + if (hasState && hasPosition) { + if (latestSeq === null) { + repaired.intent.previousSeq = null; + repaired.intent.latestSeq = minimumSequence + 1; + } else if (latestSeq <= minimumSequence) { + if (previousSeq === null) { + repaired.intent.previousSeq = null; + repaired.intent.latestSeq = minimumSequence + 1; + } else { + repaired.intent.previousSeq = minimumSequence + 1; + repaired.intent.latestSeq = minimumSequence + 2; + } + } else if (previousSeq !== null + && (previousSeq >= latestSeq || previousSeq <= minimumSequence)) { + repaired.intent.previousSeq = minimumSequence + 1; + repaired.intent.latestSeq = Math.max(latestSeq, minimumSequence + 2); + } + } else if (latestSeq === null || latestSeq <= minimumSequence) { + repaired.intent.previousSeq = null; + repaired.intent.latestSeq = minimumSequence + 1; + } + return repaired; +} + +export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOGICAL_QUEUE_SIZE) { + if (!Array.isArray(value) || typeof roomId !== 'string' || !roomId) return []; + let normalized = []; + let maximumSequence = 0; + for (const entry of value) { + if (isQueuedMediaIntent(entry)) { + let intentEntry = normalizeIntentEntry(entry, roomId); + if (intentEntry) { + intentEntry = repairIntentSequences(intentEntry, maximumSequence); + normalized.push(intentEntry); + normalized = trimQueue(normalized, maxEntries).queue; + maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry])); + } + continue; + } + if (!entry || typeof entry !== 'object' || typeof entry.event !== 'string') continue; + if (!KNOWN_EVENTS.has(entry.event)) continue; + if (typeof entry.roomId === 'string' && entry.roomId && entry.roomId !== roomId) continue; + if (STALE_OFFLINE_EVENTS.has(entry.event)) continue; + const data = entry.data && typeof entry.data === 'object' ? { ...entry.data } : entry.data; + const queuedSequence = validSequence(data?.seq); + if (data && typeof data === 'object' + && ((isMediaQueueEvent(entry.event) && queuedSequence === null) + || (queuedSequence !== null && queuedSequence <= maximumSequence))) { + data.seq = maximumSequence + 1; + } + if (isMediaQueueEvent(entry.event)) { + normalized = enqueueQueuedEvent(normalized, entry.event, data, { roomId, maxEntries }).queue; + } else { + normalized.push({ kind: 'event', roomId, event: entry.event, data }); + } + normalized = trimQueue(normalized, maxEntries).queue; + maximumSequence = Math.max(maximumSequence, maxQueuedSequence(normalized)); + } + return normalized; +} + +export function mediaIntentNeedsSequenceReservation(entry) { + if (!isQueuedMediaIntent(entry)) return false; + const { playbackState, currentTime, previousSeq, latestSeq } = entry.intent; + return playbackState !== null + && currentTime !== null + && validSequence(latestSeq) !== null + && validSequence(previousSeq) === null; +} + +export function reserveLatestMediaIntentSequence(queue, roomId, nextSequence) { + const next = Array.isArray(queue) ? queue.slice() : []; + const index = next.length - 1; + const entry = next[index]; + if (!isQueuedMediaIntent(entry) + || entry.roomId !== roomId + || !mediaIntentNeedsSequenceReservation(entry) + || validSequence(nextSequence) === null + || nextSequence <= entry.intent.latestSeq) { + return { queue: next, reserved: false }; + } + next[index] = { + ...entry, + intent: { + ...entry.intent, + previousSeq: entry.intent.latestSeq, + latestSeq: nextSequence + } + }; + return { queue: next, reserved: true }; +} + +function frameData(intent, seq, includeActionTimestamp = true) { + const data = {}; + if (seq !== null) data.seq = seq; + if (includeActionTimestamp && intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp; + if (intent.mediaTitle !== null) data.mediaTitle = intent.mediaTitle; + return data; +} + +export function materializeMediaIntent(entry) { + if (!isQueuedMediaIntent(entry)) return []; + const intent = entry.intent; + const currentTime = mediaTime(intent.currentTime); + const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused' + ? intent.playbackState + : null; + const latestSeq = validSequence(intent.latestSeq); + const previousSeq = validSequence(intent.previousSeq); + const stateEvent = playbackState === 'playing' ? EVENTS.PLAY : EVENTS.PAUSE; + + if (playbackState === null && currentTime !== null) { + return [{ + event: EVENTS.SEEK, + data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime } + }]; + } + if (playbackState !== null && currentTime === null) { + return [{ event: stateEvent, data: frameData(intent, latestSeq) }]; + } + if (playbackState === null || currentTime === null) return []; + + // A previous-format single PLAY/PAUSE has only one reserved sequence. Keep + // its original one-frame behavior during migration rather than inventing a + // sequence that could overtake a later transactional barrier. In a current + // two-frame intent, only the logical final frame carries actionTimestamp so + // its helper frame cannot falsely acknowledge the final user action. + if (previousSeq === null || latestSeq === null || previousSeq >= latestSeq) { + if (intent.latestEvent === EVENTS.SEEK) { + return [{ + event: EVENTS.SEEK, + data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime } + }]; + } + return [{ + event: stateEvent, + data: { ...frameData(intent, latestSeq), currentTime } + }]; + } + + const seekFrame = { + event: EVENTS.SEEK, + data: { + ...frameData( + intent, + intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq, + intent.latestEvent === EVENTS.SEEK + ), + currentTime, + targetTime: currentTime + } + }; + const stateFrame = { + event: stateEvent, + data: { + ...frameData( + intent, + intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq, + intent.latestEvent !== EVENTS.SEEK + ), + currentTime + } + }; + return intent.latestEvent === EVENTS.SEEK + ? [stateFrame, seekFrame] + : [seekFrame, stateFrame]; +} + +export function queuedEntryWireCount(entry) { + return isQueuedMediaIntent(entry) ? materializeMediaIntent(entry).length : 1; +} + +export function queuedWireCount(queue) { + return Array.isArray(queue) + ? queue.reduce((total, entry) => total + queuedEntryWireCount(entry), 0) + : 0; +} + +export function queuedMediaIntentCount(queue, roomId = null) { + return Array.isArray(queue) + ? queue.filter(entry => isQueuedMediaIntent(entry) && (!roomId || entry.roomId === roomId)).length + : 0; +} + +export function hasQueuedMediaIntent(queue, roomId) { + return queuedMediaIntentCount(queue, roomId) > 0; +} + +export function discardQueuedMediaIntents(queue, roomId) { + return Array.isArray(queue) + ? queue.filter(entry => !isQueuedMediaIntent(entry) || entry.roomId !== roomId) + : []; +} + +export function reconcileQueuedRoomIntent(queue, { + roomId, + canControl = true, + activeLobby = false, + desynced = false, + authoritativeLobby = false +} = {}) { + const source = Array.isArray(queue) ? queue : []; + const blockedEvents = !canControl + ? HOST_GATED_EVENTS + : new Set([ + ...(activeLobby || desynced ? FORCE_SYNC_EVENTS : []), + ...(authoritativeLobby ? [EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY, EVENTS.EPISODE_LOBBY_CANCEL] : []) + ]); + const reconciled = source.filter(entry => { + if (entry?.roomId && entry.roomId !== roomId) return false; + if (isQueuedMediaIntent(entry) && entry.roomId === roomId) { + return canControl && !activeLobby && !desynced; + } + return !blockedEvents?.has(entry?.event); + }); + const hasPendingLocalIntent = reconciled.some(entry => + (isQueuedMediaIntent(entry) && entry.roomId === roomId) + || (!isQueuedMediaIntent(entry) && FORCE_SYNC_EVENTS.has(entry?.event)) + ); + return { + queue: reconciled, + discarded: source.length - reconciled.length, + hasPendingLocalIntent + }; +} + +export function maxQueuedSequence(queue) { + let max = 0; + for (const entry of Array.isArray(queue) ? queue : []) { + if (isQueuedMediaIntent(entry)) { + max = Math.max(max, validSequence(entry.intent.previousSeq) ?? 0, validSequence(entry.intent.latestSeq) ?? 0); + } else { + max = Math.max(max, validSequence(entry?.data?.seq) ?? 0); + } + } + return max; +} + +export async function drainQueuedBatch(queue, { + roomId, + maxWireEvents, + sendFrame +}) { + const remaining = Array.isArray(queue) ? queue.slice() : []; + let sentWireEvents = 0; + let droppedStaleIntents = 0; + + while (remaining.length > 0) { + const entry = remaining[0]; + if (entry?.roomId && entry.roomId !== roomId) { + remaining.shift(); + droppedStaleIntents++; + continue; + } + const deliveryEntries = entry?.event === EVENTS.FORCE_SYNC_PREPARE + && remaining[1]?.event === EVENTS.FORCE_SYNC_EXECUTE + ? remaining.slice(0, 2) + : [entry]; + const frames = deliveryEntries.flatMap(deliveryEntry => { + const deliveryFrames = isQueuedMediaIntent(deliveryEntry) + ? materializeMediaIntent(deliveryEntry) + : [{ event: deliveryEntry.event, data: deliveryEntry.data }]; + return deliveryFrames.map(frame => ({ frame, entry: deliveryEntry })); + }); + if (frames.length === 0) { + remaining.splice(0, deliveryEntries.length); + continue; + } + if (sentWireEvents > 0 && sentWireEvents + frames.length > maxWireEvents) { + return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' }; + } + if (frames.length > maxWireEvents) { + return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'entry_exceeds_batch' }; + } + + let sentEntryFrames = 0; + for (const { frame, entry: deliveryEntry } of frames) { + if (!await sendFrame(frame, deliveryEntry)) { + return { + queue: remaining, + sentWireEvents: sentWireEvents + sentEntryFrames, + droppedStaleIntents, + status: 'send_failed' + }; + } + sentEntryFrames++; + } + sentWireEvents += sentEntryFrames; + remaining.splice(0, deliveryEntries.length); + if (sentWireEvents >= maxWireEvents) { + return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' }; + } + } + return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'drained' }; +} diff --git a/extension/offline-media-intent.test.mjs b/extension/offline-media-intent.test.mjs new file mode 100644 index 0000000..46d7d0a --- /dev/null +++ b/extension/offline-media-intent.test.mjs @@ -0,0 +1,399 @@ +import { describe, expect, it } from 'vitest'; +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; +import { canonicalMediaStateFromRoomData } from './canonical-media-state.js'; +import { + discardQueuedMediaIntents, + drainQueuedBatch, + enqueueQueuedEvent, + hasQueuedMediaIntent, + materializeMediaIntent, + maxQueuedSequence, + normalizePersistedEventQueue, + queuedEntryWireCount, + queuedMediaIntentCount, + queuedWireCount, + reconcileQueuedRoomIntent, + reserveLatestMediaIntentSequence +} from './offline-media-intent.js'; + +const roomId = 'room-a'; +const media = (event, data, queue = []) => enqueueQueuedEvent(queue, event, data, { roomId }).queue; + +function reserve(queue, sequence) { + return reserveLatestMediaIntentSequence(queue, roomId, sequence).queue; +} + +describe('offline media intent coalescing', () => { + it('normalizes an empty or roomless persisted queue to empty', () => { + expect(normalizePersistedEventQueue([], roomId)).toEqual([]); + expect(normalizePersistedEventQueue([{ event: EVENTS.PLAY, data: { seq: 1 } }], null)).toEqual([]); + }); + + it('creates a PLAY intent and reserves ordered legacy SEEK + PLAY frames', () => { + let queue = media(EVENTS.PLAY, { currentTime: 10, seq: 5, actionTimestamp: 100 }); + queue = reserve(queue, 6); + expect(queue).toHaveLength(1); + expect(materializeMediaIntent(queue[0])).toEqual([ + { event: EVENTS.SEEK, data: { seq: 5, currentTime: 10, targetTime: 10 } }, + { event: EVENTS.PLAY, data: { seq: 6, actionTimestamp: 100, currentTime: 10 } } + ]); + }); + + it('collapses repeated PLAY and SEEK while preserving final state and monotonic sequences', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2); + queue = media(EVENTS.PLAY, { currentTime: 20, seq: 3 }, queue); + queue = media(EVENTS.SEEK, { targetTime: 30, seq: 4 }, queue); + expect(queue).toHaveLength(1); + expect(queue[0].intent).toMatchObject({ + playbackState: 'playing', + currentTime: 30, + latestEvent: EVENTS.SEEK, + previousSeq: 3, + latestSeq: 4, + sourceEventCount: 3 + }); + expect(materializeMediaIntent(queue[0]).map(frame => frame.data.seq)).toEqual([3, 4]); + }); + + it('drops a regressing queued sequence instead of emitting stale ordering', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 10 }), 11); + const result = enqueueQueuedEvent(queue, EVENTS.PAUSE, { currentTime: 20, seq: 9 }, { roomId }); + expect(result.droppedStale).toBe(1); + expect(result.queue).toEqual(queue); + expect(maxQueuedSequence(result.queue)).toBe(11); + }); + + it('merges PLAY -> SEEK -> PAUSE into paused at the final position', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 500, seq: 1 }), 2); + queue = media(EVENTS.SEEK, { targetTime: 600, seq: 3 }, queue); + queue = media(EVENTS.PAUSE, { currentTime: 605, seq: 4 }, queue); + expect(materializeMediaIntent(queue[0])).toEqual([ + { event: EVENTS.SEEK, data: { seq: 3, currentTime: 605, targetTime: 605 } }, + { event: EVENTS.PAUSE, data: { seq: 4, currentTime: 605 } } + ]); + }); + + it('merges PAUSE -> SEEK -> PLAY into playing at the final position', () => { + let queue = reserve(media(EVENTS.PAUSE, { currentTime: 100, seq: 10 }), 11); + queue = media(EVENTS.SEEK, { targetTime: 200, seq: 12 }, queue); + queue = media(EVENTS.PLAY, { currentTime: 205, seq: 13 }, queue); + expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PLAY]); + expect(queue[0].intent).toMatchObject({ playbackState: 'playing', currentTime: 205 }); + }); + + it('clamps finite positions and rejects a SEEK with no trustworthy position', () => { + expect(media(EVENTS.SEEK, { targetTime: NaN, seq: 1 })).toEqual([]); + expect(media(EVENTS.SEEK, { targetTime: -50, seq: 1 })[0].intent.currentTime).toBe(0); + expect(media(EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 50, seq: 1 })[0].intent.currentTime).toBe(MAX_MEDIA_TIME); + }); + + it('keeps title metadata bounded and never retains arbitrary payload fields', () => { + const queue = media(EVENTS.PAUSE, { + currentTime: 10, + seq: 1, + mediaTitle: 'x'.repeat(200), + password: 'secret', + chatKey: 'secret' + }); + expect(queue[0].intent.mediaTitle).toHaveLength(100); + expect(JSON.stringify(queue)).not.toContain('password'); + expect(JSON.stringify(queue)).not.toContain('chatKey'); + }); + + it('keeps a thousand-event media burst at one logical entry', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 0, seq: 1 }), 2); + for (let index = 1; index <= 1000; index++) { + queue = media(EVENTS.SEEK, { targetTime: index, seq: index + 2 }, queue); + } + queue = media(EVENTS.PAUSE, { currentTime: 1000, seq: 1003 }, queue); + expect(queue).toHaveLength(1); + expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 1000, sourceEventCount: 1002 }); + }); + + it('treats retained coordination events as barriers', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2); + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 50, seq: 3 }, { roomId }).queue; + queue = media(EVENTS.SEEK, { targetTime: 100, seq: 4 }, queue); + queue = media(EVENTS.PAUSE, { currentTime: 120, seq: 5 }, queue); + expect(queue).toHaveLength(3); + expect(queue[0].kind).toBe('media-intent'); + expect(queue[1].event).toBe(EVENTS.FORCE_SYNC_PREPARE); + expect(queue[2].kind).toBe('media-intent'); + expect(queue[2].intent).toMatchObject({ playbackState: 'paused', currentTime: 120 }); + }); + + it('does not persist stale liveness and command ACK frames as ordering barriers', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2); + for (const event of [EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]) { + const result = enqueueQueuedEvent(queue, event, { seq: 99 }, { roomId }); + expect(result.droppedStale).toBe(1); + queue = result.queue; + } + queue = media(EVENTS.PAUSE, { currentTime: 20, seq: 3 }, queue); + expect(queue).toHaveLength(1); + expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 20 }); + }); + + it('preserves the bounded logical queue cap', () => { + let queue = []; + for (let index = 0; index < 60; index++) { + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { index }, { roomId }).queue; + } + expect(queue).toHaveLength(50); + expect(queue[0].data.index).toBe(10); + expect(queue.at(-1).data.index).toBe(59); + }); + + it('migrates old raw media entries without crossing transactional barriers', () => { + const restored = normalizePersistedEventQueue([ + { event: EVENTS.PLAY, data: { currentTime: 10, seq: 1 } }, + { event: EVENTS.SEEK, data: { targetTime: 20, seq: 2 } }, + { event: EVENTS.PAUSE, data: { currentTime: 25, seq: 3 } }, + { event: EVENTS.EPISODE_LOBBY, data: { expectedTitle: 'S01E02' } }, + { event: EVENTS.PLAY, data: { currentTime: 30, seq: 4 } } + ], roomId); + expect(restored).toHaveLength(3); + expect(restored[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 25 }); + expect(restored[1].event).toBe(EVENTS.EPISODE_LOBBY); + expect(restored[2].intent).toMatchObject({ playbackState: 'playing', currentTime: 30 }); + expect(maxQueuedSequence(restored)).toBe(4); + }); + + it('enforces the cap while restoring consecutive persisted media intents', () => { + const persisted = [10, 20, 30].map((currentTime, index) => ({ + kind: 'media-intent', + roomId, + intent: { + playbackState: 'paused', + currentTime, + latestEvent: EVENTS.PAUSE, + previousSeq: null, + latestSeq: index + 1, + actionTimestamp: index + 1, + mediaTitle: null, + sourceEventCount: 1 + } + })); + const restored = normalizePersistedEventQueue(persisted, roomId, 2); + expect(restored).toHaveLength(2); + expect(restored.map(entry => entry.intent.currentTime)).toEqual([20, 30]); + }); + + it('drops room-scoped barriers from another room and unknown persisted events', () => { + const restored = normalizePersistedEventQueue([ + { kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } }, + { kind: 'event', roomId, event: 'unexpected_event', data: { secret: 'nope' } }, + { kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 } } + ], roomId); + expect(restored).toEqual([{ + kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 } + }]); + }); + + it('discards stale-room intent without affecting the new room', () => { + const queue = reserve(media(EVENTS.PAUSE, { currentTime: 500, seq: 1 }), 2); + expect(hasQueuedMediaIntent(queue, roomId)).toBe(true); + expect(discardQueuedMediaIntents(queue, roomId)).toEqual([]); + expect(hasQueuedMediaIntent(queue, 'room-b')).toBe(false); + }); + + it('drops queued shared intent after controller role loss so canonical recovery can proceed', () => { + let queue = reserve(media(EVENTS.PAUSE, { currentTime: 1200, seq: 1 }), 2); + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 1300, seq: 3 }, { roomId }).queue; + const result = reconcileQueuedRoomIntent(queue, { roomId, canControl: false }); + expect(result.queue).toEqual([]); + expect(result.discarded).toBe(2); + expect(result.hasPendingLocalIntent).toBe(false); + }); + + it('keeps an active Episode Lobby authoritative over queued media and Force Sync', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 100, seq: 1 }), 2); + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 500, seq: 3 }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S01E02' }, { roomId }).queue; + const result = reconcileQueuedRoomIntent(queue, { roomId, activeLobby: true }); + expect(result.queue).toEqual([{ + kind: 'event', + roomId, + event: EVENTS.EPISODE_READY, + data: { title: 'S01E02' } + }]); + expect(result.hasPendingLocalIntent).toBe(false); + }); + + it('drops stale queued Episode Lobby coordination when ROOM_DATA has an authoritative lobby', () => { + let queue = enqueueQueuedEvent([], EVENTS.EPISODE_LOBBY, { expectedTitle: 'S02E01' }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S02E01' }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_LOBBY_CANCEL, {}, { roomId }).queue; + const result = reconcileQueuedRoomIntent(queue, { + roomId, + activeLobby: true, + authoritativeLobby: true + }); + expect(result.queue).toEqual([]); + expect(result.discarded).toBe(3); + }); + + it('does not let intentional solo mode retain future room-driving intent', () => { + const queue = reserve(media(EVENTS.SEEK, { targetTime: 600, playbackState: 'paused', seq: 1 }), 2); + const result = reconcileQueuedRoomIntent(queue, { roomId, desynced: true }); + expect(result.queue).toEqual([]); + expect(result.hasPendingLocalIntent).toBe(false); + }); + + it('materializes legacy events normally when an old relay has no media-state capability', () => { + expect(canonicalMediaStateFromRoomData({ + roomId, + capabilities: ['host-control', 'chat-v1'] + })).toEqual({ status: 'unsupported', mediaState: null }); + const queue = reserve(media(EVENTS.PAUSE, { currentTime: 75, seq: 20 }), 21); + expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]); + expect(materializeMediaIntent(queue[0]).every(frame => + frame.data.mediaState === undefined && frame.data.revision === undefined + )).toBe(true); + }); +}); + +describe('offline media intent drain', () => { + it('counts actual wire frames and never splits an intent at a batch boundary', async () => { + let queue = enqueueQueuedEvent([], EVENTS.EPISODE_READY, { seq: 1 }, { roomId }).queue; + queue = reserve(media(EVENTS.PAUSE, { currentTime: 50, seq: 2 }, queue), 3); + const sent = []; + const first = await drainQueuedBatch(queue, { + roomId, + maxWireEvents: 2, + sendFrame: async frame => { sent.push(frame); return true; } + }); + expect(first.sentWireEvents).toBe(1); + expect(first.queue).toHaveLength(1); + expect(sent.map(frame => frame.event)).toEqual([EVENTS.EPISODE_READY]); + + const second = await drainQueuedBatch(first.queue, { + roomId, + maxWireEvents: 2, + sendFrame: async frame => { sent.push(frame); return true; } + }); + expect(second.sentWireEvents).toBe(2); + expect(second.queue).toEqual([]); + expect(sent.slice(1).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]); + }); + + it('retains the whole logical intent after a partial send failure', async () => { + const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5, actionTimestamp: 500 }), 6); + let calls = 0; + const result = await drainQueuedBatch(queue, { + roomId, + maxWireEvents: 10, + sendFrame: async () => ++calls === 1 + }); + expect(result.status).toBe('send_failed'); + expect(result.sentWireEvents).toBe(1); + expect(result.queue).toEqual(queue); + expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.seq)).toEqual([5, 6]); + expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.actionTimestamp)) + .toEqual([undefined, 500]); + }); + + it('drops stale-room intent during drain and preserves unrelated events', async () => { + let queue = reserve(media(EVENTS.PAUSE, { currentTime: 40, seq: 1 }), 2); + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: 3 }, { roomId }).queue; + const sent = []; + const result = await drainQueuedBatch(queue, { + roomId: 'room-b', + maxWireEvents: 10, + sendFrame: async frame => { sent.push(frame); return true; } + }); + expect(result.droppedStaleIntents).toBe(2); + expect(sent).toEqual([]); + }); + + it('repairs regressing sequences across malformed persisted intent entries', () => { + const restored = normalizePersistedEventQueue([ + { + kind: 'media-intent', + roomId, + intent: { + playbackState: 'playing', currentTime: 10, latestEvent: EVENTS.PLAY, + previousSeq: 99, latestSeq: 100, actionTimestamp: 1, mediaTitle: null, sourceEventCount: 1 + } + }, + { + kind: 'media-intent', + roomId, + intent: { + playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE, + previousSeq: 49, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1 + } + } + ], roomId); + expect(materializeMediaIntent(restored[0]).map(frame => frame.data.seq)).toEqual([99, 100]); + expect(materializeMediaIntent(restored[1]).map(frame => frame.data.seq)).toEqual([101, 102]); + expect(maxQueuedSequence(restored)).toBe(102); + }); + + it('preserves a valid legacy single-frame intent during sequence repair', () => { + const restored = normalizePersistedEventQueue([{ + kind: 'media-intent', + roomId, + intent: { + playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE, + previousSeq: null, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1 + } + }], roomId); + expect(materializeMediaIntent(restored[0])).toEqual([{ + event: EVENTS.PAUSE, + data: { seq: 50, actionTimestamp: 2, currentTime: 20 } + }]); + }); + + it('evicts a complete Force Sync transaction instead of orphaning EXECUTE at the cap', () => { + let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue; + for (let index = 0; index < 49; index++) { + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_ACK, { seq: index + 3 }, { roomId }).queue; + } + expect(queue).toHaveLength(49); + expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_PREPARE)).toBe(false); + expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false); + }); + + it('keeps adjacent Force Sync PREPARE and EXECUTE in the same replay batch', async () => { + let queue = []; + for (let index = 0; index < 9; index++) { + queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: index + 1 }, { roomId }).queue; + } + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 10 }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 11 }, { roomId }).queue; + const sent = []; + const result = await drainQueuedBatch(queue, { + roomId, + maxWireEvents: 10, + sendFrame: async frame => { sent.push(frame.event); return true; } + }); + expect(sent).toEqual(Array(9).fill(EVENTS.EPISODE_READY)); + expect(result.queue.map(entry => entry.event)).toEqual([ + EVENTS.FORCE_SYNC_PREPARE, + EVENTS.FORCE_SYNC_EXECUTE + ]); + }); + + it('retains the full Force Sync transaction if EXECUTE replay fails', async () => { + let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue; + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue; + const result = await drainQueuedBatch(queue, { + roomId, + maxWireEvents: 10, + sendFrame: async frame => frame.event !== EVENTS.FORCE_SYNC_EXECUTE + }); + expect(result.status).toBe('send_failed'); + expect(result.sentWireEvents).toBe(1); + expect(result.queue).toEqual(queue); + }); + + it('reports logical and actual-wire queue sizes separately', () => { + let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2); + queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { seq: 3 }, { roomId }).queue; + expect(queuedMediaIntentCount(queue, roomId)).toBe(1); + expect(queuedEntryWireCount(queue[0])).toBe(2); + expect(queuedWireCount(queue)).toBe(3); + }); +}); diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 9ff87a0..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())')) @@ -116,11 +116,17 @@ describe('target tab lifecycle', () => { expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'"); expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });'); + const controlModeStart = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:'); + const controlModeEnd = backgroundSource.indexOf('case EVENTS.ROOM_LIST:', controlModeStart); + expect(backgroundSource.slice(controlModeStart, controlModeEnd)).toContain('if (!currentRoom) break;'); + expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'"); expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'"); expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED'); expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT'); - expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')"); + expect(serverSource).toContain( + "removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false })" + ); }); it('does not promote a nested media target without confirmed parent visibility', () => { diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index 3146fda..537f06f 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -107,6 +107,7 @@ function copyExtensionFiles(targetDir, browserName) { // Robust Extraction using flexible regex const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/); const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/); + const maxMediaTimeMatch = constantsContent.match(/export const MAX_MEDIA_TIME\s*=\s*(\d+);/); if (!eventsMatch) { throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js'); @@ -114,9 +115,13 @@ function copyExtensionFiles(targetDir, browserName) { if (!heartbeatMatch) { throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js'); } + if (!maxMediaTimeMatch) { + throw new Error('CRITICAL: Could not find MAX_MEDIA_TIME in shared/constants.js'); + } const eventsObject = eventsMatch[1]; const heartbeatVal = heartbeatMatch[1]; + const maxMediaTimeVal = maxMediaTimeMatch[1]; const items = fs.readdirSync(extDir); for (const item of items) { @@ -136,7 +141,7 @@ function copyExtensionFiles(targetDir, browserName) { const eStart = '// --- SHARED_EVENTS_INJECT_START ---'; const eEnd = '// --- SHARED_EVENTS_INJECT_END ---'; const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`); - const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`; + const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n ${eEnd}`; content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection'); @@ -184,6 +189,14 @@ function copyExtensionFiles(targetDir, browserName) { fs.writeFileSync(destPath, content); console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`); + } else if (item === 'canonical-media-state.js' || item === 'offline-media-intent.js') { + let content = fs.readFileSync(srcPath, 'utf8'); + const sourceImport = "from '../shared/constants.js'"; + if (!content.includes(sourceImport)) { + throw new Error(`CRITICAL: Source shared constants import missing in ${item}. Aborting build.`); + } + content = content.replace(sourceImport, "from './shared/constants.js'"); + fs.writeFileSync(destPath, content); } else if (item === 'popup.html') { let content = fs.readFileSync(srcPath, 'utf8'); const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC'; diff --git a/scripts/check-coverage-inventory.mjs b/scripts/check-coverage-inventory.mjs index a5b463d..9d51db0 100644 --- a/scripts/check-coverage-inventory.mjs +++ b/scripts/check-coverage-inventory.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/scripts/coverage-plan.mjs b/scripts/coverage-plan.mjs index 9b47dfb..1966706 100644 --- a/scripts/coverage-plan.mjs +++ b/scripts/coverage-plan.mjs @@ -1,10 +1,12 @@ export const VITEST_COVERAGE_INCLUDE = Object.freeze([ 'server/chat.js', + 'server/media-state.js', 'server/ops.js', 'server/rate-limiter.js', 'shared/blacklist.js', 'shared/invite-links.js', 'shared/names.js', + 'extension/canonical-media-state.js', 'extension/chat-activity.js', 'extension/chat-crypto.js', 'extension/chat-format.js', @@ -13,6 +15,7 @@ export const VITEST_COVERAGE_INCLUDE = Object.freeze([ 'extension/episode-utils.js', 'extension/host-access.js', 'extension/media-frame-target.js', + 'extension/offline-media-intent.js', 'extension/title-privacy.js', 'scripts/release-artifact-checks.mjs' ]); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 4272f65..2928676 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,67 +13,81 @@ export function replaceExactly(text, pattern, replacement, label) { return text.replace(pattern, replacement); } -function writeJson(root, relativePath, update) { +function stageJson(stagedUpdates, root, relativePath, update) { const absolutePath = path.join(root, relativePath); - const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8')); + const current = stagedUpdates.has(absolutePath) + ? stagedUpdates.get(absolutePath) + : fs.readFileSync(absolutePath, 'utf8'); + const value = JSON.parse(current); update(value); - fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + stagedUpdates.set(absolutePath, `${JSON.stringify(value, null, 2)}\n`); } -function updateText(root, relativePath, pattern, replacement, label) { +function stageText(stagedUpdates, root, relativePath, pattern, replacement, label) { const absolutePath = path.join(root, relativePath); - const current = fs.readFileSync(absolutePath, 'utf8'); - fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8'); + const current = stagedUpdates.has(absolutePath) + ? stagedUpdates.get(absolutePath) + : fs.readFileSync(absolutePath, 'utf8'); + stagedUpdates.set(absolutePath, replaceExactly(current, pattern, replacement, label)); } export function prepareRelease(version, date = new Date(), root = repoRoot) { versionFromTag(`v${version}`); const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z'); - writeJson(root, 'package.json', value => { value.version = version; }); - writeJson(root, 'package-lock.json', value => { + const stagedUpdates = new Map(); + stageJson(stagedUpdates, root, 'package.json', value => { value.version = version; }); + stageJson(stagedUpdates, root, 'package-lock.json', value => { value.version = version; value.packages[''].version = version; }); - writeJson(root, 'extension/manifest.base.json', value => { value.version = version; }); - writeJson(root, 'website/version.json', value => { + stageJson(stagedUpdates, root, 'extension/manifest.base.json', value => { value.version = version; }); + stageJson(stagedUpdates, root, 'website/version.json', value => { value.version = version; value.date = timestamp; }); - updateText( + stageText( + stagedUpdates, root, 'shared/constants.js', /export const APP_VERSION = ["'][^"']+["'];/gu, `export const APP_VERSION = "${version}";`, 'shared/constants.js' ); - updateText( + stageText( + stagedUpdates, root, 'website/template.html', /"softwareVersion": "[^"]+"/gu, `"softwareVersion": "${version}"`, 'website/template.html' ); - updateText( + stageText( + stagedUpdates, root, 'website/llms.txt', /Current website release: .+/gu, `Current website release: ${version}`, 'website/llms.txt' ); - updateText( + stageText( + stagedUpdates, root, 'README.md', /Release-v\d+\.\d+\.\d+-blue/gu, `Release-v${version}-blue`, 'README.md release badge' ); - updateText( + stageText( + stagedUpdates, root, 'README.md', /New v\d+\.\d+\.\d+ Release!/gu, `New v${version} Release!`, 'README.md release banner' ); + for (const [absolutePath, content] of stagedUpdates) { + fs.writeFileSync(absolutePath, content, 'utf8'); + } console.log(`Prepared release v${version} at ${timestamp}`); } diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs index a6d649b..e5413d8 100644 --- a/scripts/prepare-release.test.mjs +++ b/scripts/prepare-release.test.mjs @@ -87,4 +87,16 @@ describe('release preparation helpers', () => { .toThrow('vMAJOR.MINOR.PATCH'); expect(readFixture(root)).toEqual(before); }); + + it('does not partially update release sources when a later marker is invalid', () => { + const root = createReleaseFixture(); + const llmsPath = path.join(root, 'website/llms.txt'); + fs.writeFileSync(llmsPath, fs.readFileSync(llmsPath, 'utf8') + .replace(/Current website release: .+/u, 'Release marker intentionally missing'), 'utf8'); + const before = readFixture(root); + + expect(() => prepareRelease('9.8.7', new Date('2030-01-01T00:00:00Z'), root)) + .toThrow('website/llms.txt must contain exactly one release-version marker'); + expect(readFixture(root)).toEqual(before); + }); }); diff --git a/scripts/release-local-gate.mjs b/scripts/release-local-gate.mjs index c615c8c..c80f67a 100644 --- a/scripts/release-local-gate.mjs +++ b/scripts/release-local-gate.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index c82fe57..0e28a50 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 7881b6f..ffffb7a 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -5,6 +5,12 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js'; +import { + enqueueQueuedEvent, + materializeMediaIntent, + reserveLatestMediaIntentSequence +} from '../extension/offline-media-intent.js'; +import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); @@ -22,11 +28,14 @@ async function c() { } function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); } function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); } -async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } +async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities}); - assert.equal((await a(ws))[0],'room_data'); + const [event, data] = await a(ws); + assert.equal(event,'room_data'); + return data; } +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } // Test suite opens >10 connections/min — clear the IP connection counter so the // connection rate limiter doesn't mask test failures (test-only, never at runtime). @@ -40,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'); @@ -59,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'); @@ -67,6 +103,68 @@ try { close(); resetConnectionRate(); + // --- Combined reconnect model: compacted new-client intent over legacy wire --- + // The queue is entirely client-side. Feed its materialized PLAY/PAUSE/SEEK + // frames through the real relay and an old-client-like receiver to prove the + // optimization needs no new event, capability or ACK. + const coalescedRid = 'coalesced-media-'+Date.now(); + const legacyReceiver = await c(), coalescingSender = await c(); + await j(legacyReceiver, coalescedRid, 'legacy-receiver'); + await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1', 'media-state-v1']); + legacyReceiver._m.length = coalescingSender._m.length = 0; + + s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 }); + await w(coalescingSender, 'play'); + const canonicalBeforeReplay = { ...mod.rooms.get(coalescedRid).mediaState }; + + let compactedQueue = enqueueQueuedEvent([], 'play', { + currentTime: 500, + seq: 10, + actionTimestamp: 10 + }, { roomId: coalescedRid }).queue; + compactedQueue = reserveLatestMediaIntentSequence(compactedQueue, coalescedRid, 11).queue; + for (const [targetTime, seq] of [[540, 12], [600, 13]]) { + compactedQueue = enqueueQueuedEvent(compactedQueue, 'seek', { + currentTime: targetTime, + targetTime, + seq, + actionTimestamp: seq + }, { roomId: coalescedRid }).queue; + } + compactedQueue = enqueueQueuedEvent(compactedQueue, 'pause', { + currentTime: 605, + seq: 14, + actionTimestamp: 14 + }, { roomId: coalescedRid }).queue; + assert.equal(compactedQueue.length, 1, 'offline playback burst is one logical queue entry'); + const replayFrames = materializeMediaIntent(compactedQueue[0]); + assert.deepEqual(replayFrames.map(frame => frame.event), ['seek', 'pause'], + 'compacted intent uses only the minimum existing legacy events'); + + const legacyReplayPayloads = []; + for (const frame of replayFrames) { + s(coalescingSender, frame.event, frame.data); + legacyReplayPayloads.push([frame.event, await w(legacyReceiver, frame.event)]); + } + assert.equal(legacyReplayPayloads[0][1].targetTime, 605); + assert.equal(legacyReplayPayloads[1][1].currentTime, 605); + for (const [event, payload] of legacyReplayPayloads) { + assert.ok(event === 'seek' || event === 'pause'); + assert.equal(payload.mediaState, undefined); + assert.equal(payload.revision, undefined); + } + const canonicalAfterReplay = mod.rooms.get(coalescedRid).mediaState; + assert.equal(canonicalAfterReplay.revision, canonicalBeforeReplay.revision + replayFrames.length); + assert.equal(canonicalAfterReplay.playbackState, 'paused'); + assert.equal(canonicalAfterReplay.currentTime, 605); + assert.equal(canonicalAfterReplay.updatedBy, 'coalesce-sender'); + + const coalescedLateJoiner = await c(); + const coalescedLateRoom = await j(coalescedLateJoiner, coalescedRid, 'coalesced-late'); + assert.equal(coalescedLateRoom.mediaState.revision, canonicalAfterReplay.revision); + assert.equal(coalescedLateRoom.mediaState.playbackState, 'paused'); + assert.equal(coalescedLateRoom.mediaState.currentTime, 605); + assert.equal(coalescedLateRoom.mediaState.updatedBy, 'coalesce-sender'); // --- Stale peer reaper: terminal timeout + clean rejoin --- const staleClient = await c(); const staleRoomId = 'stale-'+Date.now(); @@ -95,23 +193,431 @@ try { 'ROOM_DATA advertises the host-control capability'); assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability'); assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability'); + assert.ok(capData.capabilities.includes('media-state-v1'), 'ROOM_DATA advertises canonical media state v1'); + assert.equal(capData.mediaState, null, 'a new room starts without invented canonical media state'); assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history'); close(); resetConnectionRate(); + // --- Mixed-version rollout: pre-media-state extension + current extension --- + // Legacy intentionally omits clientCapabilities entirely and uses only the + // pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current + // Current clients additionally advertise that they maintain canonical state + // while solo. Recovery itself remains relay-gated and legacy clients still + // omit clientCapabilities entirely. + const mixedMediaRid = 'mixed-media-'+Date.now(); + const legacyMedia = await c(), currentMedia = await c(); + const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media'); + const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']); + assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning'); + assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array'); + assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type'); + assert.ok(Array.isArray(legacyRoomData.controllers), 'legacy ROOM_DATA keeps controllers array'); + assert.equal(legacyRoomData.mediaState, null, 'new ROOM_DATA field is additive and initially null'); + assert.ok(currentRoomData.capabilities.includes('media-state-v1'), 'current client sees relay capability'); + legacyMedia._m.length = currentMedia._m.length = 0; + + const assertUnchangedMediaWire = (payload, label) => { + for (const field of ['revision', 'mediaState', 'updatedAt', 'updatedBy']) { + assert.equal(payload[field], undefined, `${label} does not add canonical field ${field}`); + } + }; + + // Legacy -> current: ordinary live relay is unchanged while internal state advances. + s(legacyMedia, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1001 }); + const legacyPlayRelay = await w(currentMedia, 'play'); + assert.equal(legacyPlayRelay.currentTime, 100); + assert.equal(legacyPlayRelay.senderId, 'legacy-media'); + assertUnchangedMediaWire(legacyPlayRelay, 'legacy PLAY relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 1, + playbackState: 'playing', + currentTime: 100, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }, 'legacy PLAY canonicalizes without a media-state client capability'); + + s(legacyMedia, 'seek', { currentTime: 1200, targetTime: 1200, seq: 2, actionTimestamp: 1002 }); + const legacySeekRelay = await w(currentMedia, 'seek'); + assert.equal(legacySeekRelay.currentTime, 1200); + assert.equal(legacySeekRelay.targetTime, 1200); + assertUnchangedMediaWire(legacySeekRelay, 'legacy SEEK relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 2, + playbackState: 'playing', + currentTime: 1200, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }); + + s(legacyMedia, 'pause', { currentTime: 1200, seq: 3, actionTimestamp: 1003 }); + const legacyPauseRelay = await w(currentMedia, 'pause'); + assert.equal(legacyPauseRelay.currentTime, 1200); + assertUnchangedMediaWire(legacyPauseRelay, 'legacy PAUSE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: 3, + playbackState: 'paused', + currentTime: 1200, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }); + + // Current -> legacy: old receive path sees the same ordinary events and fields. + s(currentMedia, 'play', { currentTime: 1300, seq: 1, actionTimestamp: 2001 }); + const currentPlayRelay = await w(legacyMedia, 'play'); + assert.equal(currentPlayRelay.currentTime, 1300); + assertUnchangedMediaWire(currentPlayRelay, 'current PLAY relay to legacy client'); + s(currentMedia, 'seek', { currentTime: 1400, targetTime: 1400, seq: 2, actionTimestamp: 2002 }); + const currentSeekRelay = await w(legacyMedia, 'seek'); + assert.equal(currentSeekRelay.targetTime, 1400); + assertUnchangedMediaWire(currentSeekRelay, 'current SEEK relay to legacy client'); + + // Legacy Force Sync needs no new target on EXECUTE, event or ACK. Canonical + // bookkeeping remains internal and commits only after the existing execute. + const beforeLegacyPrepare = { ...mod.rooms.get(mixedMediaRid).mediaState }; + s(legacyMedia, 'force_sync_prepare', { targetTime: 1600, seq: 4, actionTimestamp: 1004 }); + const legacyPrepareRelay = await w(currentMedia, 'force_sync_prepare'); + assert.equal(legacyPrepareRelay.targetTime, 1600); + assertUnchangedMediaWire(legacyPrepareRelay, 'legacy Force Sync PREPARE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, beforeLegacyPrepare, + 'legacy PREPARE remains choreography and does not commit canonical state'); + s(currentMedia, 'force_sync_ack', { seq: 3 }); + const currentAckRelay = await w(legacyMedia, 'force_sync_ack'); + assert.equal(currentAckRelay.senderId, 'current-media'); + assertUnchangedMediaWire(currentAckRelay, 'existing Force Sync ACK relay'); + s(legacyMedia, 'force_sync_execute', { seq: 5, actionTimestamp: 1005 }); + const legacyExecuteRelay = await w(currentMedia, 'force_sync_execute'); + assert.equal(legacyExecuteRelay.targetTime, undefined, 'legacy EXECUTE still requires no target field'); + assertUnchangedMediaWire(legacyExecuteRelay, 'legacy Force Sync EXECUTE relay'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, { + revision: beforeLegacyPrepare.revision + 1, + playbackState: 'playing', + currentTime: 1600, + updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt, + updatedBy: 'legacy-media' + }, 'legacy Force Sync EXECUTE commits only internal canonical state'); + + // Host Control remains the sole authorization chokepoint, independent of + // media-state client knowledge. + s(legacyMedia, 'set_control_mode', { controlMode: 'host-only' }); + await w(legacyMedia, 'control_mode'); + await w(currentMedia, 'control_mode'); + legacyMedia._m.length = currentMedia._m.length = 0; + const mixedHostBaseline = { ...mod.rooms.get(mixedMediaRid).mediaState }; + s(currentMedia, 'seek', { currentTime: 1650, targetTime: 1650, seq: 4, actionTimestamp: 2004 }); + let mixedGuestRejected = false; + try { await w(legacyMedia, 'seek', 600); } catch { mixedGuestRejected = true; } + assert.ok(mixedGuestRejected, 'current guest command remains rejected in mixed host-only room'); + assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, mixedHostBaseline, + 'rejected mixed-version guest command does not mutate or revise canonical state'); + + s(legacyMedia, 'seek', { currentTime: 1700, targetTime: 1700, seq: 6, actionTimestamp: 1006 }); + const legacyHostSeek = await w(currentMedia, 'seek'); + assert.equal(legacyHostSeek.targetTime, 1700, 'allowed legacy controller command relays normally'); + assert.equal(mod.rooms.get(mixedMediaRid).mediaState.updatedBy, 'legacy-media'); + assert.equal(mod.rooms.get(mixedMediaRid).mediaState.currentTime, 1700); + + // Make the final stable intent legacy-owned, then reconnect only the current client. + s(legacyMedia, 'pause', { currentTime: 1700, seq: 7, actionTimestamp: 1007 }); + await w(currentMedia, 'pause'); + const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision; + const currentMediaRejoin = await c(); + const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']); + assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision); + assert.equal(mixedRejoinData.mediaState.playbackState, 'paused'); + assert.equal(mixedRejoinData.mediaState.currentTime, 1700); + assert.equal(mixedRejoinData.mediaState.updatedBy, 'legacy-media', + 'current reconnect snapshot reflects the legacy client latest accepted intent'); + assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false, + 'legacy client receives no new canonical event or ACK requirement'); + currentMediaRejoin.close(); + await delay(100); + assert.equal(mod.rooms.get(mixedMediaRid).mediaState, null, + 'canonical state is cleared when only a legacy solo-suppressing client remains'); + close(); + resetConnectionRate(); + + // The inverse must remain true: a capable solo client keeps publishing + // PLAY/PAUSE/SEEK, so removing a legacy peer must not discard valid state. + const capableSoloRid = 'capable-solo-'+Date.now(); + const capableSolo = await c(), transientLegacy = await c(); + await j(capableSolo, capableSoloRid, 'capable-solo', null, ['chat-v1', 'media-state-v1']); + await j(transientLegacy, capableSoloRid, 'transient-legacy'); + capableSolo._m.length = transientLegacy._m.length = 0; + s(capableSolo, 'play', { currentTime: 42, mediaTitle: 'Series S03E04' }); + await w(transientLegacy, 'play'); + const capableSoloState = { ...mod.rooms.get(capableSoloRid).mediaState }; + transientLegacy.close(); + await delay(100); + assert.deepEqual(mod.rooms.get(capableSoloRid).mediaState, capableSoloState, + 'canonical state remains valid when the sole remaining client advertises media-state-v1'); + close(); + resetConnectionRate(); + + // --- Canonical Media State v1: late join, pause, seek and reconnect --- + const msrid = 'media-state-'+Date.now(); + const msa = await c(); + const initialMediaRoom = await j(msa, msrid, 'msa'); + assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null'); + + s(msa, 'play', { + currentTime: 100, + mediaTitle: 'Series S01E02', + revision: 999, + updatedBy: 'spoofed' + }); + await delay(120); + const msb = await c(); + const playingJoin = await j(msb, msrid, 'msb'); + assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1'); + assert.equal(playingJoin.mediaState.playbackState, 'playing'); + assert.equal(playingJoin.mediaState.mediaTitle, 'Series S01E02'); + assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101, + `playing late join receives projected position (${playingJoin.mediaState.currentTime})`); + assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity'); + + s(msa, 'pause', { currentTime: 150 }); + await delay(40); + const pausedRevision = mod.rooms.get(msrid).mediaState.revision; + await delay(100); + const msc = await c(); + const pausedJoin = await j(msc, msrid, 'msc'); + assert.equal(pausedJoin.mediaState.revision, pausedRevision); + assert.equal(pausedJoin.mediaState.playbackState, 'paused'); + assert.equal(pausedJoin.mediaState.currentTime, 150, 'paused late join position does not advance'); + + s(msa, 'seek', { currentTime: 500, targetTime: 600 }); + await delay(40); + const seekState = mod.rooms.get(msrid).mediaState; + assert.equal(seekState.currentTime, 600, 'SEEK uses targetTime rather than currentTime'); + assert.equal(seekState.playbackState, 'paused', 'SEEK preserves canonical playback state'); + + s(msa, 'play', { currentTime: 1800 }); + await delay(60); + const reconnect1 = await c(); + const reconnectFirst = await j(reconnect1, msrid, 'reconnect'); + reconnect1.close(); + await delay(120); + const beforePeerDedupe = { ...mod.rooms.get(msrid).mediaState }; + const reconnect2 = await c(); + const reconnectSecond = await j(reconnect2, msrid, 'reconnect'); + assert.equal(reconnectSecond.mediaState.revision, reconnectFirst.mediaState.revision, + 'lazy clock projection does not increment revision'); + assert.ok(reconnectSecond.mediaState.currentTime > reconnectFirst.mediaState.currentTime, + 'reconnect receives a newly projected playing position'); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforePeerDedupe, + 'peer dedupe/reconnect does not reset or revise canonical state'); + + const beforeOrderingRevision = mod.rooms.get(msrid).mediaState.revision; + s(msa, 'seek', { targetTime: 100 }); + await delay(30); + s(msb, 'seek', { targetTime: 200 }); + await delay(40); + const orderedState = mod.rooms.get(msrid).mediaState; + assert.equal(orderedState.revision, beforeOrderingRevision + 2, 'accepted controllers increment revision in server order'); + assert.equal(orderedState.currentTime, 200, 'last accepted controller wins'); + assert.equal(orderedState.updatedBy, 'msb'); + s(msb, 'seek', { targetTime: 300 }); + await delay(30); + s(msa, 'seek', { targetTime: 400 }); + await delay(40); + const reverseOrderedState = mod.rooms.get(msrid).mediaState; + assert.equal(reverseOrderedState.revision, orderedState.revision + 2); + assert.equal(reverseOrderedState.currentTime, 400, 'reversing server order reverses the winning controller'); + assert.equal(reverseOrderedState.updatedBy, 'msa'); + + // Heartbeats remain observations and cannot rewrite canonical intent. + const beforeHeartbeat = { ...reverseOrderedState }; + s(msa, 'peer_status', { playbackState: 'paused', currentTime: 999 }); + await delay(40); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'PEER_STATUS does not mutate canonical state'); + + // Force Sync PREPARE is choreography; matching EXECUTE commits its final target. + s(msa, 'force_sync_prepare', { targetTime: 700 }); + await delay(40); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'Force Sync PREPARE does not mutate canonical state'); + s(msa, 'force_sync_execute', {}); + await delay(40); + const forceSyncState = mod.rooms.get(msrid).mediaState; + assert.equal(forceSyncState.revision, beforeHeartbeat.revision + 1); + assert.equal(forceSyncState.playbackState, 'playing'); + assert.equal(forceSyncState.currentTime, 700); + + // The legacy wire exposes one room-wide prepared target. A later PREPARE + // replaces what every peer has just sought to; any currently authorized + // EXECUTE must commit that visible target instead of clearing it unmatched. + s(msa, 'force_sync_prepare', { targetTime: 800 }); + await w(msb, 'force_sync_prepare'); + s(msb, 'force_sync_prepare', { targetTime: 900 }); + await w(msa, 'force_sync_prepare'); + const beforeCompetingExecute = { ...mod.rooms.get(msrid).mediaState }; + s(msa, 'force_sync_execute', {}); + await w(msb, 'force_sync_execute'); + const competingForceState = mod.rooms.get(msrid).mediaState; + assert.equal(competingForceState.revision, beforeCompetingExecute.revision + 1); + assert.equal(competingForceState.currentTime, 900, + 'authorized EXECUTE commits the latest target visible to legacy peers'); + assert.equal(competingForceState.updatedBy, 'msa'); + + // The initiator's normal ACK timeout stays below the relay's delayed-target + // warning boundary and commits normally. + s(msa, 'force_sync_prepare', { targetTime: 925 }); + await w(msb, 'force_sync_prepare'); + mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT; + s(msa, 'force_sync_execute', {}); + await w(msb, 'force_sync_execute'); + assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 925, + 'relay grace accepts EXECUTE at the client ACK-timeout boundary'); + + // Even beyond the warning boundary, a target that no newer room action + // superseded remains the only safe way to release already-paused peers. + s(msa, 'force_sync_prepare', { targetTime: 950 }); + await w(msb, 'force_sync_prepare'); + const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState }; + mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_DELAY_WARNING - 1; + msa._m.length = msb._m.length = 0; + s(msa, 'force_sync_execute', {}); + await w(msb, 'force_sync_execute'); + assert.equal(mod.rooms.get(msrid).mediaState.revision, beforeExpiredExecute.revision + 1); + assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 950, + 'a delayed EXECUTE still releases peers and commits its unsuperseded prepared target'); + assert.equal(mod.rooms.get(msrid).forceSyncTarget, null); + + // A relay restart cannot recover transient PREPARE state. Preserve the old + // wire liveness fallback without inventing a canonical target. + const beforeUntrackedExecute = { ...mod.rooms.get(msrid).mediaState }; + msa._m.length = msb._m.length = 0; + s(msa, 'force_sync_execute', {}); + await w(msb, 'force_sync_execute'); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeUntrackedExecute, + 'an untracked compatibility EXECUTE relays without canonical mutation'); + + s(msa, 'force_sync_prepare', { targetTime: 975 }); + await w(msb, 'force_sync_prepare'); + s(msb, 'seek', { targetTime: 'invalid' }); + await w(msa, 'seek'); + assert.equal(mod.rooms.get(msrid).forceSyncTarget.targetTime, 975, + 'a sanitized no-op SEEK does not supersede an in-flight prepared target'); + s(msa, 'force_sync_execute', {}); + await w(msb, 'force_sync_execute'); + assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 975); + + msa._m.length = msb._m.length = 0; + s(msa, 'force_sync_prepare', { targetTime: 1_000 }); + await w(msb, 'force_sync_prepare'); + s(msb, 'pause', { currentTime: 1_100 }); + await w(msa, 'pause'); + const supersedingMediaState = { ...mod.rooms.get(msrid).mediaState }; + msa._m.length = msb._m.length = 0; + s(msa, 'force_sync_execute', {}); + let orphanExecuteDropped = false; + try { await w(msb, 'force_sync_execute', 500); } catch { orphanExecuteDropped = true; } + assert.ok(orphanExecuteDropped, 'an action that supersedes PREPARE makes delayed EXECUTE invalid'); + assert.deepEqual(mod.rooms.get(msrid).mediaState, supersedingMediaState); + + // Active Episode Lobby is additive ROOM_DATA state and does not rewrite mediaState. + s(msa, 'episode_lobby', { expectedTitle: 'S02E03' }); + await delay(40); + const beforeLobbyJoin = { ...mod.rooms.get(msrid).mediaState }; + const msLobbyJoiner = await c(); + const lobbyRoomData = await j(msLobbyJoiner, msrid, 'ms-lobby'); + assert.equal(lobbyRoomData.activeLobby.expectedTitle, 'S02E03'); + assert.equal(lobbyRoomData.mediaState.revision, beforeLobbyJoin.revision); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, 'Episode Lobby does not mutate canonical state'); + s(msa, 'leave_room', {}); + await delay(50); + assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, + 'host disconnect/reassignment preserves canonical state and revision'); + close(); + resetConnectionRate(); + + // --- Canonical Media State v1: Host Control and validation chokepoints --- + const msgateRid = 'media-gate-'+Date.now(); + const msgHost = await c(), msgGuest = await c(), msgUnjoined = await c(); + await j(msgHost, msgateRid, 'msg-host'); + await j(msgGuest, msgateRid, 'msg-guest'); + s(msgHost, 'play', { currentTime: 10 }); + await delay(40); + s(msgHost, 'set_control_mode', { controlMode: 'host-only' }); + await w(msgGuest, 'control_mode'); + msgHost._m.length = msgGuest._m.length = 0; + const gatedBaseline = { ...mod.rooms.get(msgateRid).mediaState }; + + s(msgGuest, 'seek', { targetTime: 900 }); + await delay(80); + assert.deepEqual(mod.rooms.get(msgateRid).mediaState, gatedBaseline, + 'host-only rejected guest cannot mutate canonical state or revision'); + + s(msgHost, 'seek', { targetTime: 800 }); + await delay(40); + assert.equal(mod.rooms.get(msgateRid).mediaState.revision, gatedBaseline.revision + 1); + assert.equal(mod.rooms.get(msgateRid).mediaState.currentTime, 800); + + // Current receivers ignore duplicate/regressing seq. The relay must make the + // same decision before canonical mutation so late joiners see the same truth. + msgHost._m.length = msgGuest._m.length = 0; + s(msgHost, 'play', { currentTime: 820, seq: 10 }); + await w(msgGuest, 'play'); + const sequencedBaseline = { ...mod.rooms.get(msgateRid).mediaState }; + const sequencedPeerBaseline = { + ...Array.from(mod.rooms.get(msgateRid).peerData.values()) + .find(peer => peer.peerId === 'msg-host') + }; + s(msgHost, 'pause', { currentTime: 1, playbackState: 'paused', seq: 10 }); + let duplicateSequenceDropped = false; + try { await w(msgGuest, 'pause', 500); } catch { duplicateSequenceDropped = true; } + assert.ok(duplicateSequenceDropped, 'duplicate media seq is not relayed'); + s(msgHost, 'seek', { targetTime: 5, seq: 9 }); + let staleSequenceDropped = false; + try { await w(msgGuest, 'seek', 500); } catch { staleSequenceDropped = true; } + assert.ok(staleSequenceDropped, 'regressing media seq is not relayed'); + assert.deepEqual(mod.rooms.get(msgateRid).mediaState, sequencedBaseline, + 'regressing media seq cannot revise canonical state'); + assert.deepEqual( + Array.from(mod.rooms.get(msgateRid).peerData.values()) + .find(peer => peer.peerId === 'msg-host'), + sequencedPeerBaseline, + 'duplicate/regressing media seq cannot alter peer state used by later canonical updates'); + const msgLateJoiner = await c(); + const msgLateRoom = await j(msgLateJoiner, msgateRid, 'msg-late'); + assert.equal(msgLateRoom.mediaState.revision, sequencedBaseline.revision); + assert.equal(msgLateRoom.mediaState.playbackState, sequencedBaseline.playbackState); + assert.ok(msgLateRoom.mediaState.currentTime >= sequencedBaseline.currentTime + && msgLateRoom.mediaState.currentTime < sequencedBaseline.currentTime + 5, + 'late joiner receives the accepted playing state with normal snapshot projection'); + + const validationBaseline = { ...mod.rooms.get(msgateRid).mediaState }; + for (const invalidPayload of [{ targetTime: null }, { targetTime: '50' }, { targetTime: {} }, {}]) { + s(msgHost, 'seek', invalidPayload); + } + s(msgUnjoined, 'seek', { roomId: msgateRid, targetTime: 999, updatedBy: 'msg-host', revision: 999999 }); + await delay(80); + assert.deepEqual(mod.rooms.get(msgateRid).mediaState, validationBaseline, + 'invalid and unjoined/cross-room payloads cannot corrupt canonical state'); + + s(msgHost, 'leave_room', {}); + s(msgGuest, 'leave_room', {}); + s(msgLateJoiner, 'leave_room', {}); + await delay(80); + assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room'); // --- Terminal room timeout: coded error + complete membership cleanup --- const timeoutClient = await c(); + const timeoutPeer = await c(); const timeoutRoomId = 'timeout-'+Date.now(); await j(timeoutClient, timeoutRoomId, 'timeout-peer'); - timeoutClient._m.length = 0; + await j(timeoutPeer, timeoutRoomId, 'timeout-peer-2'); + timeoutClient._m.length = timeoutPeer._m.length = 0; mod.rooms.get(timeoutRoomId).lastActivity = 0; mod.cleanupInactiveRooms(Date.now()); - const [timeoutEvent, timeoutData] = await a(timeoutClient); - assert.equal(timeoutEvent, 'error'); + const timeoutData = await w(timeoutClient, 'error'); + const timeoutPeerData = await w(timeoutPeer, 'error'); assert.equal(timeoutData.code, 'room_closed'); assert.equal(timeoutData.message, 'Room closed'); + assert.equal(timeoutPeerData.code, 'room_closed'); + await delay(80); + assert.deepEqual(timeoutClient._m, [], 'terminal room cleanup emits nothing after room_closed'); + assert.deepEqual(timeoutPeer._m, [], 'terminal room cleanup emits nothing after room_closed'); assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted'); - timeoutClient._m.length = 0; // The same connected socket must be able to join that room again. This // proves timeout cleanup removed its stale socketToRoom membership. @@ -370,6 +876,70 @@ try { close(); resetConnectionRate(); + // A valid PREPARE remains executable if the room changes from everyone to + // host-only before EXECUTE. An invalid PREPARE grants no such exemption. + const transitionRid = 'force-transition-'+Date.now(); + const transitionHost = await c(), transitionGuest = await c(); + await j(transitionHost, transitionRid, 'transition-host'); + await j(transitionGuest, transitionRid, 'transition-guest'); + transitionHost._m.length = transitionGuest._m.length = 0; + s(transitionGuest, 'force_sync_prepare', { targetTime: 321 }); + await w(transitionHost, 'force_sync_prepare'); + s(transitionHost, 'set_control_mode', { controlMode: 'host-only' }); + await w(transitionGuest, 'control_mode'); + transitionHost._m.length = transitionGuest._m.length = 0; + s(transitionGuest, 'force_sync_execute', {}); + await w(transitionHost, 'force_sync_execute'); + assert.equal(mod.rooms.get(transitionRid).mediaState.currentTime, 321); + + await delay(550); + s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: true }); + await w(transitionGuest, 'control_mode'); + transitionHost._m.length = transitionGuest._m.length = 0; + s(transitionGuest, 'force_sync_prepare', { targetTime: 'invalid' }); + let invalidPrepareRelayed = false; + try { await w(transitionHost, 'force_sync_prepare', 500); } catch { invalidPrepareRelayed = true; } + assert.ok(invalidPrepareRelayed, 'invalid PREPARE is not relayed'); + await delay(550); + s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: false }); + await w(transitionGuest, 'control_mode'); + transitionHost._m.length = transitionGuest._m.length = 0; + s(transitionGuest, 'force_sync_execute', {}); + let invalidExecuteGated = false; + try { await w(transitionHost, 'force_sync_execute', 500); } catch { invalidExecuteGated = true; } + assert.ok(invalidExecuteGated, 'invalid PREPARE grants no post-demotion EXECUTE exemption'); + close(); + resetConnectionRate(); + + // A transient disconnect removes the co-host role but not the validated + // room target. The same PREPARE initiator may still finish that already + // visible transaction after reconnecting as a host-only guest. + const forceReconnectRid = 'force-reconnect-'+Date.now(); + const forceReconnectHost = await c(), forceReconnectPeer = await c(); + await j(forceReconnectHost, forceReconnectRid, 'force-host'); + await j(forceReconnectPeer, forceReconnectRid, 'force-peer'); + forceReconnectHost._m.length = forceReconnectPeer._m.length = 0; + s(forceReconnectHost, 'set_control_mode', { controlMode: 'host-only' }); + await w(forceReconnectHost, 'control_mode'); + await w(forceReconnectPeer, 'control_mode'); + forceReconnectHost._m.length = forceReconnectPeer._m.length = 0; + s(forceReconnectHost, 'set_peer_role', { peerId: 'force-peer', controller: true }); + await w(forceReconnectPeer, 'control_mode'); + forceReconnectHost._m.length = forceReconnectPeer._m.length = 0; + s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 }); + await w(forceReconnectHost, 'force_sync_prepare'); + forceReconnectPeer.close(); + await delay(100); + const forceReconnectReplacement = await c(); + await j(forceReconnectReplacement, forceReconnectRid, 'force-peer'); + forceReconnectHost._m.length = forceReconnectReplacement._m.length = 0; + s(forceReconnectReplacement, 'force_sync_execute', {}); + await w(forceReconnectHost, 'force_sync_execute'); + assert.equal(mod.rooms.get(forceReconnectRid).mediaState.currentTime, 444); + assert.equal(mod.rooms.get(forceReconnectRid).mediaState.playbackState, 'playing'); + 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(); @@ -450,12 +1020,12 @@ try { 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,'force_sync_execute',{}); await w(mxo,'force_sync_execute'); + s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby'); 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(); diff --git a/server/README.md b/server/README.md index d515677..d82581f 100644 --- a/server/README.md +++ b/server/README.md @@ -106,5 +106,6 @@ and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and | File | Purpose | |---|---| | `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown | +| `media-state.js` | Canonical room media clock, control updates, and projected ROOM_DATA snapshots | | `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals | | `ops.js` | Health endpoint helpers, metrics payload builder, auth validation | diff --git a/server/index.js b/server/index.js index fc5bd88..9f3ffbf 100644 --- a/server/index.js +++ b/server/index.js @@ -4,8 +4,13 @@ import { fileURLToPath } from 'url'; import { Server } from 'socket.io'; import crypto from 'crypto'; import dotenv from 'dotenv'; -import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js'; +import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME } from '../shared/constants.js'; import { createChatEnvelope } from './chat.js'; +import { + commitForceSyncMediaState, + snapshotMediaState, + updateMediaStateFromControl +} from './media-state.js'; import { buildHealthPayload, checkCooldown, @@ -171,6 +176,18 @@ const HOST_ONLY_GATED_EVENTS = new Set([ EVENTS.EPISODE_LOBBY_CANCEL ]); +// Current clients sequence room-moving media commands. The relay mirrors the +// receiver-side stale guard so a frame ignored by live peers cannot become the +// canonical snapshot shown to a later joiner. Legacy clients omit seq entirely +// and retain their pre-feature behavior. +const SEQUENCED_ROOM_EVENTS = new Set([ + EVENTS.PLAY, + EVENTS.PAUSE, + EVENTS.SEEK, + EVENTS.FORCE_SYNC_PREPARE, + EVENTS.FORCE_SYNC_EXECUTE +]); + // Features this relay supports, advertised to clients in ROOM_DATA so they can // enable matching UI/behavior only when the server actually backs it. Append a // flag here when a new server-gated feature ships (e.g. co-host promotion). @@ -178,7 +195,8 @@ const SERVER_CAPABILITIES = [ CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT, - CAPABILITIES.CHAT_V1 + CAPABILITIES.CHAT_V1, + CAPABILITIES.MEDIA_STATE_V1 ]; function normalizeClientCapabilities(value) { @@ -186,7 +204,8 @@ function normalizeClientCapabilities(value) { return [...new Set(value.slice(0, 16) .filter(capability => typeof capability === 'string') .map(capability => capability.substring(0, 32)) - .filter(capability => capability === CAPABILITIES.CHAT_V1) + .filter(capability => capability === CAPABILITIES.CHAT_V1 + || capability === CAPABILITIES.MEDIA_STATE_V1) )]; } @@ -195,6 +214,11 @@ function clientSupportsChat(socket) { socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1); } +function clientSupportsMediaState(socket) { + return Array.isArray(socket?.data?.clientCapabilities) + && socket.data.clientCapabilities.includes(CAPABILITIES.MEDIA_STATE_V1); +} + // M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly // toggling host from thrashing every guest's UI (locked/unlocked/locked...) and // from generating one broadcast per toggle across all peers. @@ -226,8 +250,10 @@ function log(type, message, details = '') { * @param {string} socketId - The socket.id being removed. * @param {string} roomId - The room it belongs to. * @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch'). + * @param {object} options + * @param {boolean} options.notifyRemainingPeers - Whether to emit room-state updates after removal. */ -function removePeerFromRoom(socketId, roomId, reason) { +function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = true } = {}) { const room = rooms.get(roomId); if (!room) return; @@ -251,14 +277,14 @@ function removePeerFromRoom(socketId, roomId, reason) { // 3. Notify remaining peers (use io.to so the removed socket itself // doesn't receive it — it has already left or is disconnecting) const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId); - if (!isPeerStillConnected) { + if (notifyRemainingPeers && !isPeerStillConnected) { io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); } // 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 } } @@ -277,11 +303,22 @@ function removePeerFromRoom(socketId, roomId, reason) { // limitation (no host grace period); see KNOWN_LIMITATIONS.md. const peerRejoining = peerJoinLocks.has(peerId); const peerGone = !isPeerStillConnected && !peerRejoining; + if (room.peers.size === 1 && !peerRejoining) { + const remainingSocketId = room.peers.values().next().value; + const remainingSocket = io.sockets.sockets.get(remainingSocketId); + if (!clientSupportsMediaState(remainingSocket)) { + // Pre-feature extensions suppress PLAY/PAUSE/SEEK while solo. Their + // last canonical snapshot can therefore become stale before the next + // join; absence is safer than applying known-unreliable room truth. + room.mediaState = null; + } + } if (peerGone && room.controllers && room.peers.size > 0) { const wasController = room.controllers.has(peerId); room.controllers.delete(peerId); - // H-1: a leaving initiator strands the room's force-sync — release the - // slot so a future controller's PREPARE can take over cleanly. + // Release the post-demotion exemption, but retain the validated target: + // an authorized initiator may reconnect and finish the already-visible + // choreography. A newer PREPARE still replaces it normally. if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null; if (room.hostPeerId === peerId) { // Owner left → reassign owner + fall back to 'everyone' so the room is @@ -290,11 +327,11 @@ function removePeerFromRoom(socketId, roomId, reason) { room.hostPeerId = nextPeerData ? nextPeerData.peerId : null; room.controlMode = CONTROL_MODES.EVERYONE; room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []); - io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`); } else if (wasController) { // A co-host left → keep the mode, just broadcast the updated controller list. - io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`); } } @@ -378,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 @@ -413,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) { @@ -445,7 +484,16 @@ io.on('connection', (socket) => { // controller's FORCE_SYNC_EXECUTE through the host-only gate — without // it, demoting a co-host mid-force-sync would drop their EXECUTE and // leave every peer stuck paused. - forceSyncInitiator: null + forceSyncInitiator: null, + // Canonical Media State v1 is lazy, room-local and absent until + // the first accepted command establishes a trustworthy position. + mediaState: null, + // PREPARE is choreography, not stable room intent. Retain its + // validated target only so the matching EXECUTE can commit it. + forceSyncTarget: null, + // Distinguishes an unknown target after relay restart from a + // transaction explicitly replaced by newer room playback. + forceSyncSuperseded: false }; rooms.set(roomId, room); createdByMe = true; @@ -465,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" }); @@ -475,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) { @@ -528,6 +578,7 @@ io.on('connection', (socket) => { peerToSocket.set(peerId, socket.id); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' }); + const snapshotAt = Date.now(); socket.emit(EVENTS.ROOM_DATA, { roomId, peers: Array.from(room.peers).map(sid => room.peerData.get(sid)), @@ -535,6 +586,7 @@ io.on('connection', (socket) => { hostPeerId: room.hostPeerId || null, controlMode: room.controlMode || CONTROL_MODES.EVERYONE, controllers: room.controllers ? Array.from(room.controllers) : [], + mediaState: snapshotMediaState(room.mediaState, snapshotAt), capabilities: SERVER_CAPABILITIES }); log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); @@ -581,17 +633,15 @@ io.on('connection', (socket) => { // a controller (the owner + any promoted co-hosts). Robust chokepoint: // independent of client behavior, kills spam. Heartbeats/ACKs pass. // - // H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to - // land — otherwise their already-relayed PREPARE would leave the whole - // room stuck paused. Track the in-flight initiator on PREPARE and let - // their matching EXECUTE through regardless of current controllers set. - if (eventName === EVENTS.FORCE_SYNC_PREPARE && - room.controlMode === CONTROL_MODES.HOST_ONLY && - room.controllers && room.controllers.has(mapping.peerId)) { - room.forceSyncInitiator = mapping.peerId; - } - const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE && - room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator; + // H-1 exception: the latest valid PREPARE initiator's + // FORCE_SYNC_EXECUTE still has to land after demotion — + // otherwise the already-relayed room-wide choreography + // would leave peers paused. + const forceSyncInitiator = room.forceSyncTarget?.initiatorPeerId + || room.forceSyncInitiator; + const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE + && forceSyncInitiator + && mapping.peerId === forceSyncInitiator; if (!isOwnForceSyncExecute && room.controlMode === CONTROL_MODES.HOST_ONLY && !(room.controllers && room.controllers.has(mapping.peerId)) && @@ -599,17 +649,35 @@ io.on('connection', (socket) => { log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`); return; } - // Clear initiator tracking once the EXECUTE has been relayed. - if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) { - room.forceSyncInitiator = null; - } - // --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) --- const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined; const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined; const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined; const validBool = (val) => typeof val === 'boolean' ? val : undefined; + const hasSequenceField = data.seq !== undefined; + const sequence = Number.isSafeInteger(data.seq) && data.seq >= 0 + ? data.seq + : undefined; + if (SEQUENCED_ROOM_EVENTS.has(eventName)) { + if (hasSequenceField && sequence === undefined) { + log('ROOM', `Dropped ${eventName} with invalid seq from ${mapping.peerId}`); + return; + } + if (sequence !== undefined) { + if (socket.data.mediaSequencePeerId !== mapping.peerId) { + socket.data.mediaSequencePeerId = mapping.peerId; + socket.data.lastMediaSequence = null; + } + if (Number.isSafeInteger(socket.data.lastMediaSequence) + && sequence <= socket.data.lastMediaSequence) { + log('ROOM', `Dropped stale ${eventName} from ${mapping.peerId} (seq ${sequence} <= ${socket.data.lastMediaSequence})`); + return; + } + socket.data.lastMediaSequence = sequence; + } + } + const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId }; room.peerData.set(socket.id, { ...existing, @@ -617,7 +685,7 @@ io.on('connection', (socket) => { tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle), mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle), playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState, - currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime), + currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, MAX_MEDIA_TIME) ?? existing.currentTime) : existing.currentTime), volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume, muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted, desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false), @@ -627,9 +695,9 @@ io.on('connection', (socket) => { // --- S-3: Construct clean relay payload — never forward raw client data --- const relayPayload = { senderId: mapping.peerId, - seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER), - currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400), - targetTime: clampNum(data.targetTime, 0, 86400), + seq: sequence, + currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, MAX_MEDIA_TIME), + targetTime: clampNum(data.targetTime, 0, MAX_MEDIA_TIME), playbackState: validState(data.playbackState), username: clamp(data.username, 30), tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100), @@ -645,10 +713,120 @@ 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, + // room mapping, Host Control authorization and sanitization. + // Heartbeats remain observational and never enter this path. + const canonicalStateUpdated = updateMediaStateFromControl( + room, + eventName, + relayPayload, + mapping.peerId, + { + now: mediaStateNow, + senderPlaybackState: existing.playbackState, + senderMediaTitle: room.peerData.get(socket.id)?.mediaTitle + } + ); + const validLobbyTransition = (eventName === EVENTS.EPISODE_LOBBY + && typeof relayPayload.expectedTitle === 'string' + && relayPayload.expectedTitle.length > 0) + || eventName === EVENTS.EPISODE_LOBBY_CANCEL; + if (canonicalStateUpdated || validLobbyTransition) { + // A later room-driving action supersedes unfinished Force + // Sync choreography. Do not let a delayed EXECUTE commit + // an obsolete target after peers have moved elsewhere. + room.forceSyncSuperseded = true; + room.forceSyncInitiator = null; + room.forceSyncTarget = null; + } + if (eventName === EVENTS.FORCE_SYNC_PREPARE) { + // A malformed PREPARE must neither pause peers nor grant + // the initiator a later Host Control EXECUTE exemption. + if (!Number.isFinite(relayPayload.targetTime)) { + log('ROOM', `Dropped invalid force_sync_prepare from ${mapping.peerId}`); + return; + } + // One room-wide choreography is visible on the legacy + // wire. A newer PREPARE replaces the target every peer + // most recently received. Track its initiator in every + // control mode so an everyone -> host-only transition + // cannot strand that already-authorized transaction. + room.forceSyncInitiator = mapping.peerId; + room.forceSyncSuperseded = false; + room.forceSyncTarget = { + initiatorPeerId: mapping.peerId, + targetTime: relayPayload.targetTime, + preparedAt: mediaStateNow, + mediaTitle: room.peerData.get(socket.id)?.mediaTitle || null + }; + } else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { + const forceSyncTarget = room.forceSyncTarget; + if (!forceSyncTarget && room.forceSyncSuperseded) { + log('ROOM', `Dropped obsolete force_sync_execute after newer room playback from ${mapping.peerId}`); + room.forceSyncInitiator = null; + return; + } + if (forceSyncTarget) { + const targetDelayed = !Number.isFinite(forceSyncTarget.preparedAt) + || mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_DELAY_WARNING; + if (targetDelayed) { + log('ROOM', `Relaying delayed force_sync_execute from ${mapping.peerId} to release prepared peers`); + } + commitForceSyncMediaState( + room, + forceSyncTarget.targetTime, + mapping.peerId, + mediaStateNow, + forceSyncTarget.mediaTitle + ); + } else { + // A relay restart loses transient PREPARE state while legacy + // receivers can remain paused in their existing pages. Preserve + // the old wire behavior, but do not invent a canonical target. + log('ROOM', `Relaying force_sync_execute without server target from ${mapping.peerId}`); + } + room.forceSyncInitiator = null; + room.forceSyncTarget = null; + room.forceSyncSuperseded = false; + } + 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, @@ -969,7 +1147,7 @@ export function cleanupInactiveRooms(now = Date.now()) { for (const sid of Array.from(currentRoom.peers)) { const memberSocket = io.sockets?.sockets?.get(sid); if (memberSocket) memberSocket.leave(roomId); - removePeerFromRoom(sid, roomId, 'room-timeout'); + removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false }); } rooms.delete(roomId); log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`); @@ -1032,7 +1210,8 @@ export async function stopServerForTests() { peerJoinLocks.clear(); clearRateLimitMaps(); healthResponseCache.clear(); - io.removeAllListeners(); + // Keep the server's connection handler installed so an E2E process can + // stop and restart this singleton relay for a later isolated scenario. io.disconnectSockets(true); Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 }); if (!httpServer.listening) return; diff --git a/server/media-state.js b/server/media-state.js new file mode 100644 index 0000000..60a621b --- /dev/null +++ b/server/media-state.js @@ -0,0 +1,101 @@ +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; + +function clampMediaTime(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(MAX_MEDIA_TIME, value)); +} + +function normalizeMediaTitle(value) { + if (typeof value !== 'string' || !value) return null; + return value.substring(0, 100); +} + +export function effectiveMediaPosition(mediaState, now = Date.now()) { + if (!mediaState) return null; + const currentTime = clampMediaTime(mediaState.currentTime); + if (currentTime === null) return null; + if (mediaState.playbackState !== 'playing') return currentTime; + const updatedAt = typeof mediaState.updatedAt === 'number' && Number.isFinite(mediaState.updatedAt) + ? mediaState.updatedAt + : now; + return clampMediaTime(currentTime + Math.max(0, now - updatedAt) / 1000); +} + +export function snapshotMediaState(mediaState, now = Date.now()) { + if (!mediaState) return null; + const currentTime = effectiveMediaPosition(mediaState, now); + if (currentTime === null + || !Number.isSafeInteger(mediaState.revision) + || mediaState.revision < 1 + || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) { + return null; + } + const snapshot = { + revision: mediaState.revision, + playbackState: mediaState.playbackState, + currentTime, + updatedBy: mediaState.updatedBy + }; + const mediaTitle = normalizeMediaTitle(mediaState.mediaTitle); + if (mediaTitle) snapshot.mediaTitle = mediaTitle; + return snapshot; +} + +function commitMediaState(room, playbackState, currentTime, updatedBy, now, mediaTitle = null) { + const normalizedTime = clampMediaTime(currentTime); + if (normalizedTime === null + || (playbackState !== 'playing' && playbackState !== 'paused') + || typeof updatedBy !== 'string' + || !updatedBy) { + return false; + } + const nextState = { + revision: (room.mediaState?.revision || 0) + 1, + playbackState, + currentTime: normalizedTime, + updatedAt: now, + updatedBy + }; + const normalizedTitle = normalizeMediaTitle(mediaTitle); + if (normalizedTitle) nextState.mediaTitle = normalizedTitle; + room.mediaState = nextState; + return true; +} + +export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, { + now = Date.now(), + senderPlaybackState = null, + senderMediaTitle = null +} = {}) { + if (!room || !payload || typeof payload !== 'object') return false; + const mediaTitle = payload.mediaTitle === null + ? null + : (normalizeMediaTitle(payload.mediaTitle) || normalizeMediaTitle(senderMediaTitle)); + + if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) { + const eventPosition = clampMediaTime(payload.currentTime); + const currentTime = eventPosition ?? effectiveMediaPosition(room.mediaState, now); + if (currentTime === null) return false; + return commitMediaState( + room, + eventName === EVENTS.PLAY ? 'playing' : 'paused', + currentTime, + senderPeerId, + now, + mediaTitle + ); + } + + if (eventName === EVENTS.SEEK) { + const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime); + const playbackState = room.mediaState?.playbackState || senderPlaybackState; + if (targetTime === null) return false; + return commitMediaState(room, playbackState, targetTime, senderPeerId, now, mediaTitle); + } + + return false; +} + +export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now(), mediaTitle = null) { + return commitMediaState(room, 'playing', targetTime, senderPeerId, now, mediaTitle); +} diff --git a/server/media-state.test.mjs b/server/media-state.test.mjs new file mode 100644 index 0000000..bfb5b58 --- /dev/null +++ b/server/media-state.test.mjs @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js'; +import { + commitForceSyncMediaState, + effectiveMediaPosition, + snapshotMediaState, + updateMediaStateFromControl +} from './media-state.js'; + +function room(mediaState = null) { + return { mediaState }; +} + +describe('canonical media state', () => { + it('starts null and snapshots null', () => { + expect(snapshotMediaState(null, 1000)).toBeNull(); + }); + + it('projects playing state and clamps elapsed time', () => { + const state = { revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }; + expect(effectiveMediaPosition(state, 3500)).toBe(12.5); + expect(effectiveMediaPosition(state, 500)).toBe(10); + expect(effectiveMediaPosition({ ...state, currentTime: MAX_MEDIA_TIME }, 3500)).toBe(MAX_MEDIA_TIME); + }); + + it('keeps paused state fixed', () => { + const state = { revision: 1, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }; + expect(effectiveMediaPosition(state, 601000)).toBe(10); + expect(snapshotMediaState(state, 601000)).toEqual({ + revision: 1, + playbackState: 'paused', + currentTime: 10, + updatedBy: 'a' + }); + }); + + it('initializes and updates PLAY with server-owned revisions', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 12, revision: 999 }, 'a', { now: 1000 })).toBe(true); + expect(target.mediaState).toEqual({ revision: 1, playbackState: 'playing', currentTime: 12, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 20 }, 'b', { now: 2000 })).toBe(true); + expect(target.mediaState.revision).toBe(2); + expect(target.mediaState.updatedBy).toBe('b'); + }); + + it('does not invent an initial PLAY position and preserves a known effective position', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 1000 })).toBe(false); + expect(target.mediaState).toBeNull(); + target.mediaState = { revision: 1, playbackState: 'playing', currentTime: 5, updatedAt: 1000, updatedBy: 'a' }; + expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 3000 })).toBe(true); + expect(target.mediaState.currentTime).toBe(7); + }); + + it('freezes PAUSE at its event or effective canonical position', () => { + const target = room({ revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.PAUSE, {}, 'a', { now: 4000 })).toBe(true); + expect(target.mediaState).toEqual({ revision: 2, playbackState: 'paused', currentTime: 13, updatedAt: 4000, updatedBy: 'a' }); + expect(effectiveMediaPosition(target.mediaState, 9000)).toBe(13); + }); + + it('uses SEEK targetTime, preserves playback state, and lets the second controller win', () => { + const target = room({ revision: 3, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { currentTime: 50, targetTime: 100 }, 'a', { now: 2000 })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'playing', currentTime: 100, updatedBy: 'a' }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 200 }, 'b', { now: 2001 })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' }); + }); + + it('tracks only the current sender shared media title and honors an explicit privacy null', () => { + const target = room(); + expect(updateMediaStateFromControl( + target, + EVENTS.PLAY, + { currentTime: 10, mediaTitle: 'Series S01E01' }, + 'a', + { now: 1000 } + )).toBe(true); + expect(snapshotMediaState(target.mediaState, 1000).mediaTitle).toBe('Series S01E01'); + + expect(updateMediaStateFromControl( + target, + EVENTS.SEEK, + { targetTime: 20 }, + 'b', + { now: 2000, senderMediaTitle: 'Series S01E02' } + )).toBe(true); + expect(target.mediaState.mediaTitle).toBe('Series S01E02'); + + expect(updateMediaStateFromControl( + target, + EVENTS.PAUSE, + { currentTime: 20, mediaTitle: null }, + 'b', + { now: 3000, senderMediaTitle: 'stale S01E01' } + )).toBe(true); + expect(target.mediaState).not.toHaveProperty('mediaTitle'); + }); + + it('ignores client-supplied playback state while seeking', () => { + const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); + expect(updateMediaStateFromControl( + target, + EVENTS.SEEK, + { targetTime: 100, playbackState: 'playing' }, + 'b', + { now: 2000, senderPlaybackState: 'playing' } + )).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'paused', currentTime: 100 }); + }); + + it('uses an observed sender state only to establish an otherwise ambiguous first SEEK', () => { + const target = room(); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000 })).toBe(false); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000, senderPlaybackState: 'paused' })).toBe(true); + expect(target.mediaState).toMatchObject({ revision: 1, playbackState: 'paused', currentTime: 50 }); + }); + + it('rejects non-finite/missing controls without corruption and clamps existing protocol bounds', () => { + const original = { revision: 2, playbackState: 'paused', currentTime: 30, updatedAt: 1000, updatedBy: 'a' }; + for (const payload of [{ targetTime: NaN }, { targetTime: Infinity }, { targetTime: '50' }, {}]) { + const target = room({ ...original }); + expect(updateMediaStateFromControl(target, EVENTS.SEEK, payload, 'b', { now: 2000 })).toBe(false); + expect(target.mediaState).toEqual(original); + } + const low = room({ ...original }); + updateMediaStateFromControl(low, EVENTS.SEEK, { targetTime: -5 }, 'b', { now: 2000 }); + expect(low.mediaState.currentTime).toBe(0); + const high = room({ ...original }); + updateMediaStateFromControl(high, EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 5 }, 'b', { now: 2000 }); + expect(high.mediaState.currentTime).toBe(MAX_MEDIA_TIME); + }); + + it('commits Force Sync only at execute time', () => { + const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' }); + expect(commitForceSyncMediaState(target, 500, 'b', 2000, 'Series S02E03')).toBe(true); + expect(target.mediaState).toEqual({ + revision: 5, + playbackState: 'playing', + currentTime: 500, + updatedAt: 2000, + updatedBy: 'b', + mediaTitle: 'Series S02E03' + }); + }); +}); diff --git a/shared/README.md b/shared/README.md index d90bd79..79ff46f 100644 --- a/shared/README.md +++ b/shared/README.md @@ -31,6 +31,7 @@ Browser extensions cannot import files outside their own root directory, so the - `CONTROL_MODES.HOST_ONLY`: only the host and promoted controllers may move playback. - `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority. - `CAPABILITIES.CO_HOST`: relay supports promoted controller peers. +- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots. Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`. @@ -63,7 +64,9 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta - `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds. - `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs. +- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness. - `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness. +- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions. ## Do Not Break diff --git a/shared/constants.js b/shared/constants.js index ee43007..56d6e46 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -90,9 +90,16 @@ export const CAPABILITIES = { HOST_CONTROL: 'host-control', CO_HOST: 'co-host', // owner promotes guests to additional controllers CHAT: 'chat', // legacy server capability used by the first chat beta - CHAT_V1: 'chat-v1' // versioned client/server chat wire contract + CHAT_V1: 'chat-v1', // versioned client/server chat wire contract + MEDIA_STATE_V1: 'media-state-v1' // server-authoritative room playback recovery snapshot }; +// Relay and extension media-time validation must use the same upper bound. +export const MAX_MEDIA_TIME = 86400; + export const HEARTBEAT_INTERVAL = 15000; // 15s export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s) +// Log unexpectedly delayed EXECUTE delivery after the normal ACK wait plus +// transport grace. The target remains valid until newer room playback replaces it. +export const FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000; export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 09987b6..b7fb423 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -21,6 +21,7 @@ npm run test:e2e:race # @race scenarios, repeated 20 times | `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek | | `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery | | `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup | +| `global-setup.mjs` | Owns the fixture server for the complete Playwright run and closes it during teardown | | `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media | | `fixtures/pages/` | One page per scenario | | `fixtures/media/` | Small generated clips (see below) | @@ -32,6 +33,11 @@ Chrome MV3 APIs and a persistent service-worker context. The scheduled `.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads traces/results on failure. +Locally, global setup reuses an already running fixture server on the configured +port. CI always owns a fresh server so a port collision fails instead of testing +against an unknown process. Keeping the server inside Playwright's lifecycle +also prevents an orphaned `node` process from blocking teardown on Windows. + ## Two rules worth keeping **The specs run the shipped source, not a copy.** `helpers/content-source.mjs` diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 94732b6..f6be081 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -1,4 +1,12 @@ import { test, expect } from './helpers/extension-fixture.mjs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../../shared/constants.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(path.join(testDir, '..', '..', 'server', 'package.json')); +const NodeWebSocket = require('ws'); /** * Drives the packed extension itself: real background service worker, real @@ -10,7 +18,9 @@ import { test, expect } from './helpers/extension-fixture.mjs'; /** Runs code in an extension page, where the privileged chrome.* APIs exist. */ async function withExtensionPage(context, extensionId, fn) { const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/popup.html`); + // popup.html performs normal connection/settings initialization. Using it as + // a test transport can race and overwrite a just-seeded custom relay URL. + await page.goto(`chrome-extension://${extensionId}/audio-options.html`); const result = await fn(page); await page.close(); return result; @@ -54,6 +64,109 @@ async function getExtensionState(context, extensionId, message) { )); } +async function expectConnectedRoom(context, extensionId, roomId, expected = {}) { + try { + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ status: 'connected', roomId, ...expected }); + } catch (error) { + const [status, logs] = await Promise.all([ + getExtensionState(context, extensionId, { type: 'GET_STATUS' }).catch(() => null), + getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => []) + ]); + console.error(`Extension connection diagnostics: ${JSON.stringify({ status, logs: logs?.slice?.(0, 20) || logs })}`); + throw error; + } +} + +async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) { + return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, mediaState }) => { + return chrome.tabs.sendMessage(tabId, { + type: 'APPLY_CANONICAL_MEDIA_STATE', + mediaState + }); + }, { tabId, mediaState })); +} + +async function sendContentServerCommand(context, extensionId, tabId, action, payload = {}) { + return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => { + return chrome.tabs.sendMessage(tabId, { + type: 'SERVER_COMMAND', + action, + payload, + actionTimestamp: Date.now(), + commandSenderId: 'e2e-newer-command' + }); + }, { tabId, action, payload })); +} + +async function connectLegacyRelayClient(port) { + const socket = new NodeWebSocket( + `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=3.1.3&token=${OFFICIAL_SERVER_TOKEN}` + ); + socket.messages = []; + socket.on('message', value => socket.messages.push(value.toString())); + await new Promise((resolve, reject) => { + let timeout; + const onError = error => { + clearTimeout(timeout); + socket.off('open', onOpen); + reject(new Error(`legacy relay connection failed: ${error.message}`)); + }; + const onOpen = () => { + clearTimeout(timeout); + socket.off('error', onError); + resolve(); + }; + timeout = setTimeout(() => { + socket.off('open', onOpen); + socket.off('error', onError); + reject(new Error('legacy relay connection timed out')); + }, 5000); + socket.once('error', onError); + socket.once('open', onOpen); + }); + socket.send('40'); + await expect.poll(() => socket.messages.filter(message => message.startsWith('0') || message.startsWith('40')).length).toBeGreaterThanOrEqual(2); + socket.messages.length = 0; + return socket; +} + +function sendLegacyRelayEvent(socket, event, data = {}) { + socket.send(`42${JSON.stringify([event, data])}`); +} + +async function waitForLegacyRelayEvent(socket, event, timeoutMs = 10_000) { + await expect.poll(() => socket.messages.some(message => { + if (!message.startsWith('42')) return false; + try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; } + }), { timeout: timeoutMs }).toBe(true); + const index = socket.messages.findIndex(message => { + if (!message.startsWith('42')) return false; + try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; } + }); + return JSON.parse(socket.messages.splice(index, 1)[0].substring(2))[1]; +} + +async function joinLegacyRelayRoom(socket, roomId, peerId) { + sendLegacyRelayEvent(socket, 'join_room', { roomId, peerId, protocolVersion: PROTOCOL_VERSION }); + return waitForLegacyRelayEvent(socket, 'room_data'); +} + +async function terminateExtensionServiceWorker(context, extensionId, page) { + const session = await context.newCDPSession(page); + try { + const { targetInfos } = await session.send('Target.getTargets'); + const target = targetInfos.find(candidate => + candidate.type === 'service_worker' + && candidate.url.startsWith(`chrome-extension://${extensionId}/`) + ); + if (!target) throw new Error('extension service worker target not found'); + await session.send('Target.closeTarget', { targetId: target.targetId }); + } finally { + await session.detach(); + } +} + async function setAudioSettings(context, extensionId, settings) { return withExtensionPage(context, extensionId, page => page.evaluate( value => chrome.storage.local.set({ audioSettings: value }), @@ -263,6 +376,462 @@ test('applies remote play, pause and seek to the framed player', async ({ contex ).toBeGreaterThan(5); }); +test('applies canonical recovery without echoing media commands or activity', 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'); + const historyBefore = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' }); + + const playingApply = await applyCanonicalMediaState(context, extensionId, tabId, { + revision: 10, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }); + expect(playingApply).toMatchObject({ status: 'applied', revision: 10 }); + await expect.poll(() => page.locator('#player').evaluate(video => ({ + paused: video.paused, + currentTime: video.currentTime + }))).toMatchObject({ paused: false }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(5); + + const pausedApply = await applyCanonicalMediaState(context, extensionId, tabId, { + revision: 11, + playbackState: 'paused', + currentTime: 10, + updatedBy: 'peer-a' + }); + expect(pausedApply).toMatchObject({ status: 'applied', revision: 11 }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); + + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => { + await chrome.scripting.executeScript({ + target: { tabId: selectedTabId }, + world: 'ISOLATED', + func: () => { + navigator.mediaSession.metadata = new globalThis.MediaMetadata({ title: 'Series S01E02' }); + } + }); + }, tabId)); + const mismatchedApply = await applyCanonicalMediaState(context, extensionId, tabId, { + revision: 12, + playbackState: 'playing', + currentTime: 2, + updatedBy: 'peer-a', + mediaTitle: 'Series S01E01' + }); + expect(mismatchedApply).toMatchObject({ status: 'ignored_episode_mismatch' }); + await expect.poll(() => page.locator('#player').evaluate(video => ({ + paused: video.paused, + currentTime: video.currentTime + }))).toMatchObject({ paused: true }); + expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); + + // Wait past seek debounce and play/pause coalescing windows. A leaked native + // echo would have reached background.js and appeared as user activity by now. + await page.waitForTimeout(700); + const historyAfter = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' }); + expect(historyAfter).toEqual(historyBefore); +}); + +test('local media input cancels an in-flight canonical recovery', 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('canonical local-supersession 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 attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; + video.dataset.koalaDelayedPlayAttempts = String(attempts); + return nativePlay().then(() => new Promise((resolve, reject) => setTimeout(() => { + if (video.paused) { + const error = new Error('play interrupted by local pause'); + error.name = 'AbortError'; + reject(error); + } else { + resolve(); + } + }, 400))); + } + }); + } + }); + }, tabId)); + + let applyResponse = null; + const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, { + revision: 20, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }).then(response => { + applyResponse = response; + return response; + }); + await expect.poll(async () => ({ + attempts: await page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0')), + response: applyResponse + })).toMatchObject({ attempts: 1, response: null }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false); + + await page.locator('#player').evaluate(video => { + video.pause(); + video.currentTime = 10; + }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)) + .toBeGreaterThan(9); + + await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' }); + await page.waitForTimeout(700); + expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true); + expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); +}); + +test('newer server command clears local recovery state before a delayed apply resolves', 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('canonical server-supersession 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 attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; + video.dataset.koalaDelayedPlayAttempts = String(attempts); + return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400))); + } + }); + } + }); + }, tabId)); + + let applyResponse = null; + const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, { + revision: 21, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }).then(response => { + applyResponse = response; + return response; + }); + await expect.poll(async () => ({ + attempts: await page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0')), + response: applyResponse + })).toMatchObject({ attempts: 1, response: null }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false); + + await page.locator('#player').evaluate(video => { + video.pause(); + video.currentTime = 10; + }); + await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true); + await sendContentServerCommand(context, extensionId, tabId, 'play'); + + await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' }); + await page.waitForTimeout(700); + expect(await page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2); + 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'); + let legacy = null; + try { + await relay.startServer(0, '127.0.0.1'); + const port = relay.httpServer.address().port; + const roomId = `e2e-canonical-room-data-${Date.now()}`; + legacy = await connectLegacyRelayClient(port); + await joinLegacyRelayRoom(legacy, roomId, 'canonical-source'); + sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 }); + + 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 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('canonical recovery fixture video missing'); + const nativePlay = video.play.bind(video); + video.dataset.koalaCanonicalPlayAttempts = '0'; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1; + video.dataset.koalaCanonicalPlayAttempts = String(attempts); + if (attempts === 1) { + return Promise.reject(Object.assign( + new Error('audit autoplay rejection'), + { name: 'NotAllowedError' } + )); + } + return nativePlay(); + } + }); + } + }); + }, tabId)); + legacy.messages.length = 0; + + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => { + await chrome.storage.local.set(settings); + return chrome.runtime.sendMessage({ type: 'CONNECT' }); + }, { + serverUrl: `ws://127.0.0.1:${port}`, + useCustomServer: true, + roomId, + password: '', + username: 'canonical-receiver' + })); + + await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 }); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaCanonicalPlayAttempts || '0'))) + .toBeGreaterThanOrEqual(2); + await expect.poll(() => page.locator('#player').evaluate(video => ({ + paused: video.paused, + currentTime: video.currentTime + }))).toMatchObject({ paused: false }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)) + .toBeGreaterThan(5); + + await page.waitForTimeout(700); + const recoveryEchoes = legacy.messages.filter(message => { + if (!message.startsWith('42')) return false; + try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; } + }); + expect(recoveryEchoes).toEqual([]); + } finally { + try { legacy?.close(); } catch { /* already closed */ } + await relay.stopServerForTests(); + } +}); + +test('newer mixed-version playback supersedes an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => { + test.setTimeout(45_000); + const relay = await import('../../server/index.js'); + let legacy = null; + try { + await relay.startServer(0, '127.0.0.1'); + const port = relay.httpServer.address().port; + const roomId = `e2e-canonical-supersede-${Date.now()}`; + legacy = await connectLegacyRelayClient(port); + await joinLegacyRelayRoom(legacy, roomId, 'legacy-newer-control'); + sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 }); + + 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 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('canonical supersession fixture video missing'); + video.dataset.koalaCanonicalPlayAttempts = '0'; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1; + video.dataset.koalaCanonicalPlayAttempts = String(attempts); + return new Promise((_, reject) => { + setTimeout(() => reject(new Error('delayed audit autoplay rejection')), 400); + }); + } + }); + } + }); + }, tabId)); + + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => { + await chrome.storage.local.set(settings); + return chrome.runtime.sendMessage({ type: 'CONNECT' }); + }, { + serverUrl: `ws://127.0.0.1:${port}`, + useCustomServer: true, + roomId, + password: '', + username: 'current-superseded' + })); + await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 }); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaCanonicalPlayAttempts || '0'))) + .toBe(1); + + sendLegacyRelayEvent(legacy, 'seek', { currentTime: 10, targetTime: 10, seq: 2, actionTimestamp: 2 }); + sendLegacyRelayEvent(legacy, 'pause', { currentTime: 10, seq: 3, actionTimestamp: 3 }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 3, playbackState: 'paused', currentTime: 10 }); + await expect.poll(() => page.locator('#player').evaluate(video => ({ + paused: video.paused, + currentTime: video.currentTime + }))).toMatchObject({ paused: true }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)) + .toBeGreaterThan(9); + + await page.waitForTimeout(1_200); + expect(await page.locator('#player').evaluate(video => + Number(video.dataset.koalaCanonicalPlayAttempts || '0'))).toBe(1); + expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true); + } finally { + try { legacy?.close(); } catch { /* already closed */ } + await relay.stopServerForTests(); + } +}); + +test('keeps canonical media state current while the capable extension is solo', async ({ context, extensionId, baseURL }) => { + test.setTimeout(35_000); + const relay = await import('../../server/index.js'); + try { + await relay.startServer(0, '127.0.0.1'); + const port = relay.httpServer.address().port; + const roomId = `e2e-canonical-solo-${Date.now()}`; + 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 withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => { + await chrome.storage.local.set(settings); + return chrome.runtime.sendMessage({ type: 'CONNECT' }); + }, { + serverUrl: `ws://127.0.0.1:${port}`, + useCustomServer: true, + roomId, + password: '', + username: 'current-solo' + })); + await expectConnectedRoom(context, extensionId, roomId); + + expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 8 })) + .toMatchObject({ status: 'ok' }); + expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 9 })) + .toMatchObject({ status: 'ok' }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 2, playbackState: 'paused', currentTime: 9 }); + } finally { + await relay.stopServerForTests(); + } +}); + test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { const first = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); @@ -998,6 +1567,132 @@ test('keeps controlling a Drive-style player across an ordinary play and pause', await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false); }); +test('coalesces persisted offline media intent before canonical reconnect recovery', async ({ context, extensionId, baseURL }) => { + test.setTimeout(60_000); + const relay = await import('../../server/index.js'); + let legacy = null; + let canonicalKeeper = null; + let lateJoiner = null; + try { + await relay.startServer(0, '127.0.0.1'); + const port = relay.httpServer.address().port; + const roomId = `e2e-coalesced-${Date.now()}`; + legacy = await connectLegacyRelayClient(port); + await joinLegacyRelayRoom(legacy, roomId, 'legacy-e2e'); + canonicalKeeper = await connectLegacyRelayClient(port); + await joinLegacyRelayRoom(canonicalKeeper, roomId, 'legacy-keeper'); + + 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 withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => { + await chrome.storage.local.set(settings); + return chrome.runtime.sendMessage({ type: 'CONNECT' }); + }, { + serverUrl: `ws://127.0.0.1:${port}`, + useCustomServer: true, + roomId, + password: '', + username: 'current-e2e' + })); + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 }); + + // Establish accepted server truth that would be stale for this client + // after its later offline actions. + sendLegacyRelayEvent(legacy, 'play', { currentTime: 1, seq: 1, actionTimestamp: 1 }); + sendLegacyRelayEvent(legacy, 'seek', { currentTime: 1, targetTime: 1, seq: 2, actionTimestamp: 2 }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 1, updatedBy: 'legacy-e2e' }); + await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(0.8); + legacy.messages.length = 0; + + const connectedStatus = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + const extensionSocketId = Array.from(relay.rooms.get(roomId).peerData.entries()) + .find(([, data]) => data.peerId === connectedStatus.peerId)?.[0]; + expect(extensionSocketId).toBeTruthy(); + // Point future reconnect attempts at an unused local port, then sever + // only the extension socket. The relay and legacy peer stay live. + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate( + serverUrl => chrome.storage.local.set({ serverUrl }), + 'ws://127.0.0.1:1' + )); + relay.io.sockets.sockets.get(extensionSocketId).disconnect(true); + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.status)) + .not.toBe('connected'); + + expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 2 })).toMatchObject({ status: 'ok' }); + expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 4 })).toMatchObject({ status: 'ok' }); + expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 })).toMatchObject({ status: 'ok' }); + expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 6 })).toMatchObject({ status: 'ok' }); + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ queuedLogicalEvents: 1, queuedMediaIntents: 1, queuedWireEvents: 2 }); + await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime }))) + .toMatchObject({ paused: true }); + + // Terminate the actual MV3 worker. The next runtime message starts a new + // worker, which must migrate/restore the logical queue and local sequence. + await terminateExtensionServiceWorker(context, extensionId, page); + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.queuedMediaIntents)) + .toBe(1); + const restoredQueue = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(restoredQueue.queuedLogicalEvents).toBeGreaterThanOrEqual(1); + expect(restoredQueue.queuedWireEvents).toBeGreaterThanOrEqual(2); + + await page.locator('#player').evaluate(video => { + window.__koalaReconnectSeeks = []; + video.addEventListener('seeked', () => window.__koalaReconnectSeeks.push(video.currentTime)); + }); + + const retryResult = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => { + await chrome.storage.local.set({ serverUrl }); + return chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' }); + }, `ws://127.0.0.1:${port}`)); + expect(retryResult).toMatchObject({ status: 'ok' }); + const replaySeek = await waitForLegacyRelayEvent(legacy, 'seek', 20_000); + const replayPause = await waitForLegacyRelayEvent(legacy, 'pause', 20_000); + expect(replaySeek).toMatchObject({ currentTime: 6, targetTime: 6 }); + expect(replayPause).toMatchObject({ currentTime: 6 }); + expect(replaySeek.seq).toBeLessThan(replayPause.seq); + await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0, queuedMediaIntents: 0 }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ playbackState: 'paused', currentTime: 6 }); + expect(relay.rooms.get(roomId).mediaState.revision).toBeGreaterThan(2); + + // The stale r2 snapshot must never have sought the local player back to 1 + // before its authorized pending intent replayed. + const reconnectSeeks = await page.evaluate(() => window.__koalaReconnectSeeks || []); + expect(reconnectSeeks.some(value => value < 3)).toBe(false); + await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime }))) + .toMatchObject({ paused: true }); + + lateJoiner = await connectLegacyRelayClient(port); + const lateRoom = await joinLegacyRelayRoom(lateJoiner, roomId, 'late-e2e'); + expect(lateRoom.mediaState).toMatchObject({ + revision: relay.rooms.get(roomId).mediaState.revision, + playbackState: 'paused', + currentTime: 6 + }); + + await page.waitForTimeout(700); + const leakedMediaEvents = legacy.messages.filter(message => { + if (!message.startsWith('42')) return false; + try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; } + }); + expect(leakedMediaEvents).toEqual([]); + } finally { + await context.setOffline(false).catch(() => {}); + try { legacy?.close(); } catch { /* already closed */ } + try { canonicalKeeper?.close(); } catch { /* already closed */ } + try { lateJoiner?.close(); } catch { /* already closed */ } + await relay.stopServerForTests(); + } +}); + function FRAMED_VIDEO_PAUSED() { return document.querySelector('iframe').contentDocument.querySelector('video').paused; } diff --git a/tests/e2e/fixture-server.mjs b/tests/e2e/fixture-server.mjs index ce24c94..d11a154 100644 --- a/tests/e2e/fixture-server.mjs +++ b/tests/e2e/fixture-server.mjs @@ -8,8 +8,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); -const port = Number(process.argv[2] || 4173); +const modulePath = fileURLToPath(import.meta.url); +const root = path.resolve(path.dirname(modulePath), 'fixtures'); const TYPES = { '.html': 'text/html; charset=utf-8', @@ -18,62 +18,88 @@ const TYPES = { '.json': 'application/json' }; -const server = http.createServer((req, res) => { - const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); - if (requested === '/redirect/hidden-player') { - res.writeHead(302, { - Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`, - 'Cache-Control': 'no-store' - }).end(); - return; - } - const filePath = path.resolve(root, `.${requested}`); +export function createFixtureServer(port) { + return http.createServer((req, res) => { + const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + if (requested === '/redirect/hidden-player') { + res.writeHead(302, { + Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`, + 'Cache-Control': 'no-store' + }).end(); + return; + } + const filePath = path.resolve(root, `.${requested}`); - if (!filePath.startsWith(root + path.sep)) { - res.writeHead(403).end('forbidden'); - return; - } - - fs.stat(filePath, (statErr, stat) => { - if (statErr || !stat.isFile()) { - res.writeHead(404).end('not found'); + if (!filePath.startsWith(root + path.sep)) { + res.writeHead(403).end('forbidden'); return; } - const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream'; - // Media needs byte ranges: without them Chromium reports an empty - // seekable range and seeking silently does nothing, which would make - // the remote-seek test fail for a reason that has nothing to do with - // the extension. - const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); - if (range) { - const start = range[1] ? Number(range[1]) : 0; - const end = range[2] ? Number(range[2]) : stat.size - 1; - if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) { - res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); + fs.stat(filePath, (statErr, stat) => { + if (statErr || !stat.isFile()) { + res.writeHead(404).end('not found'); return; } - res.writeHead(206, { + + const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream'; + // Media needs byte ranges: without them Chromium reports an empty + // seekable range and seeking silently does nothing, which would make + // the remote-seek test fail for a reason that has nothing to do with + // the extension. + const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''); + if (range) { + const start = range[1] ? Number(range[1]) : 0; + const end = range[2] ? Number(range[2]) : stat.size - 1; + if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); + return; + } + res.writeHead(206, { + 'Content-Type': contentType, + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store' + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + return; + } + + res.writeHead(200, { 'Content-Type': contentType, - 'Content-Length': end - start + 1, - 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Content-Length': stat.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }); - fs.createReadStream(filePath, { start, end }).pipe(res); - return; - } - - res.writeHead(200, { - 'Content-Type': contentType, - 'Content-Length': stat.size, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store' + fs.createReadStream(filePath).pipe(res); }); - fs.createReadStream(filePath).pipe(res); }); -}); +} -server.listen(port, '127.0.0.1', () => { +export async function startFixtureServer(port = Number(process.env.KOALA_E2E_PORT || 4173)) { + const server = createFixtureServer(port); + await new Promise((resolve, reject) => { + const onError = error => reject(error); + server.once('error', onError); + server.listen(port, '127.0.0.1', () => { + server.off('error', onError); + resolve(); + }); + }); + return server; +} + +export async function stopFixtureServer(server) { + if (!server?.listening) return; + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections?.(); + }); +} + +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath); +if (isMainModule) { + const port = Number(process.argv[2] || process.env.KOALA_E2E_PORT || 4173); + await startFixtureServer(port); console.log(`fixture server on http://localhost:${port}`); -}); +} diff --git a/tests/e2e/global-setup.mjs b/tests/e2e/global-setup.mjs new file mode 100644 index 0000000..ca231a9 --- /dev/null +++ b/tests/e2e/global-setup.mjs @@ -0,0 +1,35 @@ +import http from 'node:http'; +import { startFixtureServer, stopFixtureServer } from './fixture-server.mjs'; + +function fixtureIsRunning(port) { + return new Promise(resolve => { + let settled = false; + const finish = result => { + if (settled) return; + settled = true; + resolve(result); + }; + const request = http.get({ + hostname: '127.0.0.1', + port, + path: '/pages/simple-player.html' + }, response => { + response.resume(); + response.once('error', () => finish(false)); + response.once('end', () => finish(response.statusCode === 200)); + }); + request.once('error', () => finish(false)); + request.setTimeout(1000, () => { + request.destroy(); + finish(false); + }); + }); +} + +export default async function globalSetup() { + const port = Number(process.env.KOALA_E2E_PORT || 4173); + if (!process.env.CI && await fixtureIsRunning(port)) return undefined; + + const server = await startFixtureServer(port); + return async () => stopFixtureServer(server); +} diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs index 4bc0d3a..da0625b 100644 --- a/tests/e2e/playwright.config.mjs +++ b/tests/e2e/playwright.config.mjs @@ -6,6 +6,7 @@ const PORT = Number(process.env.KOALA_E2E_PORT || 4173); export default defineConfig({ testDir: '.', testMatch: '**/*.spec.mjs', + globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), // Extension tests drive a persistent context and a service worker; running // them in parallel makes the profile directories fight each other. workers: 1, @@ -46,12 +47,5 @@ export default defineConfig({ name: 'extension-chromium', testIgnore: 'detection.spec.mjs' } - ], - webServer: { - command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`, - url: `http://localhost:${PORT}/pages/simple-player.html`, - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe' - } + ] });