diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2366d1f..3ff67cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,8 +41,10 @@ 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, respects Host Control solo mode and Episode Lobby, then sends an -internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path. +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 @@ -52,10 +54,18 @@ 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 before the relay target TTL. That TTL is -longer than the client ACK timeout so its scheduled fallback can still land. The +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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ee49dae..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" } ``` @@ -110,8 +110,9 @@ 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`, and `updatedBy`, so clients never compare client and -server wall clocks. +`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: @@ -121,11 +122,12 @@ Only accepted, sanitized room controls update canonical state: 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 before `FORCE_SYNC_TARGET_TTL` expires. This relay TTL is - intentionally longer than the client's `FORCE_SYNC_TIMEOUT` ACK wait so the - normal timeout fallback remains deliverable. Expired targets are cleared and - cannot alter canonical state. The latest valid prepare is also the only - post-demotion execute exemption in Host Control mode. + 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` @@ -152,7 +154,12 @@ 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. Offline `play`/`pause`/`seek` compaction remains the separate +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 @@ -347,9 +354,10 @@ The current extension sends sequence/action metadata but no target; the relay us 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 before execute. Invalid prepares are dropped and grant -no exemption. The retained target expires after `FORCE_SYNC_TARGET_TTL`, which -includes a relay grace period beyond the client's ACK timeout. +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/background.js b/extension/background.js index f4bcf1e..736b2ad 100644 --- a/extension/background.js +++ b/extension/background.js @@ -288,7 +288,10 @@ 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; @@ -1880,6 +1883,26 @@ function markCanonicalMediaStateHandled(roomId, revision) { return true; } +function supersedeCanonicalMediaRecovery(reason) { + const roomId = currentRoom?.roomId; + const pending = canonicalMediaStateTracker.getPending(roomId); + if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) { + return false; + } + 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); @@ -1904,9 +1927,21 @@ async function performPendingCanonicalMediaStateApply() { const targetDocumentId = currentTargetDocumentId; try { - const response = await sendMessageToContentTab(tabId, { - type: 'APPLY_CANONICAL_MEDIA_STATE', - mediaState + 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) @@ -1924,6 +1959,9 @@ async function performPendingCanonicalMediaStateApply() { } 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'); @@ -2246,6 +2284,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } + if (isCanonicalSupersedingControl(event, data)) { + supersedeCanonicalMediaRecovery(`newer ${event}`); + } if (data.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -2308,6 +2349,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } + supersedeCanonicalMediaRecovery(`newer ${event}`); if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -2443,6 +2485,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con break; case EVENTS.EPISODE_LOBBY: if (data.senderId && data.expectedTitle) { + supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { currentRoom.activeLobby = { expectedTitle: data.expectedTitle, @@ -2499,6 +2542,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } break; case EVENTS.EPISODE_LOBBY_CANCEL: + supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { currentRoom.activeLobby = null; if (storageInitialized) chrome.storage.session.set({ currentRoom }); @@ -2531,6 +2575,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } function executeForceSync() { + supersedeCanonicalMediaRecovery('local force_sync_execute'); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); @@ -2604,6 +2649,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 }); @@ -2733,6 +2779,10 @@ function routeToContent(action, payload) { commandSenderId, 0 ); + return enqueueContentCommand(deliver); +} + +function enqueueContentCommand(deliver) { const queued = contentCommandQueue.catch(() => {}).then(deliver); contentCommandQueue = queued; return queued; @@ -4824,6 +4874,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { payload.targetTime = targetTime; } + if (isCanonicalSupersedingControl(message.action, payload)) { + supersedeCanonicalMediaRecovery(`local ${message.action}`); + } const timestamp = Date.now(); localSeq++; chrome.storage.session.set({ localSeq }); @@ -4883,7 +4936,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendChatActivity(message.action, peerId, timestamp); const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK; - if (isNonEssentialEvent && !hasOtherPeers) { + if (isNonEssentialEvent + && !hasOtherPeers + && !serverSupports(CAPABILITIES.MEDIA_STATE_V1)) { sendResponse({ status: 'ok_solo' }); return; } @@ -5159,6 +5214,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (episodeLobby) clearEpisodeLobbyState(); // Create new lobby + supersedeCanonicalMediaRecovery('local episode_lobby'); episodeLobby = { expectedTitle: lobbyTitle, initiatorPeerId: peerId, diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index 2385d80..05f82b4 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -22,6 +22,8 @@ describe('canonical ROOM_DATA recovery contract', () => { 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', () => { @@ -39,6 +41,8 @@ describe('canonical ROOM_DATA recovery contract', () => { 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('); @@ -81,6 +85,17 @@ describe('canonical ROOM_DATA recovery contract', () => { 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(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'); @@ -92,6 +107,8 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(apply.indexOf("status: 'applied'")) .toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)')); 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)) diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js index c728797..55b7898 100644 --- a/extension/canonical-media-state.js +++ b/extension/canonical-media-state.js @@ -10,6 +10,11 @@ export function validateCanonicalMediaState(value) { || 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, @@ -18,6 +23,9 @@ export function validateCanonicalMediaState(value) { 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; } diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs index a1fe540..eac7af1 100644 --- a/extension/canonical-media-state.test.mjs +++ b/extension/canonical-media-state.test.mjs @@ -18,6 +18,14 @@ describe('canonical media state validation', () => { 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, [], diff --git a/extension/content.js b/extension/content.js index 198fa8a..4679318 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1270,10 +1270,18 @@ || typeof mediaState.currentTime !== 'number' || !Number.isFinite(mediaState.currentTime) || mediaState.currentTime < 0 - || mediaState.currentTime > MAX_MEDIA_TIME) { + || mediaState.currentTime > MAX_MEDIA_TIME + || (mediaState.mediaTitle !== undefined + && mediaState.mediaTitle !== null + && typeof mediaState.mediaTitle !== 'string')) { return { status: 'invalid' }; } 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' }; diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 7515e14..b9a6075 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -10,7 +10,7 @@ import { materializeMediaIntent, reserveLatestMediaIntentSequence } from '../extension/offline-media-intent.js'; -import { FORCE_SYNC_TARGET_TTL, FORCE_SYNC_TIMEOUT } from '../shared/constants.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')); @@ -83,7 +83,7 @@ try { 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']); + 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 }); @@ -175,12 +175,13 @@ try { // --- 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 - // client advertises only its existing chat capability; media-state support - // is a relay capability and is never required for server acquisition. + // 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']); + 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'); @@ -294,7 +295,7 @@ try { 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']); + 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); @@ -302,6 +303,27 @@ try { '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(); @@ -311,12 +333,18 @@ try { const initialMediaRoom = await j(msa, msrid, 'msa'); assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null'); - s(msa, 'play', { currentTime: 100, revision: 999, updatedBy: 'spoofed' }); + 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'); @@ -404,8 +432,8 @@ try { 'authorized EXECUTE commits the latest target visible to legacy peers'); assert.equal(competingForceState.updatedBy, 'msa'); - // The initiator's normal ACK timeout must still fit inside relay target - // retention. This is the exact fallback boundary used by background.js. + // 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; @@ -414,18 +442,39 @@ try { 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_TTL - 1; + 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', {}); - let expiredExecuteDropped = false; - try { await w(msb, 'force_sync_execute', 500); } catch { expiredExecuteDropped = true; } - assert.ok(expiredExecuteDropped, 'an expired Force Sync target rejects delayed EXECUTE'); - assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeExpiredExecute); + 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'); @@ -830,14 +879,21 @@ try { close(); resetConnectionRate(); - // A transient disconnect clears the Host Control exemption but not the - // validated room target. If the peer rejoins with current authority, its - // recovered EXECUTE must keep live playback and canonical state aligned. + // 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(); diff --git a/server/index.js b/server/index.js index f6bfddc..3de56d7 100644 --- a/server/index.js +++ b/server/index.js @@ -4,7 +4,7 @@ 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, FORCE_SYNC_TARGET_TTL, MAX_MEDIA_TIME } 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, @@ -204,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) )]; } @@ -213,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. @@ -295,6 +301,16 @@ 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); @@ -470,7 +486,10 @@ io.on('connection', (socket) => { mediaState: null, // PREPARE is choreography, not stable room intent. Retain its // validated target only so the matching EXECUTE can commit it. - forceSyncTarget: null + 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; @@ -612,8 +631,11 @@ io.on('connection', (socket) => { // FORCE_SYNC_EXECUTE still has to land after demotion — // otherwise the already-relayed room-wide choreography // would leave peers paused. - const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE && - room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator; + 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)) && @@ -687,37 +709,33 @@ io.on('connection', (socket) => { Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); const mediaStateNow = Date.now(); - if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { - const forceSyncTarget = room.forceSyncTarget; - const targetExpired = forceSyncTarget - && (!Number.isFinite(forceSyncTarget.preparedAt) - || mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_TTL); - if (!forceSyncTarget || targetExpired) { - log('ROOM', `Dropped force_sync_execute ${targetExpired ? 'with an expired target' : 'without a prepared target'} from ${mapping.peerId}`); - room.forceSyncInitiator = null; - room.forceSyncTarget = null; - return; - } - } - if (eventName === EVENTS.PLAY - || eventName === EVENTS.PAUSE - || eventName === EVENTS.SEEK - || eventName === EVENTS.EPISODE_LOBBY - || eventName === EVENTS.EPISODE_LOBBY_CANCEL) { - // 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.forceSyncInitiator = null; - room.forceSyncTarget = null; - } // Canonical Media State v1: mutate only after rate limiting, // room mapping, Host Control authorization and sanitization. // Heartbeats remain observational and never enter this path. - updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, { - now: mediaStateNow, - senderPlaybackState: existing.playbackState - }); + 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. @@ -731,23 +749,42 @@ io.on('connection', (socket) => { // 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 + 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 + 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); diff --git a/server/media-state.js b/server/media-state.js index 58f9697..60a621b 100644 --- a/server/media-state.js +++ b/server/media-state.js @@ -5,6 +5,11 @@ function clampMediaTime(value) { 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); @@ -25,15 +30,18 @@ export function snapshotMediaState(mediaState, now = Date.now()) { || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) { return null; } - return { + 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) { +function commitMediaState(room, playbackState, currentTime, updatedBy, now, mediaTitle = null) { const normalizedTime = clampMediaTime(currentTime); if (normalizedTime === null || (playbackState !== 'playing' && playbackState !== 'paused') @@ -41,21 +49,28 @@ function commitMediaState(room, playbackState, currentTime, updatedBy, now) { || !updatedBy) { return false; } - room.mediaState = { + 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 + 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); @@ -66,7 +81,8 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer eventName === EVENTS.PLAY ? 'playing' : 'paused', currentTime, senderPeerId, - now + now, + mediaTitle ); } @@ -74,12 +90,12 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer 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); + return commitMediaState(room, playbackState, targetTime, senderPeerId, now, mediaTitle); } return false; } -export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now()) { - return commitMediaState(room, 'playing', targetTime, senderPeerId, now); +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 index 3c10b1e..bfb5b58 100644 --- a/server/media-state.test.mjs +++ b/server/media-state.test.mjs @@ -67,6 +67,36 @@ describe('canonical media state', () => { 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( @@ -103,7 +133,14 @@ describe('canonical media state', () => { 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)).toBe(true); - expect(target.mediaState).toEqual({ revision: 5, playbackState: 'playing', currentTime: 500, updatedAt: 2000, updatedBy: 'b' }); + 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 a2b851f..79ff46f 100644 --- a/shared/README.md +++ b/shared/README.md @@ -64,7 +64,7 @@ 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_TTL`: relay retention for a prepared Force Sync target; includes post-timeout delivery grace. +- `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. diff --git a/shared/constants.js b/shared/constants.js index 69e3fb2..56d6e46 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -99,7 +99,7 @@ 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) -// Relay retention must outlive the client's ACK wait. Otherwise the timeout -// fallback EXECUTE arrives exactly when the relay expires its prepared target. -export const FORCE_SYNC_TARGET_TTL = FORCE_SYNC_TIMEOUT + 2000; +// 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/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 92f0e34..45c8c22 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -381,6 +381,29 @@ test('applies canonical recovery without echoing media commands or activity', as 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); @@ -470,6 +493,119 @@ test('recovers relay ROOM_DATA through background retries into the packed player } }); +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 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 expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ status: 'connected', roomId }); + + expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 8 })) + .toMatchObject({ status: 'ok' }); + expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 8 })) + .toMatchObject({ status: 'ok' }); + await expect.poll(() => relay.rooms.get(roomId)?.mediaState) + .toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 8 }); + } 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();