diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 229f69a..69dbc60 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -558,9 +558,11 @@ non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. Any timeout, failed player action, participant departure, manual media command, room/target change, or explicit cancellation produces `cancel`; v2 never executes after a timeout. Peers resume only when that transaction paused a -previously playing player and no newer local user action superseded it. Peers -joining after `start` are excluded from the frozen barrier and receive no v2 -frames for that transaction. +previously playing player and no newer local or room action superseded it. A +superseding room command defines the next state without racing a restoration. +Peers joining after `start` are excluded from the frozen barrier and receive no +v2 frames for that transaction. Clients revalidate the complete prepared state +again immediately before applying `execute`. Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE, and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire diff --git a/extension/background.js b/extension/background.js index 632fc7b..7f3b075 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1185,11 +1185,9 @@ async function clearTargetSelectionForLifecycle({ } resetUserSelectionState(); - const cleanupTasks = [clearPendingTarget()]; - if (previousTabId !== null) { - cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget)); - } - await Promise.all(cleanupTasks); + // Persist the terminal selection state before any frame messaging or host + // permission cleanup can yield. A worker stop or a concurrent new target + // must never resurrect the selection this lifecycle transition removed. await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -1203,6 +1201,12 @@ async function clearTargetSelectionForLifecycle({ selectionErrorTabId: null, selectionErrorMessage: null }); + + const cleanupTasks = [clearPendingTarget()]; + if (previousTabId !== null) { + cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget)); + } + await Promise.all(cleanupTasks); updateBadgeStatus(); chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId: clearedTabId }).catch(() => {}); return true; diff --git a/extension/content.js b/extension/content.js index c8b2a15..9acff60 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1983,20 +1983,33 @@ if (episodeSyncV2State?.transactionId === transaction.transactionId) { const state = episodeSyncV2State; const video = state.video || findVideo(); + const current = video ? getSyncCurrentTime(video) : null; const canExecute = video + && state.phase === 'prepare' && video === findVideo() - && sameEpisodeStrict(getMediaTitle(), state.expectedTitle); + && video.isConnected !== false + && sameEpisodeStrict(getMediaTitle(), state.expectedTitle) + && video.paused + && !video.seeking + && video.readyState >= 3 + && current !== null + && Math.abs(current) < 1; clearEpisodeSyncV2Content({ resume: false }).catch(() => {}); if (canExecute) { Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => { if (applied) scheduleProactiveHeartbeat(); else reportLog('Episode Sync v2 execute could not start playback', 'warn'); }).catch(() => {}); + } else { + reportLog('Episode Sync v2 execute ignored: prepared player state changed', 'warn'); } } } else if (transaction.phase === 'cancel') { if (episodeSyncV2State?.transactionId === transaction.transactionId) { - clearEpisodeSyncV2Content({ resume: true }).catch(() => {}); + // A superseding room command follows this cancellation on + // the same ordered socket. Do not race it with restoration + // of the pre-transaction play state. + clearEpisodeSyncV2Content({ resume: transaction.reason !== 'superseded' }).catch(() => {}); } } sendResponse({ status: 'ok' }); diff --git a/extension/episode-sync-v2.test.mjs b/extension/episode-sync-v2.test.mjs index b2e3444..f0d7537 100644 --- a/extension/episode-sync-v2.test.mjs +++ b/extension/episode-sync-v2.test.mjs @@ -98,6 +98,30 @@ describe('Episode Sync v2 extension contract', () => { expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)'); }); + it('does not restore playback ahead of a superseding room command', () => { + const handler = between( + contentSource, + "message.type === 'EPISODE_SYNC_V2'", + '// Episode Auto-Sync: Legacy lobby notification from background' + ); + expect(handler).toContain("resume: transaction.reason !== 'superseded'"); + }); + + it('revalidates the complete prepared state immediately before execute', () => { + const handler = between( + contentSource, + "transaction.phase === 'execute'", + "transaction.phase === 'cancel'" + ); + expect(handler).toContain("state.phase === 'prepare'"); + expect(handler).toContain('video === findVideo()'); + expect(handler).toContain('video.isConnected !== false'); + expect(handler).toContain('video.paused'); + expect(handler).toContain('!video.seeking'); + expect(handler).toContain('video.readyState >= 3'); + expect(handler).toContain('Math.abs(current) < 1'); + }); + it('injects the shared stability window into packaged content scripts', () => { expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS'); expect(buildSource).toContain('episodeSyncStabilityVal'); diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index ef967e0..a5b0a6d 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -118,6 +118,8 @@ describe('target tab lifecycle', () => { expect(clearSource).toContain('resetUserSelectionState()'); expect(clearSource).toContain('deactivateTargetTab(previousTabId, previousContentTarget)'); expect(clearSource).toContain('selectedTabId: null'); + expect(clearSource.indexOf('selectedTabId: null')) + .toBeLessThan(clearSource.indexOf('await Promise.all(cleanupTasks)')); expect(clearSource).toContain("type: 'TARGET_TAB_CLEARED'"); expect(popupSource).toContain("refreshTargetAccessState({ autoSelectMatch: false })"); expect(popupSource).toContain('if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1)');