From c064953cca28f1778e9ac2ebde5f55a1ae1874ed Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:27:08 +0200 Subject: [PATCH 01/16] feat(sync): add canonical room media state --- docs/ARCHITECTURE.md | 21 ++ docs/PROTOCOL.md | 60 +++- extension/background.js | 153 +++++++++- .../canonical-media-state-background.test.mjs | 80 +++++ extension/canonical-media-state.js | 132 +++++++++ extension/canonical-media-state.test.mjs | 132 +++++++++ extension/content.js | 47 +++ scripts/build-extension.cjs | 7 +- scripts/test-server-ws.mjs | 279 +++++++++++++++++- server/README.md | 1 + server/index.js | 53 +++- server/media-state.js | 87 ++++++ server/media-state.test.mjs | 97 ++++++ shared/README.md | 2 + shared/constants.js | 6 +- tests/e2e/extension.spec.mjs | 49 +++ 16 files changed, 1191 insertions(+), 15 deletions(-) create mode 100644 extension/canonical-media-state-background.test.mjs create mode 100644 extension/canonical-media-state.js create mode 100644 extension/canonical-media-state.test.mjs create mode 100644 server/media-state.js create mode 100644 server/media-state.test.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f894cf..2d64677 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,27 @@ 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, respects Host Control solo mode and Episode Lobby, then sends an +internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path. +That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, +the 2-second drift tolerance, and programmatic-event suppression. The apply is +one-shot recovery: it creates no action history, notification, command ACK, or +relay media event. + +Force Sync remains a two-phase ACK protocol. `PREPARE` is temporary choreography; +the matching `EXECUTE` commits its validated target to canonical state. Per-sender +`seq`, peer heartbeats, and the existing reconnect event queue remain separate. +Offline media-command compaction is intentionally deferred. + ## 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..6088b5a 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -83,13 +83,67 @@ 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`, and `updatedBy`, so clients never compare client and +server wall clocks. + +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. +- `force_sync_prepare` records only temporary coordination state. Its matching + `force_sync_execute` commits the prepared target as playing. + +`peer_status` heartbeats are observations and never rewrite canonical intent. +Per-sender `seq` still orders commands from one sender; canonical `revision` +orders server-accepted room transitions. Neither replaces the other. + +On join/reconnect, a capable extension applies a valid snapshot once through an +extension-internal recovery message. 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 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. Offline `play`/`pause`/`seek` compaction is planned separately and is +not part of Media State v1. + ## Ephemeral encrypted chat Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep @@ -253,7 +307,9 @@ 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 current extension sends sequence/action metadata but no target; the relay uses +the validated target retained from the matching `force_sync_prepare`. 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. diff --git a/extension/background.js b/extension/background.js index 01ed4ff..cacf35a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -8,6 +8,7 @@ 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 { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import { MEDIA_FRAME_ACCESS_REQUIRED, @@ -238,6 +239,15 @@ let isNamespaceJoined = false; 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_MEDIA_EVENTS = new Set([ + EVENTS.PLAY, + EVENTS.PAUSE, + EVENTS.SEEK, + EVENTS.FORCE_SYNC_PREPARE, + EVENTS.FORCE_SYNC_EXECUTE +]); +let flushedMediaControlsBeforeRoomData = false; // --- Host Control Mode --- let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only' @@ -258,6 +268,18 @@ function serverSupportsChat() { } const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]); +function persistCanonicalMediaRecovery() { + if (!storageInitialized) return; + chrome.storage.session.set({ + canonicalMediaRecovery: canonicalMediaStateTracker.snapshot() + }).catch(() => {}); +} + +function clearCanonicalMediaRecovery() { + canonicalMediaStateTracker.clear(); + persistCanonicalMediaRecovery(); +} + function invalidateChatSession() { chatSessionGeneration++; chatReceiveQueue = Promise.resolve(); @@ -274,6 +296,7 @@ function clearChatActivity() { async function clearFailedJoinCredentials() { webJoinCoordinator.invalidate(); connectIntent = false; + clearCanonicalMediaRecovery(); chatSecretGuard = ''; invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); @@ -364,7 +387,7 @@ function ensureState() { 'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo', 'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', - 'hcmDesynced', 'chatActivityTimeline' + 'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery' ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; @@ -413,6 +436,10 @@ 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(); + canonicalMediaStateTracker.restore( + data.canonicalMediaRecovery, + currentRoom?.roomId || null + ); if (data.lastActionState) lastActionState = data.lastActionState; if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50); @@ -883,7 +910,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(() => {}); + tryApplyPendingCanonicalMediaState().catch(() => {}); + return true; } currentTargetFrameId = senderFrameId; @@ -897,6 +929,7 @@ function adoptReportingFrame(sender) { currentTargetDocumentId, currentTargetHasVideo }).catch(() => {}); + tryApplyPendingCanonicalMediaState().catch(() => {}); return true; } @@ -998,6 +1031,7 @@ async function leaveRoomAfterIdleGrace(reason) { emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; + clearCanonicalMediaRecovery(); clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; @@ -1104,6 +1138,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'); @@ -1155,6 +1191,12 @@ async function connect() { protocolVersion: PROTOCOL_VERSION }); } + // Preserve the existing immediate queue-drain behavior. Remember + // whether local media intent was flushed before ROOM_DATA so an + // older snapshot cannot snap the player backward on reconnect. + flushedMediaControlsBeforeRoomData = eventQueue.some(item => + item && CANONICAL_MEDIA_EVENTS.has(item.event) + ); flushEventQueue(); } else if (msg.startsWith('42')) { try { @@ -1583,6 +1625,95 @@ function stopPing() { missedPongs = 0; } +function markCanonicalMediaStateHandled(roomId, revision) { + if (!canonicalMediaStateTracker.markHandled(roomId, revision)) return false; + persistCanonicalMediaRecovery(); + return true; +} + +async function tryApplyPendingCanonicalMediaState() { + const roomId = currentRoom?.roomId; + const pending = canonicalMediaStateTracker.getPending(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' }; + + try { + const response = await sendMessageToContentTab(tabId, { + type: 'APPLY_CANONICAL_MEDIA_STATE', + mediaState + }); + if (currentRoom?.roomId !== roomId) return { status: 'stale_room' }; + 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 === '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 handleCanonicalRoomData(data, hadQueuedMediaControls) { + 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; + + persistCanonicalMediaRecovery(); + addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info'); + + if (hadQueuedMediaControls) { + markCanonicalMediaStateHandled(data.roomId, mediaState.revision); + addLog(`Canonical media state r${mediaState.revision} skipped: queued local media controls are authoritative for this reconnect`, '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) { if (!data) { @@ -1603,7 +1734,9 @@ async function handleServerEvent(event, data) { return; } switch (event) { - case EVENTS.ROOM_DATA: + case EVENTS.ROOM_DATA: { + const hadQueuedMediaControls = flushedMediaControlsBeforeRoomData; + flushedMediaControlsBeforeRoomData = false; if (currentRoom?.roomId !== data.roomId) { invalidateChatSession(); clearChatActivity(); @@ -1668,7 +1801,9 @@ async function handleServerEvent(event, data) { // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); + await handleCanonicalRoomData(data, hadQueuedMediaControls); break; + } case EVENTS.CONTROL_MODE: // Host Control Mode changed (toggle or host-leave fallback). controlMode = data.controlMode || CONTROL_MODES.EVERYONE; @@ -3243,6 +3378,9 @@ async function activateTargetTab(tabId, tabTitle, { return { status: 'superseded' }; } updateBadgeStatus(); + if (currentTargetHasVideo) { + await tryApplyPendingCanonicalMediaState(); + } return { status: 'ok', tabId: selectedTabId, @@ -3656,6 +3794,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; @@ -4076,6 +4215,13 @@ 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) { + 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'); + } + } sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { webJoinCoordinator.invalidate(); @@ -4086,6 +4232,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; + clearCanonicalMediaRecovery(); clearChatActivity(); controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs new file mode 100644 index 0000000..97083a4 --- /dev/null +++ b/extension/canonical-media-state-background.test.mjs @@ -0,0 +1,80 @@ +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); + 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')); + }); + + 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, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'"); + 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('applyCanonicalMediaState(message.mediaState)'); + 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('tryApplyPendingCanonicalMediaState().catch(() => {})'); + expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/); + expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4); + }); + + it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => { + const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent'); + expect(apply).toContain('if (hcmDesynced)'); + expect(apply).toContain('if (episodeLobby)'); + expect(roomData).toContain('if (hadQueuedMediaControls)'); + expect(backgroundSource).toContain('flushedMediaControlsBeforeRoomData = eventQueue.some'); + }); + + it('reuses existing seek abstractions, suppression and drift tolerance', () => { + const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady'); + expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA'); + expect(apply).toContain("_setSuppress('seek')"); + expect(apply).toContain('seekVideo(video, mediaState.currentTime)'); + expect(apply).toContain('tryMediaAction(EVENTS.PAUSE)'); + expect(apply).toContain('tryMediaAction(EVENTS.PLAY)'); + expect(apply).toContain('if (hcmDesynced)'); + }); +}); diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js new file mode 100644 index 0000000..6993585 --- /dev/null +++ b/extension/canonical-media-state.js @@ -0,0 +1,132 @@ +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; + } + 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); + } + 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 }; +} + +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); + appliedRevision = 0; + pending = null; + }, + + receive(nextRoomId, value) { + 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 }; + return { status: 'pending', mediaState }; + }, + + getPending(nextRoomId = roomId) { + return pending && pending.roomId === normalizeRoomId(nextRoomId) + ? { roomId: pending.roomId, mediaState: { ...pending.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 }; + knownRevision = restoredPending.revision; + } + return true; + }, + + snapshot() { + return { + roomId, + knownRevision, + appliedRevision, + pending: pending ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null + }; + } + }; +} diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs new file mode 100644 index 0000000..de556a6 --- /dev/null +++ b/extension/canonical-media-state.test.mjs @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { + canonicalMediaStateFromRoomData, + createCanonicalMediaStateTracker, + 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.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('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('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(); + }); +}); diff --git a/extension/content.js b/extension/content.js index f1d8995..e56a6cb 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. @@ -1212,6 +1213,47 @@ } } + function applyCanonicalMediaState(mediaState) { + 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) { + return { status: 'invalid' }; + } + if (hcmDesynced) return { status: 'ignored_desynced' }; + + 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; + + 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) { + tryMediaAction(EVENTS.PAUSE); + } + if (shouldSeek) { + _setSuppress('seek'); + seekVideo(video, mediaState.currentTime); + } + if (mediaState.playbackState === 'playing' && video.paused) { + tryMediaAction(EVENTS.PLAY); + } + scheduleProactiveHeartbeat(); + return { status: 'applied', revision: mediaState.revision, drift, sought: shouldSeek }; + } catch (error) { + reportLog(`Canonical media state apply failed: ${error.message}`, 'warn'); + return { status: 'apply_failed' }; + } + } + // --- Helper: Wait until video is ready for playback (buffered & seeked) --- function pollSeekReady(targetTime, timeoutMs = 8000) { return new Promise((resolve) => { @@ -1312,6 +1354,11 @@ return true; } + if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') { + sendResponse(applyCanonicalMediaState(message.mediaState)); + return true; + } + if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index 3146fda..b523fec 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'); diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 3b7a509..a8ca1c3 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -22,11 +22,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). @@ -76,10 +79,282 @@ 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 + // client advertises only its existing chat capability; media-state support + // is a relay capability and is never required for server acquisition. + 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']); + 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']); + 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'); + 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, 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.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); + + // 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); + + 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', {}); + await delay(80); + assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room'); + close(); + resetConnectionRate(); + // --- Encrypted chat is a live-only canonical relay --- const chatRoom = 'chat-'+Date.now(); const chat1 = await c(), chat2 = await c(); diff --git a/server/README.md b/server/README.md index 320db91..545e73e 100644 --- a/server/README.md +++ b/server/README.md @@ -104,5 +104,6 @@ For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-serv | 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 e98da03..48500de 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, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js'; +import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js'; import { createChatEnvelope } from './chat.js'; +import { + commitForceSyncMediaState, + snapshotMediaState, + updateMediaStateFromControl +} from './media-state.js'; import { buildHealthPayload, checkCooldown, @@ -178,7 +183,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) { @@ -283,6 +289,7 @@ function removePeerFromRoom(socketId, roomId, reason) { // H-1: a leaving initiator strands the room's force-sync — release the // slot so a future controller's PREPARE can take over cleanly. if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null; + if (room.forceSyncTarget?.initiatorPeerId === peerId) room.forceSyncTarget = null; if (room.hostPeerId === peerId) { // Owner left → reassign owner + fall back to 'everyone' so the room is // never stuck locked, and reset the controller set to just the new owner. @@ -445,7 +452,13 @@ 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 }; rooms.set(roomId, room); createdByMe = true; @@ -528,6 +541,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 +549,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)}***`); @@ -617,7 +632,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), @@ -628,8 +643,8 @@ io.on('connection', (socket) => { 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), + 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,6 +660,32 @@ io.on('connection', (socket) => { }; // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); + + // 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 mediaStateNow = Date.now(); + updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, { + now: mediaStateNow, + senderPlaybackState: existing.playbackState + }); + if (eventName === EVENTS.FORCE_SYNC_PREPARE) { + room.forceSyncTarget = Number.isFinite(relayPayload.targetTime) + ? { initiatorPeerId: mapping.peerId, targetTime: relayPayload.targetTime } + : null; + } else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { + const forceSyncTarget = room.forceSyncTarget; + if (forceSyncTarget?.initiatorPeerId === mapping.peerId) { + commitForceSyncMediaState( + room, + forceSyncTarget.targetTime, + mapping.peerId, + mediaStateNow + ); + } + room.forceSyncTarget = null; + } + socket.to(mapping.roomId).emit(eventName, relayPayload); // --- Side-effects: Server-side Episode Lobby Tracking --- diff --git a/server/media-state.js b/server/media-state.js new file mode 100644 index 0000000..3acd5fd --- /dev/null +++ b/server/media-state.js @@ -0,0 +1,87 @@ +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)); +} + +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; + } + return { + revision: mediaState.revision, + playbackState: mediaState.playbackState, + currentTime, + updatedBy: mediaState.updatedBy + }; +} + +function commitMediaState(room, playbackState, currentTime, updatedBy, now) { + const normalizedTime = clampMediaTime(currentTime); + if (normalizedTime === null + || (playbackState !== 'playing' && playbackState !== 'paused') + || typeof updatedBy !== 'string' + || !updatedBy) { + return false; + } + room.mediaState = { + revision: (room.mediaState?.revision || 0) + 1, + playbackState, + currentTime: normalizedTime, + updatedAt: now, + updatedBy + }; + return true; +} + +export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, { + now = Date.now(), + senderPlaybackState = null +} = {}) { + if (!room || !payload || typeof payload !== 'object') return false; + + 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 + ); + } + + if (eventName === EVENTS.SEEK) { + const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime); + const playbackState = payload.playbackState === 'playing' || payload.playbackState === 'paused' + ? payload.playbackState + : (room.mediaState?.playbackState || senderPlaybackState); + if (targetTime === null) return false; + return commitMediaState(room, playbackState, targetTime, senderPeerId, now); + } + + return false; +} + +export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now()) { + return commitMediaState(room, 'playing', targetTime, senderPeerId, now); +} diff --git a/server/media-state.test.mjs b/server/media-state.test.mjs new file mode 100644 index 0000000..ce84a17 --- /dev/null +++ b/server/media-state.test.mjs @@ -0,0 +1,97 @@ +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('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)).toBe(true); + expect(target.mediaState).toEqual({ revision: 5, playbackState: 'playing', currentTime: 500, updatedAt: 2000, updatedBy: 'b' }); + }); +}); diff --git a/shared/README.md b/shared/README.md index d90bd79..8b45e3a 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`. @@ -64,6 +65,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. - `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness. +- `MAX_MEDIA_TIME`: shared relay/extension upper bound for synchronized media positions. ## Do Not Break diff --git a/shared/constants.js b/shared/constants.js index 5ed3a66..7165ff7 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -82,9 +82,13 @@ 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) 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 62e719e..a2b75cd 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -54,6 +54,15 @@ async function getExtensionState(context, extensionId, message) { )); } +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 setAudioSettings(context, extensionId, settings) { return withExtensionPage(context, extensionId, page => page.evaluate( value => chrome.storage.local.set({ audioSettings: value }), @@ -263,6 +272,46 @@ 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); + + // 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('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { const first = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); From 90050bd9ecf8cef492a79aba3f052007caf63d3a Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:30:54 +0200 Subject: [PATCH 02/16] feat(sync): coalesce offline media intent --- docs/ARCHITECTURE.md | 27 +- docs/PROTOCOL.md | 25 +- extension/background.js | 228 +++++++--- .../canonical-media-state-background.test.mjs | 6 +- .../offline-media-intent-background.test.mjs | 73 ++++ extension/offline-media-intent.js | 407 ++++++++++++++++++ extension/offline-media-intent.test.mjs | 265 ++++++++++++ scripts/test-server-ws.mjs | 70 +++ tests/e2e/extension.spec.mjs | 184 ++++++++ 9 files changed, 1228 insertions(+), 57 deletions(-) create mode 100644 extension/offline-media-intent-background.test.mjs create mode 100644 extension/offline-media-intent.js create mode 100644 extension/offline-media-intent.test.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d64677..3c86ff8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,8 +50,31 @@ relay media event. Force Sync remains a two-phase ACK protocol. `PREPARE` is temporary choreography; the matching `EXECUTE` commits its validated target to canonical state. Per-sender -`seq`, peer heartbeats, and the existing reconnect event queue remain separate. -Offline media-command compaction is intentionally deferred. +`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. + +## 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: diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6088b5a..8cf7e42 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -141,8 +141,29 @@ 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 is planned separately and is -not part of Media State v1. +required. 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. ## Ephemeral encrypted chat diff --git a/extension/background.js b/extension/background.js index cacf35a..24742d5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -9,6 +9,19 @@ 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, @@ -234,20 +247,16 @@ let storageInitialized = false; let pendingLogs = []; let pendingHistory = []; let eventQueue = []; +let eventQueueVersion = 0; let flushTimer = null; // paces draining of eventQueue after (re)connect +let flushInProgress = false; 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_MEDIA_EVENTS = new Set([ - EVENTS.PLAY, - EVENTS.PAUSE, - EVENTS.SEEK, - EVENTS.FORCE_SYNC_PREPARE, - EVENTS.FORCE_SYNC_EXECUTE -]); -let flushedMediaControlsBeforeRoomData = false; // --- Host Control Mode --- let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only' @@ -296,6 +305,7 @@ function clearChatActivity() { async function clearFailedJoinCredentials() { webJoinCoordinator.invalidate(); connectIntent = false; + clearEventQueue(); clearCanonicalMediaRecovery(); chatSecretGuard = ''; invalidateChatSession(); @@ -442,7 +452,6 @@ function ensureState() { ); 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; } @@ -482,10 +491,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) { @@ -728,6 +746,8 @@ function forceDisconnect() { currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; + awaitingRoomData = false; + pendingRoomDataRoomId = null; invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; @@ -735,7 +755,9 @@ function forceDisconnect() { lastContentHeartbeatAt = null; forceSyncAcks.clear(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + flushInProgress = false; eventQueue = []; + eventQueueVersion++; chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], @@ -1082,6 +1104,7 @@ async function connect() { try { if (!peerId) peerId = await getPeerId(); settings = await getSettings(); + pendingRoomDataRoomId = settings.roomId || currentRoom?.roomId || null; } catch (e) { throw new Error(`[Storage Error] ${e.message}`); } @@ -1180,6 +1203,8 @@ async function connect() { addLog('Joined Namespace /', 'success'); const settings = await getSettings(); if (settings.roomId) { + awaitingRoomData = true; + pendingRoomDataRoomId = settings.roomId; const sharedTitles = getSharedTitleFields(settings); emit(EVENTS.JOIN_ROOM, { roomId: settings.roomId, @@ -1190,14 +1215,11 @@ async function connect() { clientCapabilities: CLIENT_CAPABILITIES, protocolVersion: PROTOCOL_VERSION }); + } else { + awaitingRoomData = false; + pendingRoomDataRoomId = null; + flushEventQueue(); } - // Preserve the existing immediate queue-drain behavior. Remember - // whether local media intent was flushed before ROOM_DATA so an - // older snapshot cannot snap the player backward on reconnect. - flushedMediaControlsBeforeRoomData = eventQueue.some(item => - item && CANONICAL_MEDIA_EVENTS.has(item.event) - ); - flushEventQueue(); } else if (msg.startsWith('42')) { try { const payload = JSON.parse(msg.substring(2)); @@ -1215,6 +1237,8 @@ async function connect() { socket.onclose = () => { isConnecting = false; isNamespaceJoined = false; + awaitingRoomData = false; + pendingRoomDataRoomId = null; invalidateChatSession(); stopPing(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } @@ -1490,7 +1514,10 @@ 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) { try { const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); @@ -1522,41 +1549,120 @@ 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 frame again is idempotent and + * preserves the final desired state on the next reconnect. */ -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 - } - let sent = 0; - while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) { - const queuedMsg = eventQueue.shift(); - emit(queuedMsg.event, queuedMsg.data); - sent++; +async function flushEventQueue() { + if (flushTimer || flushInProgress || awaitingRoomData) return; + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return; + flushInProgress = true; + try { + // Resolve privacy settings before taking queue ownership. If new work + // arrives during the drain, keep the original logical entries too; a + // later idempotent replay is safer than losing the concurrent intent. + const replaySettings = eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null; + const drainSource = eventQueue; + const drainVersion = eventQueueVersion; + const result = await drainQueuedBatch(drainSource, { + roomId: currentRoom?.roomId || null, + maxWireEvents: FLUSH_BATCH_SIZE, + sendFrame: async (frame, entry) => { + 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 && socket?.readyState === WebSocket.OPEN && isNamespaceJoined) { + flushTimer = setTimeout(() => { + flushTimer = null; + flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn')); + }, FLUSH_BATCH_INTERVAL_MS); + } + } finally { + flushInProgress = false; + } } function addToHistory(action, senderId) { @@ -1678,7 +1784,7 @@ async function tryApplyPendingCanonicalMediaState() { } } -async function handleCanonicalRoomData(data, hadQueuedMediaControls) { +async function handleCanonicalRoomData(data, hasPendingLocalIntent) { canonicalMediaStateTracker.adoptRoom(data?.roomId || null); const canonicalSnapshot = canonicalMediaStateFromRoomData(data); if (canonicalSnapshot.status === 'unsupported' || canonicalSnapshot.status === 'empty') { @@ -1703,9 +1809,9 @@ async function handleCanonicalRoomData(data, hadQueuedMediaControls) { persistCanonicalMediaRecovery(); addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info'); - if (hadQueuedMediaControls) { + if (hasPendingLocalIntent) { markCanonicalMediaStateHandled(data.roomId, mediaState.revision); - addLog(`Canonical media state r${mediaState.revision} skipped: queued local media controls are authoritative for this reconnect`, 'info'); + addLog(`Canonical media state r${mediaState.revision} skipped: authorized queued local intent takes precedence`, 'info'); return; } const result = await tryApplyPendingCanonicalMediaState(); @@ -1735,8 +1841,6 @@ async function handleServerEvent(event, data) { } switch (event) { case EVENTS.ROOM_DATA: { - const hadQueuedMediaControls = flushedMediaControlsBeforeRoomData; - flushedMediaControlsBeforeRoomData = false; if (currentRoom?.roomId !== data.roomId) { invalidateChatSession(); clearChatActivity(); @@ -1767,7 +1871,10 @@ async function handleServerEvent(event, data) { } // Recover server-tracked active Episode Lobby if present - if (data && data.activeLobby && !episodeLobby) { + if (!data?.activeLobby && episodeLobby) { + clearEpisodeLobbyState(); + addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info'); + } else if (data && data.activeLobby && !episodeLobby) { episodeLobby = { expectedTitle: data.activeLobby.expectedTitle, initiatorPeerId: data.activeLobby.initiatorPeerId, @@ -1801,7 +1908,22 @@ async function handleServerEvent(event, data) { // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); - await handleCanonicalRoomData(data, hadQueuedMediaControls); + awaitingRoomData = false; + pendingRoomDataRoomId = null; + + const lostRoomAuthority = controlMode === CONTROL_MODES.HOST_ONLY + && hostPeerId + && !amController(); + const queuePolicy = applyQueuedRoomPolicy(data.roomId, { + canControl: !lostRoomAuthority, + activeLobby: !!episodeLobby, + desynced: hcmDesynced + }, lostRoomAuthority + ? 'Host Control role changed while offline' + : (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy')); + + await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent); + await flushEventQueue(); break; } case EVENTS.CONTROL_MODE: @@ -4013,6 +4135,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, @@ -4216,6 +4341,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { 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); diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index 97083a4..67024ac 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -64,8 +64,10 @@ describe('canonical ROOM_DATA recovery contract', () => { const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent'); expect(apply).toContain('if (hcmDesynced)'); expect(apply).toContain('if (episodeLobby)'); - expect(roomData).toContain('if (hadQueuedMediaControls)'); - expect(backgroundSource).toContain('flushedMediaControlsBeforeRoomData = eventQueue.some'); + expect(roomData).toContain('if (hasPendingLocalIntent)'); + expect(backgroundSource).toContain('awaitingRoomData = true'); + expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'); + expect(backgroundSource).toContain('await flushEventQueue()'); }); it('reuses existing seek abstractions, suppression and drift tolerance', () => { diff --git a/extension/offline-media-intent-background.test.mjs b/extension/offline-media-intent-background.test.mjs new file mode 100644 index 0000000..882e848 --- /dev/null +++ b/extension/offline-media-intent-background.test.mjs @@ -0,0 +1,73 @@ +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); + 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('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'); + }); + + 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); + expect(roomData.indexOf('applyQueuedRoomPolicy(data.roomId')) + .toBeLessThan(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)')); + expect(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)')) + .toBeLessThan(roomData.indexOf('flushEventQueue()')); + expect(roomData).toContain('activeLobby: !!episodeLobby'); + expect(roomData).toContain('desynced: hcmDesynced'); + expect(roomData).toContain('if (!data?.activeLobby && episodeLobby)'); + }); + + 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('forceDisconnect()'); + }); + + 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).not.toMatch(/eventQueue\.shift\(\)[\s\S]*emit\(/); + }); + + 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..40ed8be --- /dev/null +++ b/extension/offline-media-intent.js @@ -0,0 +1,407 @@ +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 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) { + 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 (!isMediaQueueEvent(event)) { + next.push({ 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 + } + }; +} + +export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOGICAL_QUEUE_SIZE) { + if (!Array.isArray(value) || typeof roomId !== 'string' || !roomId) return []; + let normalized = []; + for (const entry of value) { + if (isQueuedMediaIntent(entry)) { + const intentEntry = normalizeIntentEntry(entry, roomId); + if (intentEntry) normalized.push(intentEntry); + continue; + } + if (!entry || typeof entry !== 'object' || typeof entry.event !== 'string') continue; + if (STALE_OFFLINE_EVENTS.has(entry.event)) continue; + if (isMediaQueueEvent(entry.event)) { + normalized = enqueueQueuedEvent(normalized, entry.event, entry.data, { roomId, maxEntries }).queue; + } else { + normalized.push({ event: entry.event, data: entry.data }); + } + normalized = trimQueue(normalized, maxEntries).queue; + } + 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) { + const data = {}; + if (seq !== null) data.seq = seq; + if (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 []; + + // Both materialized frames carry the latest genuine action timestamp: it is + // correlation metadata for existing ACK/activity paths, not scheduled wall + // time. 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. + 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), currentTime, targetTime: currentTime } + }; + const stateFrame = { + event: stateEvent, + data: { ...frameData(intent, intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq), 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 +} = {}) { + const source = Array.isArray(queue) ? queue : []; + const blockedEvents = !canControl + ? HOST_GATED_EVENTS + : (activeLobby || desynced ? FORCE_SYNC_EVENTS : null); + const reconciled = source.filter(entry => { + 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 (isQueuedMediaIntent(entry) && entry.roomId !== roomId) { + remaining.shift(); + droppedStaleIntents++; + continue; + } + const frames = isQueuedMediaIntent(entry) + ? materializeMediaIntent(entry) + : [{ event: entry.event, data: entry.data }]; + if (frames.length === 0) { + remaining.shift(); + 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 of frames) { + if (!await sendFrame(frame, entry)) { + return { + queue: remaining, + sentWireEvents: sentWireEvents + sentEntryFrames, + droppedStaleIntents, + status: 'send_failed' + }; + } + sentEntryFrames++; + } + sentWireEvents += sentEntryFrames; + remaining.shift(); + 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..d7f3ead --- /dev/null +++ b/extension/offline-media-intent.test.mjs @@ -0,0 +1,265 @@ +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, actionTimestamp: 100, 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('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([{ event: EVENTS.EPISODE_READY, data: { title: 'S01E02' } }]); + expect(result.hasPendingLocalIntent).toBe(false); + }); + + 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 }), 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]); + }); + + 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(1); + expect(sent).toEqual([{ event: EVENTS.EPISODE_READY, data: { seq: 3 } }]); + }); + + 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/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index a8ca1c3..0efebb7 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -5,6 +5,11 @@ 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'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); @@ -70,6 +75,71 @@ 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']); + 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'); + close(); + resetConnectionRate(); + // --- Capabilities: ROOM_DATA advertises server features for client detection --- const capClient = await c(); s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' }); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index a2b75cd..7168c43 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 @@ -63,6 +71,61 @@ async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) }, { tabId, mediaState })); } +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) => { + const timeout = setTimeout(() => reject(new Error('legacy relay connection timed out')), 5000); + socket.once('open', () => { + clearTimeout(timeout); + resolve(); + }); + }); + 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 }), @@ -1047,6 +1110,127 @@ 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 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'); + + 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 withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => { + await chrome.storage.local.set({ serverUrl }); + chrome.alarms.create('keepAlive', { when: Date.now() + 50 }); + }, `ws://127.0.0.1:${port}`)); + + await page.locator('#player').evaluate(video => { + window.__koalaReconnectSeeks = []; + video.addEventListener('seeked', () => window.__koalaReconnectSeeks.push(video.currentTime)); + }); + 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 { lateJoiner?.close(); } catch { /* already closed */ } + await relay.stopServerForTests(); + } +}); + function FRAMED_VIDEO_PAUSED() { return document.querySelector('iframe').contentDocument.querySelector('video').paused; } From a477356d2505ee61554aeb48508126f56607a3dd Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:39:38 +0200 Subject: [PATCH 03/16] fix(sync): harden media state reconnect races --- docs/ARCHITECTURE.md | 10 +- docs/PROTOCOL.md | 16 +- extension/background.js | 380 ++++++++++++------ .../canonical-media-state-background.test.mjs | 6 +- extension/canonical-media-state.js | 56 ++- extension/canonical-media-state.test.mjs | 27 ++ .../offline-media-intent-background.test.mjs | 21 +- extension/offline-media-intent.js | 125 +++++- extension/offline-media-intent.test.mjs | 91 ++++- scripts/test-server-ws.mjs | 90 ++++- server/index.js | 63 ++- tests/e2e/extension.spec.mjs | 5 +- 12 files changed, 697 insertions(+), 193 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3c86ff8..d81f198 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,11 +45,13 @@ the room/revision, respects Host Control solo mode and Episode Lobby, then sends internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path. That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, the 2-second drift tolerance, and programmatic-event suppression. The apply is -one-shot recovery: it creates no action history, notification, command ACK, or -relay media event. +one-shot recovery: 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. -Force Sync remains a two-phase ACK protocol. `PREPARE` is temporary choreography; -the matching `EXECUTE` commits its validated target to canonical state. Per-sender +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. Per-sender `seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. ## 3.2 Offline Media Intent diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 8cf7e42..5b5ccad 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -119,8 +119,10 @@ Only accepted, sanitized room controls update canonical state: - `pause` uses its valid `currentTime`, or freezes an existing effective position. - `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the established playback state. -- `force_sync_prepare` records only temporary coordination state. Its matching - `force_sync_execute` commits the prepared target as playing. +- 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. The latest valid prepare is also the only post-demotion + execute exemption in Host Control mode. `peer_status` heartbeats are observations and never rewrite canonical intent. Per-sender `seq` still orders commands from one sender; canonical `revision` @@ -130,7 +132,8 @@ On join/reconnect, a capable extension applies a valid snapshot once through an extension-internal recovery message. 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 is cleared on leave/switch. Intentional host-only guest +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 @@ -329,10 +332,11 @@ them with the same sanitized relay envelope as other room events, including ### `force_sync_execute` The current extension sends sequence/action metadata but no target; the relay uses -the validated target retained from the matching `force_sync_prepare`. In +the latest validated room target retained from `force_sync_prepare`. 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 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. ## Episode Lobby diff --git a/extension/background.js b/extension/background.js index 24742d5..cd952e4 100644 --- a/extension/background.js +++ b/extension/background.js @@ -80,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; @@ -249,7 +250,7 @@ let pendingHistory = []; let eventQueue = []; let eventQueueVersion = 0; let flushTimer = null; // paces draining of eventQueue after (re)connect -let flushInProgress = false; +let flushInProgress = null; let isNamespaceJoined = false; let awaitingRoomData = false; let pendingRoomDataRoomId = null; @@ -382,9 +383,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(); @@ -399,6 +402,9 @@ function ensureState() { 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', '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 (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); @@ -720,7 +726,8 @@ function resolveServerUrl(settings) { return (settings.serverUrl && settings.useCustomServer) ? settings.serverUrl : OFFICIAL_SERVER_URL; } -function forceDisconnect() { +function forceDisconnect({ preserveEventQueue = false } = {}) { + connectionGeneration++; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -755,15 +762,17 @@ function forceDisconnect() { lastContentHeartbeatAt = null; forceSyncAcks.clear(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } - flushInProgress = false; - eventQueue = []; - eventQueueVersion++; + 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 @@ -1096,6 +1105,8 @@ async function leaveRoomAfterIdleGrace(reason) { async function connect() { if (isConnecting) return; isConnecting = true; + const startingGeneration = connectionGeneration; + let attemptGeneration = startingGeneration; let finalUrl = ''; try { @@ -1104,6 +1115,7 @@ 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}`); @@ -1170,114 +1182,125 @@ 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(); + } + } 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); + }; } 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) { - awaitingRoomData = true; - pendingRoomDataRoomId = 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 - }); - } else { - awaitingRoomData = false; - pendingRoomDataRoomId = null; - 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; - 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'); - 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'); @@ -1517,7 +1540,10 @@ function emit(event, data) { const mustWaitForRoomData = awaitingRoomData && event !== EVENTS.JOIN_ROOM && event !== EVENTS.GET_ROOMS; - if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && !mustWaitForRoomData) { + if (socket && socket.readyState === WebSocket.OPEN + && isNamespaceJoined + && !mustWaitForRoomData + && !flushInProgress) { try { const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); @@ -1606,24 +1632,49 @@ function applyQueuedRoomPolicy(roomId, policy, reason) { * 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 frame again is idempotent and - * preserves the final desired state on the next reconnect. + * 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. */ -async function flushEventQueue() { +async function flushEventQueue(replaySettingsOverride = undefined) { if (flushTimer || flushInProgress || awaitingRoomData) return; if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return; - flushInProgress = true; + 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 the original logical entries too; a - // later idempotent replay is safer than losing the concurrent intent. - const replaySettings = eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null; + // 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; + } + 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']); @@ -1654,14 +1705,19 @@ async function flushEventQueue() { if (result.sentWireEvents > 0) { addLog(`Replayed ${result.sentWireEvents} queued wire event${result.sentWireEvents === 1 ? '' : 's'}`, 'info'); } - if (eventQueue.length > 0 && socket?.readyState === WebSocket.OPEN && isNamespaceJoined) { + 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 { - flushInProgress = false; + if (flushInProgress === flushToken) flushInProgress = null; } } @@ -1739,7 +1795,7 @@ function markCanonicalMediaStateHandled(roomId, revision) { async function tryApplyPendingCanonicalMediaState() { const roomId = currentRoom?.roomId; - const pending = canonicalMediaStateTracker.getPending(roomId); + const pending = canonicalMediaStateTracker.getPendingProjected(roomId); if (!pending || !roomId) return { status: 'none' }; const { mediaState } = pending; @@ -1756,6 +1812,9 @@ async function tryApplyPendingCanonicalMediaState() { 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 sendMessageToContentTab(tabId, { @@ -1763,6 +1822,11 @@ async function tryApplyPendingCanonicalMediaState() { 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' }; @@ -1821,7 +1885,8 @@ async function handleCanonicalRoomData(data, hasPendingLocalIntent) { } // --- 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; @@ -1841,6 +1906,10 @@ async function handleServerEvent(event, data) { } switch (event) { case EVENTS.ROOM_DATA: { + if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) { + addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn'); + return; + } if (currentRoom?.roomId !== data.roomId) { invalidateChatSession(); clearChatActivity(); @@ -1870,23 +1939,40 @@ async function handleServerEvent(event, data) { currentRoom.peers = []; } - // Recover server-tracked active Episode Lobby if present - if (!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) + ); + if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby) { clearEpisodeLobbyState(); addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info'); - } else if (data && data.activeLobby && !episodeLobby) { + } else if (data?.activeLobby) { + const sameLobby = episodeLobby + && episodeLobby.expectedTitle === data.activeLobby.expectedTitle + && episodeLobby.initiatorPeerId === data.activeLobby.initiatorPeerId; + if (!sameLobby && episodeLobbyTimeout) { + clearTimeout(episodeLobbyTimeout); + episodeLobbyTimeout = null; + } episodeLobby = { expectedTitle: data.activeLobby.expectedTitle, initiatorPeerId: data.activeLobby.initiatorPeerId, readyPeers: data.activeLobby.readyPeers, - createdAt: Date.now() + createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt) + ? episodeLobby.createdAt + : Date.now() }; 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({ @@ -1908,6 +1994,15 @@ async function handleServerEvent(event, data) { // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); + if (expectedConnectionGeneration !== connectionGeneration) 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) return; awaitingRoomData = false; pendingRoomDataRoomId = null; @@ -1917,13 +2012,14 @@ async function handleServerEvent(event, data) { const queuePolicy = applyQueuedRoomPolicy(data.roomId, { canControl: !lostRoomAuthority, activeLobby: !!episodeLobby, - desynced: hcmDesynced + desynced: hcmDesynced, + authoritativeLobby: !!data.activeLobby }, lostRoomAuthority ? 'Host Control role changed while offline' : (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy')); await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent); - await flushEventQueue(); + await flushEventQueue(replaySettings); break; } case EVENTS.CONTROL_MODE: @@ -2026,6 +2122,10 @@ async function handleServerEvent(event, data) { 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) { @@ -2087,6 +2187,10 @@ 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; @@ -2228,6 +2332,14 @@ async function handleServerEvent(event, data) { break; case EVENTS.EPISODE_LOBBY: if (data.senderId && data.expectedTitle) { + if (currentRoom) { + currentRoom.activeLobby = { + expectedTitle: data.expectedTitle, + initiatorPeerId: data.senderId, + readyPeers: [data.senderId] + }; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); // If we already have a lobby for this same title, treat as dedup if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) { @@ -2269,9 +2381,17 @@ async function handleServerEvent(event, data) { addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info'); checkEpisodeLobbyCompletion(); } + if (currentRoom?.activeLobby) { + currentRoom.activeLobby.readyPeers = [...episodeLobby.readyPeers]; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } } break; case EVENTS.EPISODE_LOBBY_CANCEL: + if (currentRoom) { + currentRoom.activeLobby = null; + if (storageInitialized) chrome.storage.session.set({ currentRoom }); + } if (episodeLobby) { const title = episodeLobby.expectedTitle; clearEpisodeLobbyState(); @@ -4078,7 +4198,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { 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') { diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index 67024ac..0066af8 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -57,6 +57,10 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(backgroundSource).toContain('tryApplyPendingCanonicalMediaState().catch(() => {})'); expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/); expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4); + const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + expect(apply).toContain('getPendingProjected(roomId)'); + expect(apply).toContain('targetActivationGeneration'); + expect(apply).toContain("return { status: 'stale_target' }"); }); it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => { @@ -67,7 +71,7 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(roomData).toContain('if (hasPendingLocalIntent)'); expect(backgroundSource).toContain('awaitingRoomData = true'); expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'); - expect(backgroundSource).toContain('await flushEventQueue()'); + expect(backgroundSource).toContain('await flushEventQueue(replaySettings)'); }); it('reuses existing seek abstractions, suppression and drift tolerance', () => { diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js index 6993585..2e2f6d1 100644 --- a/extension/canonical-media-state.js +++ b/extension/canonical-media-state.js @@ -35,6 +35,22 @@ export function canonicalMediaStateFromRoomData(roomData) { : { 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; } @@ -60,11 +76,16 @@ export function createCanonicalMediaStateTracker() { 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) { + receive(nextRoomId, value, receivedAt = Date.now()) { adoptRoom(nextRoomId); const mediaState = validateCanonicalMediaState(value); if (!roomId || !mediaState) return { status: 'invalid' }; @@ -74,7 +95,13 @@ export function createCanonicalMediaStateTracker() { return { status: 'duplicate' }; } knownRevision = Math.max(knownRevision, mediaState.revision); - pending = { roomId, mediaState }; + pending = { + roomId, + mediaState, + receivedAt: typeof receivedAt === 'number' && Number.isFinite(receivedAt) + ? receivedAt + : Date.now() + }; return { status: 'pending', mediaState }; }, @@ -84,6 +111,16 @@ export function createCanonicalMediaStateTracker() { : 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) @@ -114,7 +151,14 @@ export function createCanonicalMediaStateTracker() { && restoredPending && restoredPending.revision >= appliedRevision && restoredPending.revision >= knownRevision) { - pending = { roomId, mediaState: restoredPending }; + 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; @@ -125,7 +169,11 @@ export function createCanonicalMediaStateTracker() { roomId, knownRevision, appliedRevision, - pending: pending ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null + 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 index de556a6..a1fe540 100644 --- a/extension/canonical-media-state.test.mjs +++ b/extension/canonical-media-state.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { canonicalMediaStateFromRoomData, createCanonicalMediaStateTracker, + projectCanonicalMediaState, validateCanonicalMediaState } from './canonical-media-state.js'; @@ -81,6 +82,15 @@ describe('ROOM_DATA capability compatibility', () => { }); 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'); @@ -116,6 +126,23 @@ describe('canonical media state tracker', () => { 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)); diff --git a/extension/offline-media-intent-background.test.mjs b/extension/offline-media-intent-background.test.mjs index 882e848..71c5abf 100644 --- a/extension/offline-media-intent-background.test.mjs +++ b/extension/offline-media-intent-background.test.mjs @@ -16,6 +16,7 @@ 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'); @@ -27,6 +28,7 @@ describe('offline media intent background integration', () => { 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', () => { @@ -36,10 +38,11 @@ describe('offline media intent background integration', () => { expect(roomData.indexOf('applyQueuedRoomPolicy(data.roomId')) .toBeLessThan(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)')); expect(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)')) - .toBeLessThan(roomData.indexOf('flushEventQueue()')); + .toBeLessThan(roomData.indexOf('flushEventQueue(replaySettings)')); expect(roomData).toContain('activeLobby: !!episodeLobby'); expect(roomData).toContain('desynced: hcmDesynced'); - expect(roomData).toContain('if (!data?.activeLobby && episodeLobby)'); + expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)'); + expect(roomData).toContain('authoritativeLobby: !!data.activeLobby'); }); it('clears queued room intent on failed join, leave and room switch paths', () => { @@ -51,6 +54,11 @@ describe('offline media intent background integration', () => { backgroundSource.indexOf("message.type === 'CLEAR_LOGS'") ); expect(leaveHandler).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', () => { @@ -61,9 +69,18 @@ describe('offline media intent background integration', () => { 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'); diff --git a/extension/offline-media-intent.js b/extension/offline-media-intent.js index 40ed8be..0828a36 100644 --- a/extension/offline-media-intent.js +++ b/extension/offline-media-intent.js @@ -4,6 +4,7 @@ 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([ @@ -49,6 +50,22 @@ 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++; } @@ -127,8 +144,16 @@ export function enqueueQueuedEvent(queue, event, data, { 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({ event, data }); + 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; @@ -177,23 +202,71 @@ function normalizeIntentEntry(entry, roomId) { }; } +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)) { - const intentEntry = normalizeIntentEntry(entry, roomId); - if (intentEntry) normalized.push(intentEntry); + let intentEntry = normalizeIntentEntry(entry, roomId); + if (intentEntry) { + intentEntry = repairIntentSequences(intentEntry, maximumSequence); + normalized.push(intentEntry); + 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, entry.data, { roomId, maxEntries }).queue; + normalized = enqueueQueuedEvent(normalized, entry.event, data, { roomId, maxEntries }).queue; } else { - normalized.push({ event: entry.event, data: entry.data }); + normalized.push({ kind: 'event', roomId, event: entry.event, data }); } normalized = trimQueue(normalized, maxEntries).queue; + maximumSequence = Math.max(maximumSequence, maxQueuedSequence(normalized)); } return normalized; } @@ -229,10 +302,10 @@ export function reserveLatestMediaIntentSequence(queue, roomId, nextSequence) { return { queue: next, reserved: true }; } -function frameData(intent, seq) { +function frameData(intent, seq, includeActionTimestamp = true) { const data = {}; if (seq !== null) data.seq = seq; - if (intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp; + if (includeActionTimestamp && intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp; if (intent.mediaTitle !== null) data.mediaTitle = intent.mediaTitle; return data; } @@ -259,11 +332,11 @@ export function materializeMediaIntent(entry) { } if (playbackState === null || currentTime === null) return []; - // Both materialized frames carry the latest genuine action timestamp: it is - // correlation metadata for existing ACK/activity paths, not scheduled wall - // time. A previous-format single PLAY/PAUSE has only one reserved sequence. Keep + // 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. + // 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 [{ @@ -279,11 +352,26 @@ export function materializeMediaIntent(entry) { const seekFrame = { event: EVENTS.SEEK, - data: { ...frameData(intent, intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq), currentTime, targetTime: currentTime } + 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), currentTime } + data: { + ...frameData( + intent, + intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq, + intent.latestEvent !== EVENTS.SEEK + ), + currentTime + } }; return intent.latestEvent === EVENTS.SEEK ? [stateFrame, seekFrame] @@ -320,13 +408,18 @@ export function reconcileQueuedRoomIntent(queue, { roomId, canControl = true, activeLobby = false, - desynced = false + desynced = false, + authoritativeLobby = false } = {}) { const source = Array.isArray(queue) ? queue : []; const blockedEvents = !canControl ? HOST_GATED_EVENTS - : (activeLobby || desynced ? FORCE_SYNC_EVENTS : null); + : 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; } @@ -366,7 +459,7 @@ export async function drainQueuedBatch(queue, { while (remaining.length > 0) { const entry = remaining[0]; - if (isQueuedMediaIntent(entry) && entry.roomId !== roomId) { + if (entry?.roomId && entry.roomId !== roomId) { remaining.shift(); droppedStaleIntents++; continue; diff --git a/extension/offline-media-intent.test.mjs b/extension/offline-media-intent.test.mjs index d7f3ead..df59459 100644 --- a/extension/offline-media-intent.test.mjs +++ b/extension/offline-media-intent.test.mjs @@ -34,7 +34,7 @@ describe('offline media intent coalescing', () => { queue = reserve(queue, 6); expect(queue).toHaveLength(1); expect(materializeMediaIntent(queue[0])).toEqual([ - { event: EVENTS.SEEK, data: { seq: 5, actionTimestamp: 100, currentTime: 10, targetTime: 10 } }, + { event: EVENTS.SEEK, data: { seq: 5, currentTime: 10, targetTime: 10 } }, { event: EVENTS.PLAY, data: { seq: 6, actionTimestamp: 100, currentTime: 10 } } ]); }); @@ -159,6 +159,17 @@ describe('offline media intent coalescing', () => { expect(maxQueuedSequence(restored)).toBe(4); }); + 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); @@ -180,10 +191,28 @@ describe('offline media intent coalescing', () => { 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([{ event: EVENTS.EPISODE_READY, data: { title: 'S01E02' } }]); + 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 }); @@ -229,7 +258,7 @@ describe('offline media intent drain', () => { }); it('retains the whole logical intent after a partial send failure', async () => { - const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5 }), 6); + const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5, actionTimestamp: 500 }), 6); let calls = 0; const result = await drainQueuedBatch(queue, { roomId, @@ -240,6 +269,8 @@ describe('offline media intent drain', () => { 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 () => { @@ -251,8 +282,58 @@ describe('offline media intent drain', () => { maxWireEvents: 10, sendFrame: async frame => { sent.push(frame); return true; } }); - expect(result.droppedStaleIntents).toBe(1); - expect(sent).toEqual([{ event: EVENTS.EPISODE_READY, data: { seq: 3 } }]); + 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('reports logical and actual-wire queue sizes separately', () => { diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 0efebb7..4d92c19 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -371,6 +371,35 @@ try { 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'); + + 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); @@ -675,6 +704,63 @@ 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 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. + 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(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(); @@ -755,12 +841,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/index.js b/server/index.js index 48500de..b1bd16c 100644 --- a/server/index.js +++ b/server/index.js @@ -286,10 +286,10 @@ function removePeerFromRoom(socketId, roomId, reason) { 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.forceSyncTarget?.initiatorPeerId === peerId) room.forceSyncTarget = null; if (room.hostPeerId === peerId) { // Owner left → reassign owner + fall back to 'everyone' so the room is // never stuck locked, and reset the controller set to just the new owner. @@ -596,15 +596,10 @@ 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; - } + // 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 isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator; if (!isOwnForceSyncExecute && @@ -614,11 +609,6 @@ 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; @@ -661,6 +651,23 @@ io.on('connection', (socket) => { // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); + if (eventName === EVENTS.FORCE_SYNC_EXECUTE && !room.forceSyncTarget) { + log('ROOM', `Dropped force_sync_execute without a prepared target from ${mapping.peerId}`); + room.forceSyncInitiator = 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. @@ -670,12 +677,25 @@ io.on('connection', (socket) => { senderPlaybackState: existing.playbackState }); if (eventName === EVENTS.FORCE_SYNC_PREPARE) { - room.forceSyncTarget = Number.isFinite(relayPayload.targetTime) - ? { initiatorPeerId: mapping.peerId, targetTime: relayPayload.targetTime } - : null; + // 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.forceSyncTarget = { + initiatorPeerId: mapping.peerId, + targetTime: relayPayload.targetTime + }; } else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { const forceSyncTarget = room.forceSyncTarget; - if (forceSyncTarget?.initiatorPeerId === mapping.peerId) { + if (forceSyncTarget) { commitForceSyncMediaState( room, forceSyncTarget.targetTime, @@ -683,6 +703,7 @@ io.on('connection', (socket) => { mediaStateNow ); } + room.forceSyncInitiator = null; room.forceSyncTarget = null; } diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 7168c43..835aaa2 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -1182,10 +1182,11 @@ test('coalesces persisted offline media intent before canonical reconnect recove expect(restoredQueue.queuedLogicalEvents).toBeGreaterThanOrEqual(1); expect(restoredQueue.queuedWireEvents).toBeGreaterThanOrEqual(2); - await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => { + const retryResult = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => { await chrome.storage.local.set({ serverUrl }); - chrome.alarms.create('keepAlive', { when: Date.now() + 50 }); + return chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' }); }, `ws://127.0.0.1:${port}`)); + expect(retryResult).toMatchObject({ status: 'ok' }); await page.locator('#player').evaluate(video => { window.__koalaReconnectSeeks = []; From def5e1a380802b202461877c011db234364458c5 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:21:18 +0200 Subject: [PATCH 04/16] fix(sync): harden canonical recovery --- docs/ARCHITECTURE.md | 18 ++-- docs/PROTOCOL.md | 28 ++++-- extension/background.js | 84 ++++++++++++++++- .../canonical-media-state-background.test.mjs | 29 ++++-- extension/content.js | 91 ++++++++++++++++--- scripts/test-server-ws.mjs | 45 ++++++++- server/index.js | 41 ++++++++- shared/README.md | 1 + shared/constants.js | 3 + tests/e2e/extension.spec.mjs | 69 ++++++++++++++ 10 files changed, 363 insertions(+), 46 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 031a327..2366d1f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,17 +44,23 @@ the optional `media-state-v1` capability. A joining/reconnecting client validate 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. That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, -the 2-second drift tolerance, and programmatic-event suppression. The apply is -one-shot recovery: 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. +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. 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 shared Force Sync timeout. The +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 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. +`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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 4cbbd0e..ee49dae 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -121,21 +121,28 @@ 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_TIMEOUT` expires. 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 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. `peer_status` heartbeats are observations and never rewrite canonical intent. -Per-sender `seq` still orders commands from one sender; canonical `revision` -orders server-accepted room transitions. Neither replaces the other. +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 applies a valid snapshot once through an -extension-internal recovery message. Existing seek/page-API and native-event +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. +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. @@ -341,7 +348,8 @@ 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. +no exemption. The retained target expires after `FORCE_SYNC_TARGET_TTL`, which +includes a relay grace period beyond the client's ACK timeout. ## Episode Lobby diff --git a/extension/background.js b/extension/background.js index 44bde99..f4bcf1e 100644 --- a/extension/background.js +++ b/extension/background.js @@ -258,6 +258,18 @@ 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' @@ -285,7 +297,52 @@ function persistCanonicalMediaRecovery() { }).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(); canonicalMediaStateTracker.clear(); persistCanonicalMediaRecovery(); } @@ -728,6 +785,7 @@ function resolveServerUrl(settings) { function forceDisconnect({ preserveEventQueue = false } = {}) { connectionGeneration++; + resetCanonicalMediaRecoveryRetries(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -945,7 +1003,7 @@ function adoptReportingFrame(sender) { currentTargetHasVideo = true; stopMediaDiscoveryPoll(); chrome.storage.session.set({ currentTargetHasVideo }).catch(() => {}); - tryApplyPendingCanonicalMediaState().catch(() => {}); + requestCanonicalMediaRecoveryAttempt(); return true; } @@ -960,7 +1018,7 @@ function adoptReportingFrame(sender) { currentTargetDocumentId, currentTargetHasVideo }).catch(() => {}); - tryApplyPendingCanonicalMediaState().catch(() => {}); + requestCanonicalMediaRecoveryAttempt(); return true; } @@ -1817,11 +1875,12 @@ function stopPing() { function markCanonicalMediaStateHandled(roomId, revision) { if (!canonicalMediaStateTracker.markHandled(roomId, revision)) return false; + resetCanonicalMediaRecoveryRetries(); persistCanonicalMediaRecovery(); return true; } -async function tryApplyPendingCanonicalMediaState() { +async function performPendingCanonicalMediaStateApply() { const roomId = currentRoom?.roomId; const pending = canonicalMediaStateTracker.getPendingProjected(roomId); if (!pending || !roomId) return { status: 'none' }; @@ -1876,6 +1935,22 @@ async function tryApplyPendingCanonicalMediaState() { } } +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); @@ -1898,6 +1973,7 @@ async function handleCanonicalRoomData(data, hasPendingLocalIntent) { } if (received.status !== 'pending') return; + resetCanonicalMediaRecoveryRetries(); persistCanonicalMediaRecovery(); addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info'); @@ -4896,6 +4972,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { updateBadgeStatus(); } + requestCanonicalMediaRecoveryAttempt(); markRoomUseful(); getSettings().then(settings => { const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle); @@ -5222,6 +5299,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 index 699751d..2385d80 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -37,7 +37,7 @@ describe('canonical ROOM_DATA recovery contract', () => { }); it('uses a dedicated internal apply message without action/history/ACK machinery', () => { - const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'"); expect(apply).not.toContain('routeToContent('); expect(apply).not.toContain('emit('); @@ -55,17 +55,23 @@ describe('canonical ROOM_DATA recovery contract', () => { it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => { expect(backgroundSource).toContain("'canonicalMediaRecovery'"); expect(backgroundSource).toContain('canonicalMediaStateTracker.restore('); - expect(backgroundSource).toContain('tryApplyPendingCanonicalMediaState().catch(() => {})'); + 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, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + 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'); }); it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => { - const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData'); + const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent'); expect(apply).toContain('if (hcmDesynced)'); expect(apply).toContain('if (episodeLobby)'); @@ -75,13 +81,20 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(backgroundSource).toContain('await flushEventQueue(replaySettings)'); }); - it('reuses existing seek abstractions, suppression and drift tolerance', () => { + 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('seekVideo(video, mediaState.currentTime)'); - expect(apply).toContain('tryMediaAction(EVENTS.PAUSE)'); - expect(apply).toContain('tryMediaAction(EVENTS.PLAY)'); + 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)'); + expect(apply.indexOf("status: 'applied'")) + .toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)')); expect(apply).toContain('if (hcmDesynced)'); + 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).then(sendResponse)'); }); }); diff --git a/extension/content.js b/extension/content.js index e07b636..198fa8a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1185,13 +1185,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 }; } @@ -1199,28 +1199,71 @@ 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 applyCanonicalMediaState(mediaState) { + function pollCanonicalMediaState(mediaState, startedAt, timeoutMs = 2500) { + return new Promise((resolve) => { + const interval = 100; + const finishAt = Date.now() + timeoutMs; + const timer = setInterval(() => { + if (destroyed) { + 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) { if (!mediaState || typeof mediaState !== 'object' || !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1 || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused') @@ -1238,23 +1281,40 @@ 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(); 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) { - tryMediaAction(EVENTS.PAUSE); + if (!await tryMediaAction(EVENTS.PAUSE)) { + return { status: 'apply_failed', reason: 'pause_action_failed' }; + } } if (shouldSeek) { _setSuppress('seek'); - seekVideo(video, mediaState.currentTime); + if (!await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime })) { + return { status: 'apply_failed', reason: 'seek_action_failed' }; + } } if (mediaState.playbackState === 'playing' && video.paused) { - tryMediaAction(EVENTS.PLAY); + if (!await tryMediaAction(EVENTS.PLAY)) { + return { status: 'apply_failed', reason: 'play_action_failed' }; + } + } + const verified = await pollCanonicalMediaState(mediaState, startedAt); + 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, sought: shouldSeek }; + 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' }; @@ -1362,7 +1422,10 @@ } if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') { - sendResponse(applyCanonicalMediaState(message.mediaState)); + applyCanonicalMediaState(message.mediaState).then(sendResponse).catch(error => { + reportLog(`Canonical media state apply failed: ${error.message}`, 'warn'); + sendResponse({ status: 'apply_failed', reason: 'unexpected_error' }); + }); return true; } diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 8cad6d4..ea40de8 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_TIMEOUT } from '../shared/constants.js'; +import { FORCE_SYNC_TARGET_TTL, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); @@ -404,10 +404,20 @@ 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. + 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'); + 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_TIMEOUT - 1; + mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_TTL - 1; msa._m.length = msb._m.length = 0; s(msa, 'force_sync_execute', {}); let expiredExecuteDropped = false; @@ -467,6 +477,37 @@ try { 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.currentTime, sequencedBaseline.currentTime, + 'late joiner receives the same state accepted by live receivers'); + const validationBaseline = { ...mod.rooms.get(msgateRid).mediaState }; for (const invalidPayload of [{ targetTime: null }, { targetTime: '50' }, { targetTime: {} }, {}]) { s(msgHost, 'seek', invalidPayload); diff --git a/server/index.js b/server/index.js index 31ab60a..1541765 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_TIMEOUT, MAX_MEDIA_TIME } from '../shared/constants.js'; +import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_TTL, MAX_MEDIA_TIME } from '../shared/constants.js'; import { createChatEnvelope } from './chat.js'; import { commitForceSyncMediaState, @@ -176,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). @@ -615,6 +627,29 @@ io.on('connection', (socket) => { 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, @@ -632,7 +667,7 @@ 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), + 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), @@ -656,7 +691,7 @@ io.on('connection', (socket) => { const forceSyncTarget = room.forceSyncTarget; const targetExpired = forceSyncTarget && (!Number.isFinite(forceSyncTarget.preparedAt) - || mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TIMEOUT); + || 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; diff --git a/shared/README.md b/shared/README.md index f00c52e..a2b851f 100644 --- a/shared/README.md +++ b/shared/README.md @@ -64,6 +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. - `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 74b6c72..69e3fb2 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -99,4 +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; 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 7b09594..bf0a60f 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -388,6 +388,75 @@ test('applies canonical recovery without echoing media commands or activity', as expect(historyAfter).toEqual(historyBefore); }); +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); + await selectTargetTab(context, extensionId, url); + await page.locator('#player').evaluate(video => { + const nativePlay = video.play.bind(video); + window.__koalaCanonicalPlayAttempts = 0; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + window.__koalaCanonicalPlayAttempts++; + if (window.__koalaCanonicalPlayAttempts === 1) { + return Promise.reject(new DOMException('audit autoplay rejection', 'NotAllowedError')); + } + return nativePlay(); + } + }); + }); + 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 expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) + .toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 }); + await expect.poll(() => page.evaluate(() => window.__koalaCanonicalPlayAttempts)) + .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('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { const first = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage(); From e9ec19b6419b88c312fb35413be261465eee72de Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:23:54 +0200 Subject: [PATCH 05/16] test(sync): account for playing snapshot projection --- scripts/test-server-ws.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index ea40de8..f85d2e5 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -505,8 +505,10 @@ try { const msgLateJoiner = await c(); const msgLateRoom = await j(msgLateJoiner, msgateRid, 'msg-late'); assert.equal(msgLateRoom.mediaState.revision, sequencedBaseline.revision); - assert.equal(msgLateRoom.mediaState.currentTime, sequencedBaseline.currentTime, - 'late joiner receives the same state accepted by live receivers'); + 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: {} }, {}]) { From 8572d3694e8795c5575fe1be30dda2517272432d Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:34:56 +0200 Subject: [PATCH 06/16] test(sync): fix canonical recovery harness --- scripts/test-server-ws.mjs | 1 + tests/e2e/extension.spec.mjs | 38 +++++++++++++++++++++++------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index f85d2e5..7515e14 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -521,6 +521,7 @@ try { 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 --- diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index bf0a60f..9753ec2 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -406,21 +406,30 @@ test('recovers relay ROOM_DATA through background retries into the packed player const page = await context.newPage(); await page.goto(url); await page.waitForFunction(() => window.__fixtureReady === true); - await selectTargetTab(context, extensionId, url); - await page.locator('#player').evaluate(video => { - const nativePlay = video.play.bind(video); - window.__koalaCanonicalPlayAttempts = 0; - Object.defineProperty(video, 'play', { - configurable: true, - value: () => { - window.__koalaCanonicalPlayAttempts++; - if (window.__koalaCanonicalPlayAttempts === 1) { - return Promise.reject(new DOMException('audit autoplay rejection', 'NotAllowedError')); - } - return nativePlay(); + 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(new DOMException('audit autoplay rejection', 'NotAllowedError')); + } + return nativePlay(); + } + }); } }); - }); + }, tabId)); legacy.messages.length = 0; await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => { @@ -436,7 +445,8 @@ test('recovers relay ROOM_DATA through background retries into the packed player await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) .toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 }); - await expect.poll(() => page.evaluate(() => window.__koalaCanonicalPlayAttempts)) + 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, From 213a6393144736d77eac87d7ee9b9a8f4ea9649c Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:43:12 +0200 Subject: [PATCH 07/16] test(sync): allow repeated relay e2e startup --- server/index.js | 3 ++- tests/e2e/extension.spec.mjs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server/index.js b/server/index.js index 1541765..f6bfddc 100644 --- a/server/index.js +++ b/server/index.js @@ -1137,7 +1137,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/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 9753ec2..92f0e34 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -422,7 +422,10 @@ test('recovers relay ROOM_DATA through background retries into the packed player const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1; video.dataset.koalaCanonicalPlayAttempts = String(attempts); if (attempts === 1) { - return Promise.reject(new DOMException('audit autoplay rejection', 'NotAllowedError')); + return Promise.reject(Object.assign( + new Error('audit autoplay rejection'), + { name: 'NotAllowedError' } + )); } return nativePlay(); } From 0d23859a9f7a58e9345d2938bb7267635793e0bc Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:24:37 +0200 Subject: [PATCH 08/16] fix(sync): close canonical recovery merge blockers --- docs/ARCHITECTURE.md | 18 ++- docs/PROTOCOL.md | 32 +++-- extension/background.js | 66 ++++++++- .../canonical-media-state-background.test.mjs | 17 +++ extension/canonical-media-state.js | 8 ++ extension/canonical-media-state.test.mjs | 8 ++ extension/content.js | 10 +- scripts/test-server-ws.mjs | 90 +++++++++--- server/index.js | 105 +++++++++----- server/media-state.js | 32 +++-- server/media-state.test.mjs | 41 +++++- shared/README.md | 2 +- shared/constants.js | 6 +- tests/e2e/extension.spec.mjs | 136 ++++++++++++++++++ 14 files changed, 484 insertions(+), 87 deletions(-) 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(); From cba2731c7542009a026e811b25324057c142b303 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:30:52 +0200 Subject: [PATCH 09/16] test(sync): preserve canonical race coverage --- tests/e2e/extension.spec.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 45c8c22..08e009b 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -595,12 +595,12 @@ test('keeps canonical media state current while the capable extension is solo', await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) .toMatchObject({ status: 'connected', roomId }); - expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 8 })) + expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 8 })) .toMatchObject({ status: 'ok' }); - expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 8 })) + expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 9 })) .toMatchObject({ status: 'ok' }); await expect.poll(() => relay.rooms.get(roomId)?.mediaState) - .toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 8 }); + .toMatchObject({ revision: 2, playbackState: 'paused', currentTime: 9 }); } finally { await relay.stopServerForTests(); } @@ -1345,6 +1345,7 @@ test('coalesces persisted offline media intent before canonical reconnect recove 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'); @@ -1352,6 +1353,8 @@ test('coalesces persisted offline media intent before canonical reconnect recove 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(); @@ -1458,6 +1461,7 @@ test('coalesces persisted offline media intent before canonical reconnect recove } 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(); } From 76a123fac976af69e3bc3c928e2a64ee620c317d Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:39:31 +0200 Subject: [PATCH 10/16] test(sync): isolate relay recovery setup --- tests/e2e/extension.spec.mjs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 08e009b..3bb87d4 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -18,7 +18,9 @@ const NodeWebSocket = require('ws'); /** 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; @@ -62,6 +64,20 @@ 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, { @@ -469,8 +485,7 @@ test('recovers relay ROOM_DATA through background retries into the packed player username: 'canonical-receiver' })); - await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) - .toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 }); + await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 }); await expect.poll(() => page.locator('#player').evaluate(video => Number(video.dataset.koalaCanonicalPlayAttempts || '0'))) .toBeGreaterThanOrEqual(2); @@ -544,6 +559,7 @@ test('newer mixed-version playback supersedes an in-flight canonical recovery', 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); @@ -592,8 +608,7 @@ test('keeps canonical media state current while the capable extension is solo', password: '', username: 'current-solo' })); - await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' })) - .toMatchObject({ status: 'connected', roomId }); + await expectConnectedRoom(context, extensionId, roomId); expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 8 })) .toMatchObject({ status: 'ok' }); From 5854c106b8368267b082bc53efd78ad6d42582e6 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:08:00 +0200 Subject: [PATCH 11/16] test(e2e): close fixture server reliably --- tests/e2e/README.md | 6 ++ tests/e2e/fixture-server.mjs | 120 +++++++++++++++++++------------- tests/e2e/global-setup.mjs | 35 ++++++++++ tests/e2e/playwright.config.mjs | 10 +-- 4 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 tests/e2e/global-setup.mjs 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/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' - } + ] }); From e5110504c18378c86daed83dbb576c93abe15515 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:41:54 +0200 Subject: [PATCH 12/16] fix(sync): cancel superseded canonical apply --- extension/background.js | 7 ++ .../canonical-media-state-background.test.mjs | 14 ++- extension/content.js | 116 +++++++++++++++++- .../offline-media-intent-background.test.mjs | 1 + tests/e2e/extension.spec.mjs | 61 +++++++++ 5 files changed, 189 insertions(+), 10 deletions(-) diff --git a/extension/background.js b/extension/background.js index 736b2ad..f6cdcec 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1889,6 +1889,13 @@ function supersedeCanonicalMediaRecovery(reason) { 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 + }).catch(() => {}); addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info'); return true; } diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index 05f82b4..f02c7b2 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -51,7 +51,8 @@ describe('canonical ROOM_DATA recovery contract', () => { 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('applyCanonicalMediaState(message.mediaState)'); + 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'); }); @@ -89,6 +90,7 @@ describe('canonical ROOM_DATA recovery contract', () => { 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(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)'); @@ -103,15 +105,19 @@ describe('canonical ROOM_DATA recovery contract', () => { 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)'); + expect(apply).toContain('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)'); expect(apply.indexOf("status: 'applied'")) - .toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)')); + .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).then(sendResponse)'); + .toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)'); + expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'"); + expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)'); }); }); diff --git a/extension/content.js b/extension/content.js index 4679318..58c443a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -91,6 +91,8 @@ // 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 = {}; + let canonicalMediaApplyGeneration = 0; + let canonicalSupersedingLocalState = null; function _setSuppress(state) { if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]); @@ -1229,12 +1231,61 @@ } } - function pollCanonicalMediaState(mediaState, startedAt, timeoutMs = 2500) { + function beginCanonicalMediaApply() { + canonicalMediaApplyGeneration++; + canonicalSupersedingLocalState = null; + return canonicalMediaApplyGeneration; + } + + function cancelCanonicalMediaApply(action = null, video = null) { + canonicalMediaApplyGeneration++; + if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) { + canonicalSupersedingLocalState = { + playbackState: action === EVENTS.PLAY + ? 'playing' + : (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')), + currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null + }; + } + return canonicalMediaApplyGeneration; + } + + function isCanonicalMediaApplyCurrent(generation) { + return !destroyed && generation === canonicalMediaApplyGeneration; + } + + async function restoreSupersedingLocalState(video) { + const state = canonicalSupersedingLocalState; + if (!state || !video) 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'); + try { + await video.play(); + } catch (error) { + _clearSuppress('playing'); + reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn'); + } + } + } + + function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) { return new Promise((resolve) => { const interval = 100; const finishAt = Date.now() + timeoutMs; const timer = setInterval(() => { - if (destroyed) { + if (!isCanonicalMediaApplyCurrent(applyGeneration)) { clearInterval(timer); seekPollTimers.delete(timer); resolve(null); @@ -1263,7 +1314,7 @@ }); } - async function applyCanonicalMediaState(mediaState) { + async function applyCanonicalMediaState(mediaState, applyGeneration) { if (!mediaState || typeof mediaState !== 'object' || !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1 || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused') @@ -1276,6 +1327,7 @@ && 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)) { @@ -1290,6 +1342,10 @@ 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 @@ -1299,19 +1355,25 @@ if (!await tryMediaAction(EVENTS.PAUSE)) { return { status: 'apply_failed', reason: 'pause_action_failed' }; } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); if (shouldSeek) { _setSuppress('seek'); if (!await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime })) { return { status: 'apply_failed', reason: 'seek_action_failed' }; } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); if (mediaState.playbackState === 'playing' && video.paused) { if (!await tryMediaAction(EVENTS.PLAY)) { return { status: 'apply_failed', reason: 'play_action_failed' }; } + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); } - const verified = await pollCanonicalMediaState(mediaState, startedAt); + 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' }; @@ -1429,8 +1491,15 @@ return true; } + if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') { + cancelCanonicalMediaApply(); + sendResponse({ status: 'cancelled' }); + return true; + } + if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') { - applyCanonicalMediaState(message.mediaState).then(sendResponse).catch(error => { + 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' }); }); @@ -1440,6 +1509,9 @@ if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; + if ([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE].includes(action)) { + cancelCanonicalMediaApply(); + } // Host Control Mode: while watching on our own (desynced), don't apply // host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's @@ -1783,6 +1855,10 @@ 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. if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return; @@ -1936,6 +2012,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); @@ -1953,6 +2036,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; @@ -1967,6 +2062,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); @@ -1999,9 +2095,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); diff --git a/extension/offline-media-intent-background.test.mjs b/extension/offline-media-intent-background.test.mjs index 3e4a568..99154d8 100644 --- a/extension/offline-media-intent-background.test.mjs +++ b/extension/offline-media-intent-background.test.mjs @@ -9,6 +9,7 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js' 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); } diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 3bb87d4..4f916c7 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -427,6 +427,67 @@ test('applies canonical recovery without echoing media commands or activity', as 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 => setTimeout(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('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'); From 981776d374ccc0f6adde573243f877eb68861715 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:56:48 +0200 Subject: [PATCH 13/16] fix(sync): close episode lobby without ready race --- extension/background.js | 38 +++++++++++---- extension/content.js | 15 +++--- extension/episode-lobby-background.test.mjs | 51 +++++++++++++++++++++ tests/e2e/extension.spec.mjs | 10 +++- 4 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 extension/episode-lobby-background.test.mjs diff --git a/extension/background.js b/extension/background.js index f6cdcec..4ac260a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2534,18 +2534,23 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } 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; + 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 = [...episodeLobby.readyPeers]; + currentRoom.activeLobby.readyPeers = readyPeers; if (storageInitialized) chrome.storage.session.set({ currentRoom }); } + if (readyAdded) checkEpisodeLobbyCompletion(); } break; case EVENTS.EPISODE_LOBBY_CANCEL: @@ -2698,6 +2703,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'); @@ -5259,15 +5268,24 @@ 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; + if (lobby && message.payload && sameEpisode(message.payload.title, lobby.expectedTitle)) { + if (!lobby.readyPeers.includes(peerId)) { const settings = await getSettings(); + if (episodeLobby !== lobby) { + 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'); + addLog(`Local episode ready: "${readyTitle || lobby.expectedTitle}"`, 'success'); checkEpisodeLobbyCompletion(); } } diff --git a/extension/content.js b/extension/content.js index 58c443a..cb5f64d 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1352,25 +1352,28 @@ // starting. Both paths reuse the same site/page-API abstractions and // native-event suppression as ordinary remote commands. if (mediaState.playbackState === 'paused' && !video.paused) { - if (!await tryMediaAction(EVENTS.PAUSE)) { + 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 (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); if (shouldSeek) { _setSuppress('seek'); - if (!await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime })) { + 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 (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); if (mediaState.playbackState === 'playing' && video.paused) { - if (!await tryMediaAction(EVENTS.PLAY)) { + const playApplied = await tryMediaAction(EVENTS.PLAY); + if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); + if (!playApplied) { return { status: 'apply_failed', reason: 'play_action_failed' }; } - if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); } const verified = await pollCanonicalMediaState(mediaState, startedAt, applyGeneration); if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded(); diff --git a/extension/episode-lobby-background.test.mjs b/extension/episode-lobby-background.test.mjs new file mode 100644 index 0000000..c8e3035 --- /dev/null +++ b/extension/episode-lobby-background.test.mjs @@ -0,0 +1,51 @@ +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 (episodeLobby !== lobby)'); + const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)'); + + expect(handler).toContain('const lobby = episodeLobby'); + 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 })'); + }); +}); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 4f916c7..6f0bd01 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -451,7 +451,15 @@ test('local media input cancels an in-flight canonical recovery', async ({ conte value: () => { const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; video.dataset.koalaDelayedPlayAttempts = String(attempts); - return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400))); + 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))); } }); } From 59788e0749afc6ed3723463b7ad77a5571286c81 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:10:33 +0200 Subject: [PATCH 14/16] fix(sync): let newer commands own recovery state --- extension/content.js | 16 +++++--- tests/e2e/extension.spec.mjs | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/extension/content.js b/extension/content.js index cb5f64d..048defe 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1237,7 +1237,7 @@ return canonicalMediaApplyGeneration; } - function cancelCanonicalMediaApply(action = null, video = null) { + function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) { canonicalMediaApplyGeneration++; if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) { canonicalSupersedingLocalState = { @@ -1246,6 +1246,8 @@ : (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')), currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null }; + } else if (!preserveLocalState) { + canonicalSupersedingLocalState = null; } return canonicalMediaApplyGeneration; } @@ -1495,7 +1497,10 @@ } if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') { - cancelCanonicalMediaApply(); + const preserveLocalState = message.reason === `local ${EVENTS.PLAY}` + || message.reason === `local ${EVENTS.PAUSE}` + || message.reason === `local ${EVENTS.SEEK}`; + cancelCanonicalMediaApply(null, null, preserveLocalState); sendResponse({ status: 'cancelled' }); return true; } @@ -1512,9 +1517,6 @@ if (message.type === 'SERVER_COMMAND') { const { action, payload } = message; let actionCompleted = false; - if ([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE].includes(action)) { - cancelCanonicalMediaApply(); - } // Host Control Mode: while watching on our own (desynced), don't apply // host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's @@ -1548,6 +1550,10 @@ return; } } + + if (syncActions.includes(action)) { + cancelCanonicalMediaApply(); + } if (action === EVENTS.PLAY) { tryMediaAction(EVENTS.PLAY); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 6f0bd01..75331ab 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -87,6 +87,18 @@ async function applyCanonicalMediaState(context, extensionId, tabId, 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}` @@ -496,6 +508,68 @@ test('local media input cancels an in-flight canonical recovery', async ({ conte 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('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'); From f615e4d23683bd2747881112daa5eacd71279a5c Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:24:03 +0200 Subject: [PATCH 15/16] fix(release): close remaining PR review gaps --- extension/background.js | 6 +++- extension/target-tab-lifecycle.test.mjs | 8 ++++- scripts/prepare-release.mjs | 44 ++++++++++++++++--------- scripts/prepare-release.test.mjs | 12 +++++++ scripts/test-server-ws.mjs | 13 +++++--- server/index.js | 12 ++++--- 6 files changed, 69 insertions(+), 26 deletions(-) diff --git a/extension/background.js b/extension/background.js index 4ac260a..a62a7c7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2172,6 +2172,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con 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; @@ -2239,7 +2242,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con 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 @@ -2274,6 +2277,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message }; await broadcastJoinStatus(errStatusMsg); break; + } case EVENTS.PLAY: case EVENTS.PAUSE: case EVENTS.SEEK: diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 9ff87a0..79918fd 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -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/prepare-release.mjs b/scripts/prepare-release.mjs index 1f558e3..2928676 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -13,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/test-server-ws.mjs b/scripts/test-server-ws.mjs index b9a6075..bdf2d0f 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -575,17 +575,22 @@ try { 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. diff --git a/server/index.js b/server/index.js index 3de56d7..561dc61 100644 --- a/server/index.js +++ b/server/index.js @@ -250,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; @@ -275,7 +277,7 @@ 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' }); } @@ -325,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)}***`); } } @@ -1111,7 +1113,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)`); From 6a748fb3f5e577c8c776d8fca0e295619a0b3778 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:03:55 +0200 Subject: [PATCH 16/16] fix(sync): harden async session and lobby races --- extension/async-session-guards.test.mjs | 89 ++++ extension/background.js | 384 ++++++++++++++---- .../canonical-media-state-background.test.mjs | 8 + extension/canonical-media-state.js | 2 +- extension/canonical-media-state.test.mjs | 15 + extension/content.js | 64 ++- extension/episode-lobby-background.test.mjs | 38 +- .../offline-media-intent-background.test.mjs | 6 +- extension/target-tab-lifecycle.test.mjs | 4 +- scripts/test-server-ws.mjs | 33 +- server/index.js | 38 +- tests/e2e/extension.spec.mjs | 68 ++++ 12 files changed, 644 insertions(+), 105 deletions(-) create mode 100644 extension/async-session-guards.test.mjs diff --git a/extension/async-session-guards.test.mjs b/extension/async-session-guards.test.mjs new file mode 100644 index 0000000..01615b0 --- /dev/null +++ b/extension/async-session-guards.test.mjs @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const extensionDir = path.dirname(fileURLToPath(import.meta.url)); +const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); + +function sourceBetween(startNeedle, endNeedle) { + const start = backgroundSource.indexOf(startNeedle); + const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return backgroundSource.slice(start, end); +} + +describe('async room-session guards', () => { + it('normalizes persisted room, peer, lobby and Force Sync state before restoration', () => { + const restore = sourceBetween('function ensureState()', '// Start restoration immediately'); + expect(restore).toContain("typeof data.currentRoom.roomId === 'string'"); + expect(restore).toContain('.map(createPeerData)'); + expect(restore).toContain('data.currentRoom.activeLobby,'); + expect(restore).toContain('Array.isArray(data.forceSyncAcks)'); + expect(restore).toContain('currentRoom && Number.isFinite(data.forceSyncDeadline)'); + expect(restore).toContain('const restoredEpisodeLobby = currentRoom'); + expect(restore).toContain('data.episodeLobby,'); + }); + + it('revalidates ROOM_DATA after every asynchronous join boundary', () => { + const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:'); + expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2); + expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby('); + }); + + it('does not return chat context after its room or target changed', () => { + const handler = sourceBetween("message.type === 'GET_CHAT_CONTEXT'", "message.type === 'CHAT_SEND'"); + expect(handler).toContain('const roomId = currentRoom.roomId'); + expect(handler).toContain('const isCurrentSession = () =>'); + expect(handler.indexOf('if (!isCurrentSession() || settings.roomId !== roomId)')) + .toBeGreaterThan(handler.indexOf('await loadLocale')); + expect(handler).not.toContain('roomId: currentRoom.roomId'); + }); + + it('drops heartbeats and episode transitions that cross a room or target switch', () => { + const heartbeat = sourceBetween("message.type === 'HEARTBEAT'", "message.type === 'INJECT_CONTENT_SCRIPT'"); + expect(heartbeat).toContain('const heartbeatRoomId = currentRoom?.roomId || null'); + expect(heartbeat).toContain("status: 'ignored_stale_session'"); + expect(heartbeat.indexOf('currentRoom?.roomId !== heartbeatRoomId')) + .toBeLessThan(heartbeat.indexOf('emit(EVENTS.PEER_STATUS')); + + const episode = sourceBetween("message.type === 'EPISODE_CHANGED'", "message.type === 'EPISODE_READY_LOCAL'"); + expect(episode).toContain('const isCurrentEpisodeContext = () =>'); + expect(episode.match(/if \(!isCurrentEpisodeContext\(\)/g)?.length).toBeGreaterThanOrEqual(2); + + const ready = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); + expect(ready).toContain('settings.roomId !== lobbyRoomId'); + + const privacy = sourceBetween("message.type === 'TITLE_PRIVACY_CHANGED'", "message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'"); + expect(privacy).toContain('currentRoom?.roomId !== privacyRoomId'); + }); + + it('resolves content-event awaits before mutating canonical or room state', () => { + const handler = sourceBetween("message.type === 'CONTENT_EVENT'", "message.type === 'FORCE_SYNC_ACK'"); + const processEventIndex = handler.indexOf('const processEvent = async () =>'); + const videoStateIndex = handler.indexOf('await getReadyTabVideoState(tabId)', processEventIndex); + const settingsIndex = handler.indexOf('const settings = await getSettings()', videoStateIndex); + const contextGuardIndex = handler.indexOf("sendResponse({ status: 'ignored_stale_session' })", settingsIndex); + const supersedeIndex = handler.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)', contextGuardIndex); + expect(videoStateIndex).toBeGreaterThan(-1); + expect(settingsIndex).toBeGreaterThan(videoStateIndex); + expect(contextGuardIndex).toBeGreaterThan(settingsIndex); + expect(handler).toContain('(eventRoomId && settings.roomId !== eventRoomId)'); + expect(supersedeIndex).toBeGreaterThan(contextGuardIndex); + }); + + it('serializes new connection attempts behind terminal room teardown', () => { + const teardown = sourceBetween('async function endRoomSession', 'async function leaveRoomAfterIdleGrace'); + expect(teardown).toContain('if (roomTeardownPromise) return roomTeardownPromise'); + expect(teardown).toContain('performRoomSessionTeardown(options)'); + + for (const [start, end] of [ + ["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"], + ["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"], + ["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"] + ]) { + expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()'); + } + }); +}); diff --git a/extension/background.js b/extension/background.js index a62a7c7..84e5b80 100644 --- a/extension/background.js +++ b/extension/background.js @@ -346,6 +346,9 @@ function requestCanonicalMediaRecoveryAttempt() { function clearCanonicalMediaRecovery() { resetCanonicalMediaRecoveryRetries(); + // The content command itself may still finish, but it belongs to the room + // being cleared and must not block the first recovery attempt of a new room. + canonicalRecoveryApplyInProgress = null; canonicalMediaStateTracker.clear(); persistCanonicalMediaRecovery(); } @@ -466,7 +469,9 @@ function ensureState() { // snapshot after the worker already continued with defaults. if (restorationTimedOut) return; clearTimeout(storageTimeout); - if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; + if (Number.isSafeInteger(data.expectedAcksCount) && data.expectedAcksCount >= 0) { + expectedAcksCount = data.expectedAcksCount; + } if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); userSelectedTabId = normalizeTabId(data.selectedTabId); userSelectedTabTitle = userSelectedTabId !== null && typeof data.selectedTabTitle === 'string' @@ -495,10 +500,22 @@ function ensureState() { } // Merge data from storage with any early-arriving state // New entries (added during boot) must stay at the top (index 0) - if (data.logs) logs = [...logs, ...data.logs].slice(0, 200); - if (data.history) history = [...history, ...data.history].slice(0, 20); - if (data.currentRoom) { - currentRoom = data.currentRoom; + if (Array.isArray(data.logs)) logs = [...logs, ...data.logs].slice(0, 200); + if (Array.isArray(data.history)) history = [...history, ...data.history].slice(0, 20); + if (data.currentRoom + && typeof data.currentRoom === 'object' + && typeof data.currentRoom.roomId === 'string' + && data.currentRoom.roomId) { + currentRoom = { ...data.currentRoom }; + currentRoom.peers = (Array.isArray(data.currentRoom.peers) ? data.currentRoom.peers : []) + .map(createPeerData) + .filter(candidate => candidate.peerId); + const restoredPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId)); + currentRoom.activeLobby = normalizeEpisodeLobby( + data.currentRoom.activeLobby, + Date.now(), + restoredPeerIds + ); // Host Control Mode: restore role/mode/capabilities from persisted room. controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = currentRoom.hostPeerId || null; @@ -516,12 +533,14 @@ function ensureState() { data.canonicalMediaRecovery, currentRoom?.roomId || null ); - if (data.lastActionState) lastActionState = data.lastActionState; - - if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) { - isForceSyncInitiator = data.isForceSyncInitiator; + if (data.lastActionState && typeof data.lastActionState === 'object') { + lastActionState = data.lastActionState; } - if (data.forceSyncAcks) { + + if (currentRoom && data.isForceSyncInitiator === true && isForceSyncInitiator === false) { + isForceSyncInitiator = true; + } + if (currentRoom && isForceSyncInitiator && Array.isArray(data.forceSyncAcks)) { const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]); forceSyncAcks = mergedAcks; } @@ -532,7 +551,7 @@ function ensureState() { if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt; // Recover Force Sync Timeout - if (data.forceSyncDeadline) { + if (currentRoom && Number.isFinite(data.forceSyncDeadline)) { const remaining = data.forceSyncDeadline - Date.now(); if (remaining > 0 && isForceSyncInitiator) { forceSyncTimeout = setTimeout(() => { @@ -547,8 +566,15 @@ function ensureState() { } // Recover Episode Lobby - if (data.episodeLobby && !episodeLobby) { - episodeLobby = data.episodeLobby; + const restoredEpisodeLobby = currentRoom + ? normalizeEpisodeLobby( + data.episodeLobby, + Date.now(), + new Set(currentRoom.peers.map(candidate => candidate.peerId)) + ) + : null; + if (restoredEpisodeLobby && !episodeLobby) { + episodeLobby = restoredEpisodeLobby; const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now(); if (lobbyRemaining > 0) { episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining); @@ -601,6 +627,7 @@ let currentServerUrl = null; let roomIdleSince = null; let lastContentHeartbeatAt = null; let connectIntent = false; +let roomTeardownPromise = null; const MAX_RECONNECT_ATTEMPTS = 20; // Backoff tuned so that at most ~8 connection attempts land in any 60s window, // keeping a single client comfortably under the server's per-IP connection @@ -644,20 +671,50 @@ let episodeLobbyTimeout = null; * @returns {object} Normalized peer data object. */ function createPeerData(raw) { + const source = raw && typeof raw === 'object' + ? raw + : (typeof raw === 'string' ? { peerId: raw } : {}); return { - peerId: raw.peerId || null, - username: raw.username || null, - tabTitle: raw.tabTitle || null, - mediaTitle: raw.mediaTitle || null, - playbackState: raw.playbackState || null, - currentTime: raw.currentTime != null ? raw.currentTime : null, - volume: raw.volume != null ? raw.volume : null, - muted: raw.muted != null ? raw.muted : null, - desynced: raw.desynced === true, // HCM: peer is watching on their own + peerId: typeof source.peerId === 'string' && source.peerId ? source.peerId.substring(0, 16) : null, + username: typeof source.username === 'string' ? source.username.substring(0, 30) : null, + tabTitle: typeof source.tabTitle === 'string' ? source.tabTitle.substring(0, 100) : null, + mediaTitle: typeof source.mediaTitle === 'string' ? source.mediaTitle.substring(0, 100) : null, + playbackState: source.playbackState === 'playing' || source.playbackState === 'paused' ? source.playbackState : null, + currentTime: Number.isFinite(source.currentTime) ? source.currentTime : null, + volume: Number.isFinite(source.volume) ? source.volume : null, + muted: typeof source.muted === 'boolean' ? source.muted : null, + desynced: source.desynced === true, // HCM: peer is watching on their own lastHeartbeat: Date.now() }; } +function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const expectedTitle = typeof value.expectedTitle === 'string' + ? value.expectedTitle.substring(0, 100) + : ''; + const initiatorPeerId = typeof value.initiatorPeerId === 'string' + ? value.initiatorPeerId.substring(0, 16) + : ''; + if (!expectedTitle + || !initiatorPeerId + || !Array.isArray(value.readyPeers) + || (allowedPeerIds && !allowedPeerIds.has(initiatorPeerId))) return null; + const readyPeers = [...new Set(value.readyPeers + .filter(candidate => typeof candidate === 'string' && candidate) + .map(candidate => candidate.substring(0, 16)) + .filter(candidate => !allowedPeerIds || allowedPeerIds.has(candidate)))]; + if (!readyPeers.includes(initiatorPeerId)) readyPeers.unshift(initiatorPeerId); + return { + expectedTitle, + initiatorPeerId, + readyPeers, + createdAt: Number.isFinite(value.createdAt) && value.createdAt > 0 + ? value.createdAt + : fallbackCreatedAt + }; +} + /** * Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI. * Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state. @@ -739,9 +796,13 @@ function withTitlePrivacy(payload, settings, keys) { function emitEpisodeLobbyForCurrentPrivacy() { if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; + const lobby = episodeLobby; + const roomId = currentRoom?.roomId || null; getSettings().then(settings => { - if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; - const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode); + if (episodeLobby !== lobby + || currentRoom?.roomId !== roomId + || settings.roomId !== roomId) return; + const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode); if (expectedTitle) { emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle }); } @@ -1113,7 +1174,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) return true; } -async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) { +async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left Room' } = {}) { webJoinCoordinator.invalidate(); connectIntent = false; reconnectFailed = false; @@ -1182,6 +1243,22 @@ async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = { updateBadgeStatus(); } +async function endRoomSession(options = {}) { + if (roomTeardownPromise) return roomTeardownPromise; + + const teardown = performRoomSessionTeardown(options); + roomTeardownPromise = teardown; + try { + return await teardown; + } finally { + if (roomTeardownPromise === teardown) roomTeardownPromise = null; + } +} + +async function waitForRoomTeardown() { + if (roomTeardownPromise) await roomTeardownPromise; +} + async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; await endRoomSession({ notifyServer: true, reason }); @@ -1883,7 +1960,7 @@ function markCanonicalMediaStateHandled(roomId, revision) { return true; } -function supersedeCanonicalMediaRecovery(reason) { +function supersedeCanonicalMediaRecovery(reason, action = null, payload = null) { const roomId = currentRoom?.roomId; const pending = canonicalMediaStateTracker.getPending(roomId); if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) { @@ -1894,7 +1971,9 @@ function supersedeCanonicalMediaRecovery(reason) { // a newer local or remote control is allowed to win. sendMessageToCurrentContent({ type: 'CANCEL_CANONICAL_MEDIA_STATE', - reason + reason, + action, + payload }).catch(() => {}); addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info'); return true; @@ -2055,6 +2134,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } switch (event) { case EVENTS.ROOM_DATA: { + if (typeof data.roomId !== 'string' || !data.roomId) { + addLog('Ignored malformed ROOM_DATA without a room ID', 'warn'); + return; + } if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) { addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn'); return; @@ -2074,7 +2157,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con broadcastControlMode(); markRoomPotentiallyIdle(); if (currentRoom && Array.isArray(currentRoom.peers)) { - currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() }); + currentRoom.peers = currentRoom.peers + .map(createPeerData) + .filter(candidate => candidate.peerId); // Clear sequence tracking for peers that are no longer in the room const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p)); @@ -2095,24 +2180,31 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con entry?.event === EVENTS.EPISODE_LOBBY && (!entry.roomId || entry.roomId === data.roomId) ); - if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby) { + const authoritativeLobby = normalizeEpisodeLobby( + data.activeLobby, + Date.now(), + new Set(currentRoom.peers.map(candidate => candidate.peerId)) + ); + if (data.activeLobby && !authoritativeLobby) { + addLog('Ignored malformed active Episode Lobby in ROOM_DATA', 'warn'); + } + currentRoom.activeLobby = authoritativeLobby; + if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby) { clearEpisodeLobbyState(); addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info'); - } else if (data?.activeLobby) { + } else if (authoritativeLobby) { const sameLobby = episodeLobby - && episodeLobby.expectedTitle === data.activeLobby.expectedTitle - && episodeLobby.initiatorPeerId === data.activeLobby.initiatorPeerId; + && episodeLobby.expectedTitle === authoritativeLobby.expectedTitle + && episodeLobby.initiatorPeerId === authoritativeLobby.initiatorPeerId; if (!sameLobby && episodeLobbyTimeout) { clearTimeout(episodeLobbyTimeout); episodeLobbyTimeout = null; } episodeLobby = { - expectedTitle: data.activeLobby.expectedTitle, - initiatorPeerId: data.activeLobby.initiatorPeerId, - readyPeers: data.activeLobby.readyPeers, + ...authoritativeLobby, createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt) ? episodeLobby.createdAt - : Date.now() + : authoritativeLobby.createdAt }; persistEpisodeLobby(); broadcastLobbyUpdate(); @@ -2143,7 +2235,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; await broadcastJoinStatus(joinStatusMsg); - if (expectedConnectionGeneration !== connectionGeneration) return; + if (expectedConnectionGeneration !== connectionGeneration + || currentRoom?.roomId !== data.roomId) return; // Resolve replay privacy before declaring ROOM_DATA complete. Local // commands remain queued during this await, and any intervening // role/lobby event is reflected by the policy below before the @@ -2151,7 +2244,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con const replaySettings = eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null; - if (expectedConnectionGeneration !== connectionGeneration) return; + if (expectedConnectionGeneration !== connectionGeneration + || currentRoom?.roomId !== data.roomId) return; awaitingRoomData = false; pendingRoomDataRoomId = null; @@ -2162,7 +2256,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con canControl: !lostRoomAuthority, activeLobby: !!episodeLobby, desynced: hcmDesynced, - authoritativeLobby: !!data.activeLobby + authoritativeLobby: !!authoritativeLobby }, lostRoomAuthority ? 'Host Control role changed while offline' : (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy')); @@ -2296,7 +2390,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con _persistLastSeq(); } if (isCanonicalSupersedingControl(event, data)) { - supersedeCanonicalMediaRecovery(`newer ${event}`); + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); } if (data.senderId) { addToHistory(event, data.senderId); @@ -2360,7 +2454,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } - supersedeCanonicalMediaRecovery(`newer ${event}`); + supersedeCanonicalMediaRecovery(`newer ${event}`, event, data); if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -2495,28 +2589,40 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } break; case EVENTS.EPISODE_LOBBY: - if (data.senderId && data.expectedTitle) { + if (typeof data.senderId === 'string' + && typeof data.expectedTitle === 'string' + && data.expectedTitle + && currentRoom?.peers?.some(peer => (typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) { + const expectedTitle = data.expectedTitle.substring(0, 100); + const incomingLobby = normalizeEpisodeLobby({ + expectedTitle, + initiatorPeerId: data.senderId, + readyPeers: data.authoritative === true && Array.isArray(data.readyPeers) + ? data.readyPeers + : [data.senderId], + createdAt: Date.now() + }, Date.now(), new Set(currentRoom.peers.map(peer => peer.peerId))); + if (!incomingLobby) { + addLog(`Ignored malformed Episode lobby from ${data.senderId}`, 'warn'); + break; + } supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { - currentRoom.activeLobby = { - expectedTitle: data.expectedTitle, - initiatorPeerId: data.senderId, - readyPeers: [data.senderId] - }; + currentRoom.activeLobby = incomingLobby; if (storageInitialized) chrome.storage.session.set({ currentRoom }); } - addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); + addLog(`Episode lobby from ${data.senderId}: "${expectedTitle}"`, 'info'); // If we already have a lobby for this same title, treat as dedup - if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) { + if (episodeLobby + && episodeLobby.initiatorPeerId === data.senderId + && sameEpisode(episodeLobby.expectedTitle, expectedTitle)) { break; // Already tracking this lobby } // Cancel any existing lobby before starting a new one if (episodeLobby) clearEpisodeLobbyState(); episodeLobby = { - expectedTitle: data.expectedTitle, - initiatorPeerId: data.senderId, - readyPeers: [data.senderId], // Initiator is already ready + ...incomingLobby, createdAt: Date.now() }; persistEpisodeLobby(); @@ -2531,7 +2637,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con if (!isNaN(tabId)) { sendMessageToCurrentContent({ type: 'EPISODE_LOBBY', - expectedTitle: data.expectedTitle + expectedTitle }).catch(() => {}); } } @@ -2541,6 +2647,15 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con { const lobby = episodeLobby; if (!lobby || !data.senderId) break; + const senderPresent = currentRoom?.peers?.some(peer => + (typeof peer === 'object' ? peer.peerId : peer) === data.senderId + ); + if (!senderPresent + || (data.expectedTitle + && !sameEpisode(data.expectedTitle, lobby.expectedTitle))) { + addLog(`Ignored stale Episode ready from ${data.senderId}`, 'warn'); + break; + } let readyAdded = false; if (!lobby.readyPeers.includes(data.senderId)) { lobby.readyPeers.push(data.senderId); @@ -2558,6 +2673,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } break; case EVENTS.EPISODE_LOBBY_CANCEL: + if (typeof data.senderId !== 'string' || !currentRoom?.peers?.some(peer => + (typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) { + addLog(`Ignored Episode lobby cancellation from unknown peer ${data.senderId || 'unknown'}`, 'warn'); + break; + } supersedeCanonicalMediaRecovery(`newer ${event}`); if (currentRoom) { currentRoom.activeLobby = null; @@ -2591,7 +2711,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con } function executeForceSync() { - supersedeCanonicalMediaRecovery('local force_sync_execute'); + supersedeCanonicalMediaRecovery('local force_sync_execute', EVENTS.FORCE_SYNC_EXECUTE); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); @@ -2749,8 +2869,13 @@ function checkEpisodeLobbyCompletion() { // M-3: desynced peers (watching on their own) sit out the lobby — their content // script ignores EPISODE_LOBBY and never reports ready. Don't let them block // completion: count only peers who actually participate. - const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length; - if (episodeLobby.readyPeers.length >= participatingCount) { + const participatingPeerIds = new Set(peers + .filter(candidate => !(typeof candidate === 'object' && candidate.desynced)) + .map(candidate => typeof candidate === 'object' ? candidate.peerId : candidate) + .filter(Boolean)); + const readyParticipatingCount = episodeLobby.readyPeers + .filter(candidate => participatingPeerIds.has(candidate)).length; + if (readyParticipatingCount >= participatingPeerIds.size) { executeEpisodeLobby(); } } @@ -4384,6 +4509,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } if (message.type === 'CONNECT') { + await waitForRoomTeardown(); webJoinCoordinator.invalidate(); const settings = await getSettings(); connectIntent = !!settings.roomId; @@ -4422,6 +4548,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'RETRY_CONNECT') { + await waitForRoomTeardown(); connectIntent = true; reconnectFailed = false; reconnectStartTime = null; @@ -4508,9 +4635,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ supported: false, hasKey: false }); return; } + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const isCurrentSession = () => generation === chatSessionGeneration + && currentRoom?.roomId === roomId + && Number(currentTabId) === tabId + && isCurrentContentSender(sender); const settings = await getSettings(); const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']); await loadLocale(localeData.locale || getSystemLanguage()); + if (!isCurrentSession() || settings.roomId !== roomId) { + sendResponse({ supported: false, hasKey: false, status: 'session_changed' }); + return; + } const translated = key => { const value = getMessage(key); return value === key ? '' : value; @@ -4522,7 +4660,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), eventNotifications: localeData.browserNotifications === true, peerId, - roomId: currentRoom.roomId, + roomId, activity: chatActivityStore.snapshot(), strings: { title: translated('CHAT_TITLE'), @@ -4697,6 +4835,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { addLog(`Canonical media state r${pending.mediaState.revision} skipped: local guest chose desynced mode`, 'info'); } } + if (episodeLobby) { + if (hcmDesynced && episodeLobby.initiatorPeerId === peerId) { + cancelEpisodeLobby('Initiator entered solo mode'); + } else { + checkEpisodeLobbyCompletion(); + } + } sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { await endRoomSession({ notifyServer: true, reason: 'Left Room' }); @@ -4712,6 +4857,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { emit(EVENTS.GET_ROOMS, {}); sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { + await waitForRoomTeardown(); const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; const roomId = normalizeRoomId(rawRoomId); const chatKey = validateChatSecret(rawChatKey); @@ -4843,6 +4989,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { } } const processEvent = async () => { + const eventRoomId = currentRoom?.roomId || pendingRoomDataRoomId || null; + const eventTabId = normalizeTabId(currentTabId); + const isCurrentEventContext = () => (currentRoom?.roomId || pendingRoomDataRoomId || null) === eventRoomId + && normalizeTabId(currentTabId) === eventTabId + && (!senderIsContent || isCurrentContentSender(sender)); // Host Control Mode (sender-side): a non-controller in host-only mode must // not drive the room. Don't broadcast; hand the action back to content.js so // it can snap the local player back / offer desync. @@ -4866,7 +5017,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // event (the list is updated synchronously on PEER_STATUS join/leave), // never cached, so the instant a peer joins we resume sending. const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0; - const hasOtherPeers = otherCount > 0; + let hasOtherPeers = otherCount > 0; // Force Sync only makes sense with other peers. Solo it is a no-op: // skip the pause/seek + ACK-wait entirely (no freeze, no server traffic). @@ -4894,14 +5045,6 @@ async function handleAsyncMessage(message, sender, sendResponse) { payload.targetTime = targetTime; } - if (isCanonicalSupersedingControl(message.action, payload)) { - supersedeCanonicalMediaRecovery(`local ${message.action}`); - } - const timestamp = Date.now(); - localSeq++; - chrome.storage.session.set({ localSeq }); - updateLastAction(message.action, 'You', timestamp); - const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime); if (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) { const tabId = currentTabId ? parseInt(currentTabId) : NaN; @@ -4912,6 +5055,36 @@ async function handleAsyncMessage(message, sender, sendResponse) { } } } + if (!isCurrentEventContext()) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + + const isNonEssentialEvent = message.action === EVENTS.PLAY + || message.action === EVENTS.PAUSE + || message.action === EVENTS.SEEK; + const settings = await getSettings(); + if (!isCurrentEventContext() + || (eventRoomId && settings.roomId !== eventRoomId)) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + const currentOtherCount = currentRoom && Array.isArray(currentRoom.peers) + ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length + : 0; + hasOtherPeers = currentOtherCount > 0; + const shouldEmit = !(isNonEssentialEvent + && !hasOtherPeers + && !serverSupports(CAPABILITIES.MEDIA_STATE_V1)); + + if (isCanonicalSupersedingControl(message.action, payload)) { + supersedeCanonicalMediaRecovery(`local ${message.action}`); + } + const timestamp = Date.now(); + localSeq++; + chrome.storage.session.set({ localSeq }); + updateLastAction(message.action, 'You', timestamp); + lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); @@ -4955,15 +5128,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { addToHistory(message.action, 'You'); sendChatActivity(message.action, peerId, timestamp); - const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK; - if (isNonEssentialEvent - && !hasOtherPeers - && !serverSupports(CAPABILITIES.MEDIA_STATE_V1)) { + if (!shouldEmit) { sendResponse({ status: 'ok_solo' }); return; } - - const settings = await getSettings(); + const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']); emit(message.action, { ...outboundPayload, peerId }); sendResponse({ status: 'ok' }); @@ -5049,10 +5218,20 @@ async function handleAsyncMessage(message, sender, sendResponse) { requestCanonicalMediaRecoveryAttempt(); markRoomUseful(); + const heartbeatRoomId = currentRoom?.roomId || null; + const heartbeatPayload = message.payload && typeof message.payload === 'object' + ? { ...message.payload } + : {}; getSettings().then(settings => { - const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle); + if ((sender.tab && !isCurrentContentSender(sender)) + || currentRoom?.roomId !== heartbeatRoomId + || settings.roomId !== heartbeatRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + const sharedTitles = getSharedTitleFields(settings, heartbeatPayload.mediaTitle); const statusPayload = { - ...message.payload, + ...heartbeatPayload, peerId, username: settings.username, tabTitle: sharedTitles.tabTitle, @@ -5068,10 +5247,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { me.tabTitle = sharedTitles.tabTitle; me.username = settings.username; me.mediaTitle = sharedTitles.mediaTitle; - me.playbackState = message.payload?.playbackState; - me.currentTime = message.payload?.currentTime; - me.volume = message.payload?.volume; - me.muted = message.payload?.muted; + me.playbackState = heartbeatPayload.playbackState; + me.currentTime = heartbeatPayload.currentTime; + me.volume = heartbeatPayload.volume; + me.muted = heartbeatPayload.muted; me.lastHeartbeat = Date.now(); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); @@ -5167,6 +5346,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { return; } } + const episodeRoomId = currentRoom?.roomId || null; + const isCurrentEpisodeContext = () => currentRoom?.roomId === episodeRoomId + && (!sender.tab || isCurrentContentSender(sender)); const newTitle = message.payload && message.payload.newTitle; if (newTitle && extractEpisodeId(newTitle) === null) { @@ -5180,6 +5362,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { } const settings = await getSettings(); + if (!isCurrentEpisodeContext() || settings.roomId !== episodeRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode); if (!lobbyTitle) { addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info'); @@ -5189,12 +5375,22 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Check setting const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']); + if (!isCurrentEpisodeContext()) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } if (epSettings.autoSyncNextEpisode === false) { addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info'); sendResponse({ status: 'disabled' }); return; } + if (hcmDesynced) { + addLog(`Episode change ("${lobbyTitle}") — intentional solo mode, not creating a lobby.`, 'info'); + sendResponse({ status: 'desynced_skip' }); + return; + } + // Host Control Mode: a gated guest must NOT initiate an episode lobby — the // server drops the guest's EPISODE_LOBBY, so the lobby would never complete // and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In @@ -5223,7 +5419,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { episodeLobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); - emit(EVENTS.EPISODE_READY, { peerId, title: lobbyTitle }); + emit(EVENTS.EPISODE_READY, { + peerId, + title: lobbyTitle, + expectedTitle: episodeLobby.expectedTitle + }); checkEpisodeLobbyCompletion(); } sendResponse({ status: 'ready_sent' }); @@ -5234,7 +5434,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (episodeLobby) clearEpisodeLobbyState(); // Create new lobby - supersedeCanonicalMediaRecovery('local episode_lobby'); + supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE); episodeLobby = { expectedTitle: lobbyTitle, initiatorPeerId: peerId, @@ -5273,10 +5473,14 @@ async function handleAsyncMessage(message, sender, sendResponse) { } // Content script confirmed it loaded the lobby episode const lobby = episodeLobby; + const lobbyRoomId = currentRoom?.roomId || null; + const isCurrentLobbyContext = () => episodeLobby === lobby + && currentRoom?.roomId === lobbyRoomId + && (!sender.tab || isCurrentContentSender(sender)); if (lobby && message.payload && sameEpisode(message.payload.title, lobby.expectedTitle)) { if (!lobby.readyPeers.includes(peerId)) { const settings = await getSettings(); - if (episodeLobby !== lobby) { + if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId) { sendResponse({ status: 'ignored_stale_lobby' }); return; } @@ -5288,17 +5492,27 @@ async function handleAsyncMessage(message, sender, sendResponse) { lobby.readyPeers.push(peerId); persistEpisodeLobby(); broadcastLobbyUpdate(); - emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle }); + emit(EVENTS.EPISODE_READY, { + peerId, + title: readyTitle, + expectedTitle: lobby.expectedTitle + }); addLog(`Local episode ready: "${readyTitle || lobby.expectedTitle}"`, 'success'); checkEpisodeLobbyCompletion(); } } sendResponse({ status: 'ok' }); } else if (message.type === 'TITLE_PRIVACY_CHANGED') { + const privacyRoomId = currentRoom?.roomId || null; + const privacyLobby = episodeLobby; const settings = await getSettings(); - if (episodeLobby && episodeLobby.initiatorPeerId === peerId) { - const nextLobbyTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode); - if (!nextLobbyTitle || nextLobbyTitle !== episodeLobby.expectedTitle) { + if (currentRoom?.roomId !== privacyRoomId || settings.roomId !== privacyRoomId) { + sendResponse({ status: 'ignored_stale_session' }); + return; + } + if (episodeLobby === privacyLobby && privacyLobby?.initiatorPeerId === peerId) { + const nextLobbyTitle = sanitizeSharedTitle(privacyLobby.expectedTitle, settings.mediaTitlePrivacyMode); + if (!nextLobbyTitle || nextLobbyTitle !== privacyLobby.expectedTitle) { cancelEpisodeLobby('Title privacy changed'); } } diff --git a/extension/canonical-media-state-background.test.mjs b/extension/canonical-media-state-background.test.mjs index f02c7b2..09af527 100644 --- a/extension/canonical-media-state-background.test.mjs +++ b/extension/canonical-media-state-background.test.mjs @@ -73,6 +73,8 @@ describe('canonical ROOM_DATA recovery contract', () => { const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt'); expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length'); expect(retry).toContain('latest?.mediaState.revision !== expectedRevision'); + const clear = functionBody(backgroundSource, 'clearCanonicalMediaRecovery', 'invalidateChatSession'); + expect(clear).toContain('canonicalRecoveryApplyInProgress = null'); }); it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => { @@ -91,6 +93,8 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)'); expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)'); expect(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'"); + expect(supersede).toContain('action,'); + expect(supersede).toContain('payload'); expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)'); expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)'); @@ -118,6 +122,10 @@ describe('canonical ROOM_DATA recovery contract', () => { expect(contentSource.slice(contentHandlerStart, serverCommandStart)) .toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)'); expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'"); + expect(contentSource).toContain('cancelCanonicalMediaApply(action, findVideo(), false, payload)'); + expect(contentSource).toContain('restorationGeneration !== canonicalMediaApplyGeneration'); + expect(contentSource).toContain('holdCanonicalRestorePlaySuppression()'); + expect(contentSource).toContain('consumeCanonicalRestorePlaySuppression()'); expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)'); }); }); diff --git a/extension/canonical-media-state.js b/extension/canonical-media-state.js index 55b7898..2daf2f5 100644 --- a/extension/canonical-media-state.js +++ b/extension/canonical-media-state.js @@ -157,7 +157,7 @@ export function createCanonicalMediaStateTracker() { const restoredPending = validateCanonicalMediaState(value.pending?.mediaState); if (value.pending?.roomId === roomId && restoredPending - && restoredPending.revision >= appliedRevision + && restoredPending.revision > appliedRevision && restoredPending.revision >= knownRevision) { pending = { roomId, diff --git a/extension/canonical-media-state.test.mjs b/extension/canonical-media-state.test.mjs index eac7af1..1d88bb5 100644 --- a/extension/canonical-media-state.test.mjs +++ b/extension/canonical-media-state.test.mjs @@ -164,4 +164,19 @@ describe('canonical media state tracker', () => { expect(otherRoom.restore(stored, 'room-b')).toBe(false); expect(otherRoom.getPending('room-b')).toBeNull(); }); + + it('does not resurrect a pending snapshot that was already applied', () => { + const tracker = createCanonicalMediaStateTracker(); + expect(tracker.restore({ + roomId: 'room-a', + knownRevision: 7, + appliedRevision: 7, + pending: { + roomId: 'room-a', + mediaState: state(7), + receivedAt: 1_000 + } + }, 'room-a')).toBe(true); + expect(tracker.getPending('room-a')).toBeNull(); + }); }); diff --git a/extension/content.js b/extension/content.js index 048defe..4fdfbad 100644 --- a/extension/content.js +++ b/extension/content.js @@ -91,6 +91,7 @@ // While a timer exists, matching native events are consumed and not relayed. // Timers self-clean after 300ms if the native event never fires. let _suppressTimers = {}; + const canonicalRestorePlaySuppressions = new Set(); let canonicalMediaApplyGeneration = 0; let canonicalSupersedingLocalState = null; @@ -108,6 +109,25 @@ } } + function holdCanonicalRestorePlaySuppression() { + const hold = { timeout: null }; + hold.timeout = setTimeout(() => canonicalRestorePlaySuppressions.delete(hold), 5000); + canonicalRestorePlaySuppressions.add(hold); + return hold; + } + + function releaseCanonicalRestorePlaySuppression(hold) { + if (!canonicalRestorePlaySuppressions.delete(hold)) return; + clearTimeout(hold.timeout); + } + + function consumeCanonicalRestorePlaySuppression() { + const hold = canonicalRestorePlaySuppressions.values().next().value; + if (!hold) return false; + releaseCanonicalRestorePlaySuppression(hold); + return true; + } + // --- Seek Relay Filtering --- // Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks // from being relayed to peers as user-initiated seeks. @@ -1237,14 +1257,25 @@ return canonicalMediaApplyGeneration; } - function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) { + function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) { canonicalMediaApplyGeneration++; - if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) { + const storesPlaybackIntent = action === EVENTS.PLAY + || action === EVENTS.PAUSE + || action === EVENTS.SEEK + || action === EVENTS.FORCE_SYNC_PREPARE + || action === EVENTS.FORCE_SYNC_EXECUTE; + if (storesPlaybackIntent) { + const payloadTime = Number.isFinite(data?.targetTime) + ? data.targetTime + : (Number.isFinite(data?.currentTime) ? data.currentTime : null); + const videoTime = video ? getSyncCurrentTime(video) : null; canonicalSupersedingLocalState = { - playbackState: action === EVENTS.PLAY + playbackState: action === EVENTS.PLAY || action === EVENTS.FORCE_SYNC_EXECUTE ? 'playing' - : (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')), - currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null + : (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE + ? 'paused' + : (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)), + currentTime: payloadTime ?? videoTime }; } else if (!preserveLocalState) { canonicalSupersedingLocalState = null; @@ -1257,8 +1288,9 @@ } async function restoreSupersedingLocalState(video) { + const restorationGeneration = canonicalMediaApplyGeneration; const state = canonicalSupersedingLocalState; - if (!state || !video) return; + if (!state || !video || destroyed || video.isConnected === false) return; if (state.playbackState === 'paused' && !video.paused) { _setSuppress('paused'); @@ -1273,13 +1305,21 @@ } if (state.playbackState === 'playing' && video.paused) { _setSuppress('playing'); + const playSuppression = holdCanonicalRestorePlaySuppression(); try { await video.play(); } catch (error) { _clearSuppress('playing'); reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn'); + } finally { + releaseCanonicalRestorePlaySuppression(playSuppression); } } + // A delayed play() can settle after an even newer command already ran. + // Re-assert that newest intent so the old promise cannot win last. + if (!destroyed && restorationGeneration !== canonicalMediaApplyGeneration) { + await restoreSupersedingLocalState(video); + } } function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) { @@ -1500,7 +1540,12 @@ const preserveLocalState = message.reason === `local ${EVENTS.PLAY}` || message.reason === `local ${EVENTS.PAUSE}` || message.reason === `local ${EVENTS.SEEK}`; - cancelCanonicalMediaApply(null, null, preserveLocalState); + cancelCanonicalMediaApply( + message.action, + findVideo(), + preserveLocalState, + message.payload + ); sendResponse({ status: 'cancelled' }); return true; } @@ -1552,7 +1597,7 @@ } if (syncActions.includes(action)) { - cancelCanonicalMediaApply(); + cancelCanonicalMediaApply(action, findVideo(), false, payload); } if (action === EVENTS.PLAY) { @@ -1863,6 +1908,7 @@ _clearSuppress(eventState); return; } + if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return; if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { cancelCanonicalMediaApply(action, video); @@ -2327,6 +2373,8 @@ for (const timer of Object.values(_suppressTimers)) clearTimeout(timer); _suppressTimers = {}; + for (const hold of canonicalRestorePlaySuppressions) clearTimeout(hold.timeout); + canonicalRestorePlaySuppressions.clear(); for (const timer of lifecycleTimeouts) clearTimeout(timer); lifecycleTimeouts.clear(); for (const timer of seekPollTimers) clearInterval(timer); diff --git a/extension/episode-lobby-background.test.mjs b/extension/episode-lobby-background.test.mjs index c8e3035..de4a02b 100644 --- a/extension/episode-lobby-background.test.mjs +++ b/extension/episode-lobby-background.test.mjs @@ -30,10 +30,11 @@ describe('episode lobby completion races', () => { it('revalidates the lobby after awaiting settings for a local ready', () => { const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); const awaitIndex = handler.indexOf('const settings = await getSettings()'); - const guardIndex = handler.indexOf('if (episodeLobby !== lobby)'); + const guardIndex = handler.indexOf('if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId)'); const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)'); expect(handler).toContain('const lobby = episodeLobby'); + expect(handler).toContain('const isCurrentLobbyContext = () => episodeLobby === lobby'); expect(awaitIndex).toBeGreaterThan(-1); expect(guardIndex).toBeGreaterThan(awaitIndex); expect(mutationIndex).toBeGreaterThan(guardIndex); @@ -48,4 +49,39 @@ describe('episode lobby completion races', () => { expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex); expect(execute).toContain('chrome.storage.session.set({ currentRoom })'); }); + + it('validates restored and authoritative lobby state before using readyPeers', () => { + expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)'); + expect(backgroundSource).toContain('data.currentRoom.activeLobby,'); + expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby('); + expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))'); + }); + + it('rejects stale or unknown ready senders and correlates new ready frames to the lobby', () => { + const remoteReady = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:'); + expect(remoteReady).toContain('const senderPresent = currentRoom?.peers?.some'); + expect(remoteReady).toContain('!sameEpisode(data.expectedTitle, lobby.expectedTitle)'); + + const localReady = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'"); + expect(localReady).toContain('expectedTitle: lobby.expectedTitle'); + }); + + it('adopts authoritative correction data after the relay rejects a competing lobby', () => { + const remoteLobby = sourceBetween('case EVENTS.EPISODE_LOBBY:', 'case EVENTS.EPISODE_READY:'); + expect(remoteLobby).toContain('data.authoritative === true && Array.isArray(data.readyPeers)'); + expect(remoteLobby).toContain('currentRoom.activeLobby = incomingLobby'); + }); + + it('counts only ready peers who still participate in lobby completion', () => { + const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()'); + expect(completion).toContain('const participatingPeerIds = new Set(peers'); + expect(completion).toContain('participatingPeerIds.has(candidate)'); + expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size'); + }); + + it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => { + const desync = sourceBetween("message.type === 'HCM_DESYNC_STATE'", "message.type === 'LEAVE_ROOM'"); + expect(desync).toContain("cancelEpisodeLobby('Initiator entered solo mode')"); + expect(desync).toContain('checkEpisodeLobbyCompletion()'); + }); }); diff --git a/extension/offline-media-intent-background.test.mjs b/extension/offline-media-intent-background.test.mjs index 99154d8..2fe02a7 100644 --- a/extension/offline-media-intent-background.test.mjs +++ b/extension/offline-media-intent-background.test.mjs @@ -46,8 +46,8 @@ describe('offline media intent background integration', () => { expect(canonicalIndex).toBeLessThan(flushIndex); expect(roomData).toContain('activeLobby: !!episodeLobby'); expect(roomData).toContain('desynced: hcmDesynced'); - expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)'); - expect(roomData).toContain('authoritativeLobby: !!data.activeLobby'); + expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)'); + expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby'); }); it('clears queued room intent on failed join, leave and room switch paths', () => { @@ -59,7 +59,7 @@ describe('offline media intent background integration', () => { backgroundSource.indexOf("message.type === 'CLEAR_LOGS'") ); expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })"); - expect(functionBody('endRoomSession', 'leaveRoomAfterIdleGrace')).toContain('forceDisconnect()'); + expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()'); const retryHandler = backgroundSource.slice( backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"), backgroundSource.indexOf("message.type === 'GET_STATUS'") diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 79918fd..0bda1e4 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -99,8 +99,8 @@ describe('target tab lifecycle', () => { }); it('routes every terminal room exit through the full target unhook', () => { - const teardownStart = backgroundSource.indexOf('async function endRoomSession'); - const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart); + const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown'); + const teardownEnd = backgroundSource.indexOf('async function endRoomSession', teardownStart); const teardownSource = backgroundSource.slice(teardownStart, teardownEnd); expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())'); expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())')) diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index bdf2d0f..ffffb7a 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -49,10 +49,11 @@ try { // --- Pool: 2 peers in 1 room, test everything --- const rid = 't-'+Date.now(); - const p1 = await c(), p2 = await c(); + const p1 = await c(), p2 = await c(), p3 = await c(); // Room + join - await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0; + await j(p1, rid, 'a'); await j(p2, rid, 'b'); await j(p3, rid, 'c'); + p1._m.length = p2._m.length = p3._m.length = 0; // Relay s(p1,'play',{currentTime:10}); await w(p2,'play'); @@ -68,7 +69,33 @@ try { s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack'); // Lobby - s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby'); + s(p1,'episode_lobby',{expectedTitle:'S01E01'}); + await w(p2,'episode_lobby'); await w(p3,'episode_lobby'); + s(p3,'episode_lobby',{expectedTitle:'S01E02'}); + const authoritativeLobby = await w(p3, 'episode_lobby'); + assert.equal(authoritativeLobby.authoritative, true, + 'competing initiator receives an authoritative lobby correction'); + assert.equal(authoritativeLobby.expectedTitle, 'S01E01'); + assert.deepEqual(authoritativeLobby.readyPeers, ['a']); + let competingLobbyDropped = false; + try { await w(p2, 'episode_lobby', 500); } catch { competingLobbyDropped = true; } + assert.ok(competingLobbyDropped, 'relay drops a competing lobby while one is active'); + assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01'); + + s(p3,'episode_ready',{expectedTitle:'S01E02',title:'S01E02'}); + let staleReadyDropped = false; + try { await w(p2, 'episode_ready', 500); } catch { staleReadyDropped = true; } + assert.ok(staleReadyDropped, 'relay drops ready frames for an obsolete lobby'); + assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a']); + + // Missing expectedTitle remains accepted for old-extension compatibility. + s(p2,'episode_ready',{title:'S01E01'}); await w(p1,'episode_ready'); + assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a', 'b']); + + s(p3,'leave_room',{}); await w(p1,'peer_status'); + assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01', + 'an unrelated departure does not dissolve a lobby with two peers left'); + p1._m.length = p2._m.length = 0; // Leave s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left'); diff --git a/server/index.js b/server/index.js index 561dc61..9f3ffbf 100644 --- a/server/index.js +++ b/server/index.js @@ -284,7 +284,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t // 3.5. Clean up active lobby if a peer leaves if (room.activeLobby) { room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId); - if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) { + if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) { room.activeLobby = null; // Dissolve lobby } } @@ -415,6 +415,7 @@ io.on('connection', (socket) => { const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities); if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization + if (!socket.connected) return; try { // Protocol check @@ -450,6 +451,7 @@ io.on('connection', (socket) => { let lockPromise = roomCreationLocks.get(roomId); if (lockPromise) { await lockPromise; + if (!socket.connected) return; room = rooms.get(roomId); } if (!room) { @@ -511,6 +513,7 @@ io.on('connection', (socket) => { let peerLockPromise = peerJoinLocks.get(peerId); if (peerLockPromise) { await peerLockPromise; + if (!socket.connected) return; room = rooms.get(roomId); if (!room) { socket.emit(EVENTS.ERROR, { message: "Room no longer exists" }); @@ -521,6 +524,7 @@ io.on('connection', (socket) => { peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; }); peerJoinLocks.set(peerId, peerLockPromise); try { + if (!socket.connected) return; if (!createdByMe) { if (room.passwordHash) { if (!password || hashPassword(password) !== room.passwordHash) { @@ -710,6 +714,36 @@ io.on('connection', (socket) => { // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); + // The first live lobby owns the room until completion or + // cancellation. Drop concurrent lobby starts and stale ready + // frames instead of letting clients build divergent lobbies. + if (eventName === EVENTS.EPISODE_LOBBY && room.activeLobby) { + log('ROOM', `Dropped competing episode lobby from ${mapping.peerId}`); + socket.emit(EVENTS.EPISODE_LOBBY, { + senderId: room.activeLobby.initiatorPeerId, + peerId: room.activeLobby.initiatorPeerId, + expectedTitle: room.activeLobby.expectedTitle, + readyPeers: [...room.activeLobby.readyPeers], + authoritative: true + }); + return; + } + if (eventName === EVENTS.EPISODE_LOBBY && !relayPayload.expectedTitle) { + log('ROOM', `Dropped malformed episode lobby from ${mapping.peerId}`); + return; + } + if (eventName === EVENTS.EPISODE_READY) { + if (!room.activeLobby) { + log('ROOM', `Dropped stale episode ready from ${mapping.peerId}`); + return; + } + if (relayPayload.expectedTitle + && relayPayload.expectedTitle !== room.activeLobby.expectedTitle) { + log('ROOM', `Dropped episode ready for an obsolete lobby from ${mapping.peerId}`); + return; + } + } + const mediaStateNow = Date.now(); // Canonical Media State v1: mutate only after rate limiting, @@ -792,7 +826,7 @@ io.on('connection', (socket) => { socket.to(mapping.roomId).emit(eventName, relayPayload); // --- Side-effects: Server-side Episode Lobby Tracking --- - if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) { + if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) { room.activeLobby = { expectedTitle: relayPayload.expectedTitle, initiatorPeerId: mapping.peerId, diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 75331ab..f6be081 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -570,6 +570,74 @@ test('newer server command clears local recovery state before a delayed apply re expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false); }); +test('newer remote pause wins after a stale restoration play settles late', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/simple-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId } = await selectTargetTab(context, extensionId, url); + await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true'); + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => { + await chrome.scripting.executeScript({ + target: { tabId: selectedTabId }, + world: 'ISOLATED', + func: () => { + const video = document.querySelector('#player'); + if (!video) throw new Error('late restoration fixture video missing'); + video.pause(); + video.currentTime = 0; + const nativePlay = video.play.bind(video); + video.dataset.koalaDelayedPlayAttempts = '0'; + Object.defineProperty(video, 'play', { + configurable: true, + value: () => { + const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1; + video.dataset.koalaDelayedPlayAttempts = String(attempt); + if (attempt === 1) { + return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200))); + } + return new Promise((resolve, reject) => setTimeout(() => { + nativePlay().then(resolve, reject); + }, 400)); + } + }); + } + }); + }, tabId)); + + const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, { + revision: 22, + playbackState: 'playing', + currentTime: 6, + updatedBy: 'peer-a' + }); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1); + + // Capture a superseding play intent while leaving the fixture paused, so + // stale recovery has to enter its delayed restoration play path. + await page.locator('#player').evaluate(video => { + video.pause(); + }); + await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate( + selectedTabId => chrome.tabs.sendMessage(selectedTabId, { + type: 'CANCEL_CANONICAL_MEDIA_STATE', + reason: 'local play', + action: 'play', + payload: { currentTime: 0 } + }), + tabId + )); + await expect.poll(() => page.locator('#player').evaluate(video => + Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2); + + await sendContentServerCommand(context, extensionId, tabId, 'pause'); + await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' }); + await page.waitForTimeout(500); + expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true); +}); + test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => { test.setTimeout(45_000); const relay = await import('../../server/index.js');