fix(sync): harden episode transition compatibility

This commit is contained in:
Timo
2026-09-01 07:08:39 +02:00
parent 742386064c
commit dc7eb86ce6
14 changed files with 1625 additions and 220 deletions
+13
View File
@@ -5,6 +5,19 @@ All notable changes to the KoalaSync browser extension and relay server.
--- ---
## [v3.2.0] — 2026-09-01
This release makes episode transitions relay-authoritative and safe across mixed
extension and relay versions.
### Changed
- **Reliable next-episode sync** — Quarantines automatic source-swap playback noise, waits up to 120 seconds for every participant to load, and commits playback only after every prepared player confirms execution.
- **Rolling compatibility** — Preserves the released legacy lobby contract for older extensions and relays while keeping new transactions correlated, bounded, and reconnect-safe.
### Fixed
- **Episode transition races** — Handles title/load event reordering, long metadata titles, clock skew, disconnects, execute failures, and deliberate user controls without freezing or prematurely pausing peers.
- **Room-exit target cleanup** — Clears stale selected tabs after manual leaves, inactivity removal, and room closure so reinjection works on the next selection.
## [v3.1.5] — 2026-08-25 ## [v3.1.5] — 2026-08-25
This patch fixes room-exit cleanup and excessive popup width. This patch fixes room-exit cleanup and excessive popup width.
+49 -24
View File
@@ -364,6 +364,9 @@ Otherwise, even a delayed execute is relayed to release paused receivers.
Episode lobby coordination is implemented primarily in the extension. The relay Episode lobby coordination is implemented primarily in the extension. The relay
tracks enough state to include `activeLobby` in `room_data` for later joiners. tracks enough state to include `activeLobby` in `room_data` for later joiners.
For compatibility with released extensions, ordinary legacy `play`, `pause`, and
`seek` events do not cancel an active lobby. Only the existing lobby/Force Sync
choreography or an explicit `episode_lobby_cancel` ends it.
### `episode_lobby` ### `episode_lobby`
@@ -535,36 +538,58 @@ Clients should treat a missing or unknown capabilities list as unsupported.
## Episode Sync v2 ## Episode Sync v2
Relays advertise `"episode-sync-v2"`. Updated clients use the additive Relays advertise `"episode-sync-v2"`. Updated clients use the additive
`episode_sync_v2` event without changing `PROTOCOL_VERSION`; automatic episode `episode_sync_v2` event without changing `PROTOCOL_VERSION`. When the relay
sync is skipped safely when the relay omits the capability or any current room omits the capability, or rejects START because a current peer is legacy, updated
peer did not announce it. Manual Force Sync remains on the legacy event family. clients fall back to the released legacy lobby wire. Manual Force Sync remains
on the legacy event family.
The relay creates the transaction ID, freezes non-desynced participants, owns The relay creates the transaction ID, freezes non-desynced participants, owns
deadlines, and is the only component that advances: deadlines, and is the only component that advances:
```text ```text
start -> lobby/loading -> prepare -> execute start -> lobby/loading -> prepare -> execute/executing -> complete
\-> cancel | | |
+------------------+--------------+-> cancel
``` ```
Client phases are `start`, `loaded`, `prepared`, `failed`, and `cancel`. Relay `start` carries bounded `expectedTitle` and may carry a sanitized
phases are `lobby`, `prepare`, `execute`, and `cancel`. Every post-start frame is `expectedEpisodeId` (`S01E06` or `EP006`). Relay state includes `remainingMs`,
correlated by `transactionId`; duplicate or stale frames are idempotently not an absolute relay wall-clock deadline. Client phases are `start`, `loaded`,
ignored. No participant is pre-marked loaded. `execute` is emitted exactly once `prepared`, `executed`, `failed`, `failed_execute`, and `cancel`. Relay phases
only after every frozen participant reports `loaded`, then pauses, seeks to are `lobby`, `prepare`, `execute`, `complete`, and `cancel`. Every post-start
0:00, reaches `readyState >= 3`, and remains on the same player/title in paused, frame is correlated by `transactionId`; duplicate or stale frames are
non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. idempotently ignored.
Any timeout, failed player action, participant departure, manual media command, No participant is pre-marked loaded. `execute` is emitted exactly once only
room/target change, or explicit cancellation produces `cancel`; v2 never after every frozen participant reports `loaded`, then pauses, seeks to 0:00,
executes after a timeout. Peers resume only when that transaction paused a reaches `readyState >= 3`, and remains on the same player/episode in paused,
previously playing player and no newer local or room action superseded it. A non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`. The transaction remains
superseding room command defines the next state without racing a restoration. in the relay's internal `executing` phase until every participant reports
Peers joining after `start` are excluded from the frozen barrier and receive no `executed`. Only then does the relay commit canonical `playing` at 0:00 and emit
v2 frames for that transaction. Clients revalidate the complete prepared state terminal `complete`.
again immediately before applying `execute`.
Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE, Raw `play`, `pause`, and `seek` churn is tolerated during `lobby/loading`, where
and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire players commonly replace sources. A current client can classify a fresh local
actions; an unmodified old non-initiator can still run its own local timeout, so gesture as `episodeSyncIntent: "manual"`; that command supersedes loading before
full v2 guarantees require capable extensions on every peer. the sanitized media command is relayed. During `prepare`, every such media
command supersedes the barrier and releases prepared peers. Explicit
`failed`/`cancel`, Force Sync,
participant desync/departure, any successful join or reconnect, and every phase
timeout cancel the transaction. A join always cancels rather than extending the
frozen participant set or reusing ACKs from an earlier socket. v2 never advances
to a later phase after a timeout.
If `failed_execute` or the execute-ACK deadline ends `executing`, the relay emits
`cancel` with `settlePlaybackState: "paused"` and `targetTime: 0`, and commits
canonical paused state. Clients retain transaction state until `complete` or
`cancel`, allowing that terminal settlement to reach peers that already started
playback.
The independent deadlines are 120 seconds for v2 loading, 15 seconds for
prepare, and 10 seconds for execute acknowledgements. The released legacy lobby
keeps its 60-second client-side deadline for old/new compatibility.
Legacy episode events remain accepted with their released origin/main wire
semantics. The relay does not globally bind legacy PREPARE, EXECUTE, or CANCEL
to one lobby owner because old clients provide no field that distinguishes an
automatic duplicate from an intentional manual Force Sync.
+242 -28
View File
@@ -1,7 +1,7 @@
import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_LOAD_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT, EPISODE_SYNC_V2_EXECUTE_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js'; import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, sameEpisodeStrict, extractEpisodeId } from './episode-utils.js'; import { createEpisodeWireIdentity, createLocalEpisodeDeadline, extractEpisodeId, isEpisodeSyncV2StartContextCurrent, matchesEpisodeSyncV2StartRejection, sameEpisode, sameEpisodeIdentity, toEpisodeWireTitle } from './episode-utils.js';
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
import { initTabManager } from './modules/tab-manager.js'; import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
@@ -677,6 +677,8 @@ let forceSyncTimeout = null;
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
let episodeLobbyTimeout = null; let episodeLobbyTimeout = null;
let episodeSyncV2 = null; let episodeSyncV2 = null;
let episodeSyncV2PendingStart = null;
let episodeSyncV2PendingStartId = 0;
// --- Storage Utils --- // --- Storage Utils ---
@@ -818,7 +820,9 @@ function emitEpisodeLobbyForCurrentPrivacy() {
if (episodeLobby !== lobby if (episodeLobby !== lobby
|| currentRoom?.roomId !== roomId || currentRoom?.roomId !== roomId
|| settings.roomId !== roomId) return; || settings.roomId !== roomId) return;
const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode); const expectedTitle = toEpisodeWireTitle(
sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode)
);
if (expectedTitle) { if (expectedTitle) {
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle }); emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
} }
@@ -1217,10 +1221,13 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
const transactionId = typeof value.transactionId === 'string' const transactionId = typeof value.transactionId === 'string'
? value.transactionId.substring(0, 64) ? value.transactionId.substring(0, 64)
: ''; : '';
const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : ''; const phase = value.phase === 'lobby' || value.phase === 'prepare'
const expectedTitle = typeof value.expectedTitle === 'string' ? value.phase
? value.expectedTitle.substring(0, 100) : (value.phase === 'execute' || value.phase === 'executing' ? 'execute' : '');
: ''; const expectedTitle = toEpisodeWireTitle(value.expectedTitle) || '';
const expectedEpisodeId = typeof value.expectedEpisodeId === 'string'
? value.expectedEpisodeId.substring(0, 16)
: extractEpisodeId(expectedTitle);
const initiatorPeerId = typeof value.initiatorPeerId === 'string' const initiatorPeerId = typeof value.initiatorPeerId === 'string'
? value.initiatorPeerId.substring(0, 16) ? value.initiatorPeerId.substring(0, 16)
: ''; : '';
@@ -1238,20 +1245,117 @@ function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
if (participants.length < 2 if (participants.length < 2
|| !participantSet.has(initiatorPeerId) || !participantSet.has(initiatorPeerId)
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null; || (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
const phaseTimeout = phase === 'lobby'
? EPISODE_SYNC_V2_LOAD_TIMEOUT
: (phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT);
// The relay's wall clock is not comparable to the browser's. Convert its
// bounded remaining duration into a local deadline. Older v2 relays omit
// remainingMs; a full local phase timeout is safer than trusting clock skew.
const localDeadline = createLocalEpisodeDeadline(value.remainingMs, phaseTimeout);
return { return {
transactionId, transactionId,
phase, phase,
expectedTitle, expectedTitle,
expectedEpisodeId,
initiatorPeerId, initiatorPeerId,
participants, participants,
loadedPeers, loadedPeers,
preparedPeers, preparedPeers,
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(), createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null, deadlineAt: localDeadline.deadlineAt,
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1 remainingMs: localDeadline.remainingMs,
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1,
executionReportStatus: value.executionReportStatus === 'executed'
|| value.executionReportStatus === 'failed_execute'
? value.executionReportStatus
: null,
executionReportInFlight: false
}; };
} }
function createPendingEpisodeSyncV2Start(identity, sender = null) {
return {
requestId: ++episodeSyncV2PendingStartId,
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(sender?.tab?.id ?? currentTabId),
frameId: normalizeFrameId(sender?.frameId ?? currentTargetFrameId),
documentId: sender?.documentId || currentTargetDocumentId || null,
expectedTitle: identity.expectedTitle,
expectedEpisodeId: identity.expectedEpisodeId,
requestedAt: Date.now()
};
}
function isCurrentEpisodeSyncV2Start(pending) {
return episodeSyncV2PendingStart === pending
&& isEpisodeSyncV2StartContextCurrent(pending, {
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(currentTabId)
});
}
function startLegacyEpisodeLobbyForTransition(identity, pending = null) {
if (!identity?.expectedTitle || !identity.expectedEpisodeId) return 'invalid_identity';
if (pending && !isCurrentEpisodeSyncV2Start(pending)) return 'stale_session';
// Legacy has no separate expectedEpisodeId field. If the 100-code-unit
// title clamp removed the episode marker, use the canonical ID itself so
// old content scripts can still match the local full title.
const lobbyTitle = extractEpisodeId(identity.expectedTitle)
? identity.expectedTitle
: identity.expectedEpisodeId;
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
if (!episodeLobby.readyPeers.includes(peerId)) {
if (!emitLive(EVENTS.EPISODE_READY, {
peerId,
title: lobbyTitle,
expectedTitle: episodeLobby.expectedTitle
})) return 'offline';
episodeLobby.readyPeers.push(peerId);
persistEpisodeLobby();
broadcastLobbyUpdate();
checkEpisodeLobbyCompletion();
}
return 'ready_sent';
}
if (episodeLobby) clearEpisodeLobbyState();
if (!emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })) return 'offline';
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
episodeLobby = {
expectedTitle: lobbyTitle,
initiatorPeerId: peerId,
readyPeers: [peerId],
createdAt: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Episode lobby created via compatibility fallback: "${lobbyTitle}"`, 'info');
const targetTabId = pending?.tabId ?? normalizeTabId(currentTabId);
if (targetTabId !== null) {
sendMessageToFrame(
targetTabId,
pending?.frameId ?? currentTargetFrameId,
{ type: 'PAUSE_FOR_LOBBY', expectedTitle: lobbyTitle },
null,
pending?.documentId ?? currentTargetDocumentId
).catch(() => {});
}
episodeLobbyTimeout = setTimeout(
() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'),
EPISODE_LOBBY_TIMEOUT
);
checkEpisodeLobbyCompletion();
return 'lobby_created';
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
return clearTargetSelectionForLifecycle({ return clearTargetSelectionForLifecycle({
expectedTabId, expectedTabId,
@@ -1266,6 +1370,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
reconnectFailed = false; reconnectFailed = false;
reconnectAttempts = 0; reconnectAttempts = 0;
reconnectStartTime = null; reconnectStartTime = null;
episodeSyncV2PendingStart = null;
completeForceSyncBeforeTargetChange(null); completeForceSyncBeforeTargetChange(null);
if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId }); if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
@@ -2272,7 +2377,13 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2; currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
persistEpisodeSyncV2(); persistEpisodeSyncV2();
broadcastLobbyUpdate(); broadcastLobbyUpdate();
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {}); if (shouldNotifyContent) {
if (authoritativeEpisodeSyncV2.phase === 'execute') {
executeEpisodeSyncV2FromRelay(authoritativeEpisodeSyncV2).catch(() => {});
} else {
sendEpisodeSyncV2ToContent().catch(() => {});
}
}
} else if (episodeSyncV2) { } else if (episodeSyncV2) {
clearEpisodeSyncV2State({ reason: 'relay_state_ended' }); clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
} else { } else {
@@ -2701,6 +2812,28 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
} }
const phase = data.phase; const phase = data.phase;
if (phase === 'cancel' && !data.transactionId) { if (phase === 'cancel' && !data.transactionId) {
const pending = episodeSyncV2PendingStart;
const matchesPending = episodeSyncV2PendingStart === pending
&& matchesEpisodeSyncV2StartRejection(pending, {
roomId: currentRoom?.roomId || null,
connectionGeneration,
targetGeneration: targetActivationGeneration,
tabId: normalizeTabId(currentTabId)
}, data);
if (!matchesPending) {
addLog(`Ignored stale Episode Sync v2 rejection: ${data.reason || 'rejected'}`, 'info');
break;
}
if (data.reason === 'capability_mismatch') {
const fallbackStatus = startLegacyEpisodeLobbyForTransition({
expectedTitle: pending.expectedTitle,
expectedEpisodeId: pending.expectedEpisodeId
}, pending);
episodeSyncV2PendingStart = null;
addLog(`Episode Sync v2 unavailable; compatibility fallback: ${fallbackStatus}`, 'warn');
break;
}
episodeSyncV2PendingStart = null;
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn'); addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
break; break;
} }
@@ -2728,6 +2861,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
clearEpisodeSyncV2State({ reason: 'transaction_replaced' }); clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
} }
if (episodeLobby) clearEpisodeLobbyState(); if (episodeLobby) clearEpisodeLobbyState();
if (episodeSyncV2PendingStart
&& incoming.expectedTitle === episodeSyncV2PendingStart.expectedTitle
&& incoming.expectedEpisodeId === episodeSyncV2PendingStart.expectedEpisodeId) {
episodeSyncV2PendingStart = null;
}
episodeSyncV2 = incoming; episodeSyncV2 = incoming;
if (currentRoom) currentRoom.episodeSyncV2 = incoming; if (currentRoom) currentRoom.episodeSyncV2 = incoming;
persistEpisodeSyncV2(); persistEpisodeSyncV2();
@@ -2736,14 +2874,16 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info'); addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
break; break;
} }
if ((phase === 'execute' || phase === 'cancel') if ((phase === 'execute' || phase === 'complete' || phase === 'cancel')
&& episodeSyncV2 && episodeSyncV2
&& data.transactionId === episodeSyncV2.transactionId) { && data.transactionId === episodeSyncV2.transactionId) {
const completed = episodeSyncV2; const completed = episodeSyncV2;
if (phase === 'execute') { if (phase === 'execute') {
await executeEpisodeSyncV2FromRelay(data);
} else if (phase === 'complete') {
sendMessageToCurrentContent({ sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2', type: 'EPISODE_SYNC_V2',
transaction: { ...completed, phase: 'execute', targetTime: 0 } transaction: { ...completed, phase: 'complete', targetTime: 0 }
}).catch(() => {}); }).catch(() => {});
if (currentRoom && Array.isArray(currentRoom.peers)) { if (currentRoom && Array.isArray(currentRoom.peers)) {
currentRoom.peers.forEach(candidate => { currentRoom.peers.forEach(candidate => {
@@ -2758,7 +2898,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' }); clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success'); addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
} else { } else {
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' }); clearEpisodeSyncV2State({
reason: data.reason || 'cancelled',
relayState: data
});
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn'); addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
} }
} }
@@ -2928,6 +3071,7 @@ function completeForceSyncBeforeTargetChange(nextTabId) {
const normalizedNextTabId = normalizeTabId(nextTabId); const normalizedNextTabId = normalizeTabId(nextTabId);
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return; if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
episodeSyncV2PendingStart = null;
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed'); if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
if (!isForceSyncInitiator) return; if (!isForceSyncInitiator) return;
@@ -2945,7 +3089,7 @@ function episodeLobbyForUi() {
return { return {
expectedTitle: episodeSyncV2.expectedTitle, expectedTitle: episodeSyncV2.expectedTitle,
initiatorPeerId: episodeSyncV2.initiatorPeerId, initiatorPeerId: episodeSyncV2.initiatorPeerId,
readyPeers: episodeSyncV2.phase === 'prepare' readyPeers: episodeSyncV2.phase === 'prepare' || episodeSyncV2.phase === 'execute'
? [...episodeSyncV2.preparedPeers] ? [...episodeSyncV2.preparedPeers]
: [...episodeSyncV2.loadedPeers], : [...episodeSyncV2.loadedPeers],
createdAt: episodeSyncV2.createdAt, createdAt: episodeSyncV2.createdAt,
@@ -2972,7 +3116,60 @@ function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) {
}); });
} }
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) { async function executeEpisodeSyncV2FromRelay(relayState) {
const transaction = episodeSyncV2;
if (!transaction
|| !relayState
|| relayState.transactionId !== transaction.transactionId
|| transaction.executionReportInFlight
|| transaction.executionReportStatus) return false;
transaction.phase = 'execute';
const remainingMs = Number.isFinite(relayState.remainingMs)
? Math.max(0, Math.min(EPISODE_SYNC_V2_EXECUTE_TIMEOUT, relayState.remainingMs))
: EPISODE_SYNC_V2_EXECUTE_TIMEOUT;
transaction.remainingMs = remainingMs;
transaction.deadlineAt = Date.now() + remainingMs;
transaction.executionReportInFlight = true;
persistEpisodeSyncV2();
broadcastLobbyUpdate();
let response = null;
try {
response = await sendMessageToCurrentContent({
type: 'EPISODE_SYNC_V2',
transaction: {
...transaction,
phase: 'execute',
targetTime: 0
}
});
} catch (error) {
addLog(`Episode Sync v2 execute content error: ${error.message}`, 'warn');
}
if (episodeSyncV2 !== transaction) return false;
const reportStatus = response?.status === 'executed' ? 'executed' : 'failed_execute';
transaction.executionReportInFlight = false;
const sent = emitLive(EVENTS.EPISODE_SYNC_V2, {
phase: reportStatus,
transactionId: transaction.transactionId,
reason: reportStatus === 'failed_execute'
? (typeof response?.reason === 'string' ? response.reason.substring(0, 32) : 'content_execute_failed')
: undefined
});
transaction.executionReportStatus = sent ? reportStatus : null;
persistEpisodeSyncV2();
addLog(
sent
? `Episode Sync v2 execute result sent: ${reportStatus}`
: `Episode Sync v2 execute result could not be sent: ${reportStatus}`,
reportStatus === 'executed' && sent ? 'info' : 'warn'
);
return sent;
}
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled', relayState = null } = {}) {
const previous = episodeSyncV2; const previous = episodeSyncV2;
episodeSyncV2 = null; episodeSyncV2 = null;
if (currentRoom) currentRoom.episodeSyncV2 = null; if (currentRoom) currentRoom.episodeSyncV2 = null;
@@ -2983,6 +3180,7 @@ function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' }
type: 'EPISODE_SYNC_V2', type: 'EPISODE_SYNC_V2',
transaction: { transaction: {
...previous, ...previous,
...(relayState && typeof relayState === 'object' ? relayState : {}),
phase: 'cancel', phase: 'cancel',
reason reason
} }
@@ -5623,7 +5821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const newTitle = message.payload && message.payload.newTitle; const newTitle = message.payload && message.payload.newTitle;
if (newTitle && extractEpisodeId(newTitle) === null) { if (newTitle && extractEpisodeId(newTitle) === null) {
addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info'); addLog(`Episode change detected ("${toEpisodeWireTitle(newTitle) || 'unknown'}") but no episode ID was found; ignoring.`, 'info');
sendResponse({ status: 'not_an_episode' }); sendResponse({ status: 'not_an_episode' });
return; return;
} }
@@ -5637,12 +5835,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ignored_stale_session' }); sendResponse({ status: 'ignored_stale_session' });
return; return;
} }
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode); const sharedLobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
if (!lobbyTitle) { const episodeIdentity = createEpisodeWireIdentity(sharedLobbyTitle);
if (!episodeIdentity) {
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info'); addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
sendResponse({ status: 'title_privacy_no_lobby' }); sendResponse({ status: 'title_privacy_no_lobby' });
return; return;
} }
const lobbyTitle = episodeIdentity.expectedTitle;
// Check setting // Check setting
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']); const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
@@ -5680,21 +5880,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return; return;
} }
// Automatic episode sync v2 is relay-owned. Never self-pause or fall // Prefer the relay-owned barrier. Old relays and mixed rooms retain the
// back to the legacy client-owned lobby: on an old/mixed relay that path // established legacy lobby, but only from this exact room/target/start
// can create multiple Force Sync initiators. Manual Force Sync remains // context so a delayed rejection cannot resurrect stale automation.
// available and unchanged.
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) { if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn'); const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender);
sendResponse({ status: 'episode_sync_v2_unsupported' }); episodeSyncV2PendingStart = pending;
const fallbackStatus = startLegacyEpisodeLobbyForTransition(episodeIdentity, pending);
episodeSyncV2PendingStart = null;
addLog(`Episode change ("${lobbyTitle}") — legacy relay fallback: ${fallbackStatus}.`, 'warn');
sendResponse({ status: fallbackStatus });
return; return;
} }
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) { if (episodeSyncV2
&& episodeSyncV2.expectedTitle === episodeIdentity.expectedTitle
&& episodeSyncV2.expectedEpisodeId === episodeIdentity.expectedEpisodeId) {
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId }); sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
return; return;
} }
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode'); if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) { const pending = createPendingEpisodeSyncV2Start(episodeIdentity, sender);
episodeSyncV2PendingStart = pending;
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })) {
if (episodeSyncV2PendingStart === pending) episodeSyncV2PendingStart = null;
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn'); addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
sendResponse({ status: 'episode_sync_v2_offline' }); sendResponse({ status: 'episode_sync_v2_offline' });
return; return;
@@ -5725,7 +5933,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' }); sendResponse({ status: 'ok' });
return; return;
} }
const readyTitle = sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode); const readyTitle = toEpisodeWireTitle(
sanitizeSharedTitle(message.payload.title, settings.mediaTitlePrivacyMode)
);
lobby.readyPeers.push(peerId); lobby.readyPeers.push(peerId);
persistEpisodeLobby(); persistEpisodeLobby();
broadcastLobbyUpdate(); broadcastLobbyUpdate();
@@ -5758,7 +5968,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} }
const localTitle = message.payload?.title; const localTitle = message.payload?.title;
if (localPhase !== 'failed' if (localPhase !== 'failed'
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) { && (typeof localTitle !== 'string' || !sameEpisodeIdentity(
localTitle,
transaction.expectedTitle,
transaction.expectedEpisodeId
))) {
sendResponse({ status: 'ignored_episode_mismatch' }); sendResponse({ status: 'ignored_episode_mismatch' });
return; return;
} }
+480 -53
View File
@@ -331,8 +331,17 @@
let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush let pendingPlayPauseVideo = null; // source element for rejecting a stale trailing flush
// --- Episode Auto-Sync State --- // --- Episode Auto-Sync State ---
const EPISODE_TRANSITION_QUARANTINE_MS = 2000;
const EPISODE_TRANSITION_CANDIDATE_MS = 30000;
const EPISODE_TRANSITION_POLL_MS = 250;
const EPISODE_TRANSITION_END_WINDOW_SECONDS = 30;
let lastKnownMediaTitle = null; let lastKnownMediaTitle = null;
let episodeTransitionDebounce = null; let episodeTransitionDebounce = null;
let episodeTransitionCandidate = null;
let episodeTransitionPollTimer = null;
let episodeTransitionQuarantine = null;
let episodeTransitionNoiseVideo = null;
let episodeTransitionNoiseUntil = 0;
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby) let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
let lobbyPollTimer = null; let lobbyPollTimer = null;
let episodeSyncV2State = null; let episodeSyncV2State = null;
@@ -354,6 +363,10 @@
if (destroyed) return; if (destroyed) return;
if (area === 'local' && changes.autoSyncNextEpisode) { if (area === 'local' && changes.autoSyncNextEpisode) {
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false; _autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
if (!_autoSyncEnabled) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
flushEpisodeTransitionQuarantine();
}
} }
if (area === 'local' && changes.audioSettings) { if (area === 'local' && changes.audioSettings) {
_audioSettings = mergeAudioSettings(changes.audioSettings.newValue); _audioSettings = mergeAudioSettings(changes.audioSettings.newValue);
@@ -1090,6 +1103,13 @@
// Returns null if no episode pattern found. // Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START --- // --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.cjs // This block is automatically replaced by /scripts/build-extension.cjs
const EPISODE_WIRE_TITLE_LENGTH = 100;
function toEpisodeWireTitle(title) {
if (typeof title !== 'string' || title.length === 0) return null;
return title.substring(0, EPISODE_WIRE_TITLE_LENGTH);
}
function extractEpisodeId(title) { function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null; if (!title || typeof title !== 'string') return null;
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
@@ -1125,10 +1145,24 @@
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced // it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID. // S/E-only titles still fall back to the canonical episode ID.
function sameEpisodeStrict(titleA, titleB) { function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false; const wireTitleA = toEpisodeWireTitle(titleA);
const contextA = episodeContext(titleA); const wireTitleB = toEpisodeWireTitle(titleB);
const contextB = episodeContext(titleB); if (!sameEpisode(wireTitleA, wireTitleB)) return false;
return !contextA || !contextB || contextA === contextB; const contextA = episodeContext(wireTitleA);
const contextB = episodeContext(wireTitleB);
if (!contextA || !contextB || contextA === contextB) return true;
const [shorter, longer] = contextA.length <= contextB.length
? [contextA, contextB]
: [contextB, contextA];
return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `);
}
function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) {
const normalizedExpectedId = typeof expectedEpisodeId === 'string'
? expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: null;
if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false;
return sameEpisodeStrict(localTitle, expectedTitle);
} }
// --- SHARED_EPISODE_UTILS_INJECT_END --- // --- SHARED_EPISODE_UTILS_INJECT_END ---
@@ -1142,26 +1176,177 @@
if (!idA || !idB) return false; // At least one unparseable → allow if (!idA || !idB) return false; // At least one unparseable → allow
return idA !== idB; // Both parseable → only block if different return idA !== idB; // Both parseable → only block if different
} }
function checkEpisodeTransition() { function getEpisodeSource(video) {
return video ? (video.currentSrc || video.src || null) : null;
}
function isNearEpisodeBoundary(video, current = getSyncCurrentTime(video)) {
if (!video || current === null) return false;
if (video.ended) return true;
const duration = getSyncDuration(video);
const boundaryWindow = Math.min(
EPISODE_TRANSITION_END_WINDOW_SECONDS,
Math.max(3, duration * 0.05)
);
return duration > 0 && current >= Math.max(0, duration - boundaryWindow);
}
function isAutomaticTerminalEvent(video, current = getSyncCurrentTime(video)) {
if (!video || current === null) return false;
if (video.ended) return true;
const duration = getSyncDuration(video);
return duration > 0
&& duration - current <= 1.5
&& hcmClassifyIntent() !== 'deliberate';
}
function clearEpisodeTransitionPoll() {
if (!episodeTransitionPollTimer) return;
clearTimeout(episodeTransitionPollTimer);
episodeTransitionPollTimer = null;
}
function clearEpisodeTransitionCandidate({ commitCurrentTitle = false } = {}) {
clearEpisodeTransitionPoll();
episodeTransitionCandidate = null;
if (commitCurrentTitle) {
const title = getMediaTitle();
if (title) lastKnownMediaTitle = title;
}
}
function scheduleEpisodeTransitionPoll() {
if (destroyed || pageSuspended || episodeTransitionPollTimer || !episodeTransitionCandidate) return;
episodeTransitionPollTimer = setTimeout(() => {
episodeTransitionPollTimer = null;
if (destroyed || pageSuspended || !episodeTransitionCandidate) return;
if (Date.now() >= episodeTransitionCandidate.deadlineAt) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
flushEpisodeTransitionQuarantine();
return;
}
checkEpisodeTransition('poll');
scheduleEpisodeTransitionPoll();
}, EPISODE_TRANSITION_POLL_MS);
}
function ensureEpisodeTransitionCandidate(video, reason, {
baselineSource = lastVideoSrc,
nearBoundary = false,
transitionLike = false,
quarantineEvents = false
} = {}) {
if (!_autoSyncEnabled || !video || !lastKnownMediaTitle) return null;
const source = getEpisodeSource(video);
if (!episodeTransitionCandidate || episodeTransitionCandidate.video !== video) {
clearEpisodeTransitionCandidate();
episodeTransitionCandidate = {
video,
baselineTitle: lastKnownMediaTitle,
baselineSource: baselineSource === undefined ? source : baselineSource,
startedAt: Date.now(),
deadlineAt: Date.now() + EPISODE_TRANSITION_CANDIDATE_MS,
nearBoundary: nearBoundary === true,
transitionLike: transitionLike === true,
quarantineEvents: quarantineEvents === true || transitionLike === true,
reason
};
} else {
episodeTransitionCandidate.nearBoundary ||= nearBoundary === true;
episodeTransitionCandidate.transitionLike ||= transitionLike === true;
episodeTransitionCandidate.quarantineEvents ||= quarantineEvents === true || transitionLike === true;
episodeTransitionCandidate.reason = reason || episodeTransitionCandidate.reason;
}
scheduleEpisodeTransitionPoll();
return episodeTransitionCandidate;
}
function discardEpisodeTransitionQuarantine() {
const quarantine = episodeTransitionQuarantine;
episodeTransitionQuarantine = null;
if (quarantine?.timer) clearTimeout(quarantine.timer);
}
function checkEpisodeTransition(reason = 'signal') {
const currentTitle = getMediaTitle(); const currentTitle = getMediaTitle();
const video = findVideo(); const video = findVideo();
if (!video) return false;
const current = video ? getSyncCurrentTime(video) : null; const current = getSyncCurrentTime(video);
// Only trigger if: we had a previous title, the title changed, const source = getEpisodeSource(video);
// a video exists, and we're near the start of new content. if (!lastKnownMediaTitle) {
if (lastKnownMediaTitle && currentTitle if (currentTitle) lastKnownMediaTitle = currentTitle;
&& !sameEpisode(currentTitle, lastKnownMediaTitle) return false;
&& extractEpisodeId(currentTitle) !== null
&& video
&& current !== null && current < 5
&& video.readyState >= 1) {
onEpisodeTransition(currentTitle);
} }
// Always track the latest known title const candidate = episodeTransitionCandidate;
if (currentTitle) lastKnownMediaTitle = currentTitle; const baselineTitle = candidate?.video === video
? candidate.baselineTitle
: lastKnownMediaTitle;
const baselineSource = candidate?.video === video
? candidate.baselineSource
: lastVideoSrc;
const titleChanged = !!currentTitle && !sameEpisode(currentTitle, baselineTitle);
const sourceChanged = baselineSource !== undefined
&& !!baselineSource
&& !!source
&& source !== baselineSource;
const nearBoundary = isNearEpisodeBoundary(video, current);
const nearNewStart = current !== null && current < 5;
const transitionSignal = titleChanged
|| sourceChanged
|| reason === 'source_changed'
|| reason === 'loadstart'
|| reason === 'emptied'
|| reason === 'loadeddata'
|| reason === 'player_changed'
|| candidate?.nearBoundary;
if (transitionSignal) {
const nextCandidate = ensureEpisodeTransitionCandidate(video, reason, {
baselineSource,
nearBoundary,
transitionLike: titleChanged
|| (sourceChanged && (nearNewStart || candidate?.nearBoundary === true)),
quarantineEvents: titleChanged
|| sourceChanged
|| reason === 'player_changed'
|| candidate?.nearBoundary === true
});
if (nextCandidate && nearNewStart
&& (sourceChanged || reason === 'loadeddata')
&& nextCandidate.nearBoundary) {
nextCandidate.transitionLike = true;
}
}
// Commit the title baseline only after the replacement is actually usable.
// This keeps both title-before-loadeddata and loadeddata-before-title alive
// as pending candidates instead of consuming the only transition signal.
if (titleChanged
&& extractEpisodeId(currentTitle) !== null
&& current !== null && current < 5
&& video.readyState >= 1) {
lastKnownMediaTitle = currentTitle;
clearEpisodeTransitionCandidate();
discardEpisodeTransitionQuarantine();
episodeTransitionNoiseVideo = video;
episodeTransitionNoiseUntil = Date.now() + EPISODE_TRANSITION_QUARANTINE_MS;
cancelPlayPauseCoalesce();
if (seekDebounceTimer) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
}
onEpisodeTransition(currentTitle);
return true;
}
// Harmless metadata reformatting for the same canonical episode may update
// the baseline. A genuinely different episode stays pending until ready.
if (currentTitle && sameEpisode(currentTitle, baselineTitle)) {
lastKnownMediaTitle = currentTitle;
}
return false;
} }
function onEpisodeTransition(newTitle) { function onEpisodeTransition(newTitle) {
@@ -1304,7 +1489,7 @@
&& video && video
&& video === findVideo() && video === findVideo()
&& video.isConnected !== false && video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle) && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused; && video.paused;
if (mayResume) { if (mayResume) {
const resumed = await tryMediaAction(EVENTS.PLAY); const resumed = await tryMediaAction(EVENTS.PLAY);
@@ -1318,8 +1503,8 @@
const title = getMediaTitle(); const title = getMediaTitle();
const matches = video const matches = video
&& title && title
&& sameEpisodeStrict(title, state.expectedTitle) && sameEpisodeIdentity(title, state.expectedTitle, state.expectedEpisodeId)
&& video.readyState >= 1 && video.readyState >= 3
&& getSyncCurrentTime(video) !== null; && getSyncCurrentTime(video) !== null;
if (!matches) { if (!matches) {
state.loadCandidateVideo = null; state.loadCandidateVideo = null;
@@ -1339,8 +1524,19 @@
function startEpisodeSyncV2Lobby(transaction) { function startEpisodeSyncV2Lobby(transaction) {
if (!transaction?.transactionId || !transaction.expectedTitle) return; if (!transaction?.transactionId || !transaction.expectedTitle) return;
// Any held source-swap media events predate the relay-owned transaction.
// They are transition noise, not a newer intent that may cancel the lobby.
discardEpisodeTransitionQuarantine();
cancelPlayPauseCoalesce();
if (seekDebounceTimer) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
}
if (episodeSyncV2State?.transactionId === transaction.transactionId) { if (episodeSyncV2State?.transactionId === transaction.transactionId) {
episodeSyncV2State.phase = 'lobby'; episodeSyncV2State.phase = 'lobby';
episodeSyncV2State.expectedEpisodeId = typeof transaction.expectedEpisodeId === 'string'
? transaction.expectedEpisodeId
: episodeSyncV2State.expectedEpisodeId;
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt) episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
? transaction.deadlineAt ? transaction.deadlineAt
: episodeSyncV2State.deadlineAt; : episodeSyncV2State.deadlineAt;
@@ -1351,6 +1547,9 @@
episodeSyncV2State = { episodeSyncV2State = {
transactionId: transaction.transactionId, transactionId: transaction.transactionId,
expectedTitle: transaction.expectedTitle, expectedTitle: transaction.expectedTitle,
expectedEpisodeId: typeof transaction.expectedEpisodeId === 'string'
? transaction.expectedEpisodeId
: null,
phase: 'lobby', phase: 'lobby',
generation: episodeSyncV2Generation, generation: episodeSyncV2Generation,
loadedReported: false, loadedReported: false,
@@ -1365,7 +1564,9 @@
pausedByTransaction: false, pausedByTransaction: false,
manualAction: false, manualAction: false,
programmaticPausePending: false, programmaticPausePending: false,
prepareStarted: false prepareStarted: false,
executePromise: null,
executeResult: null
}; };
stopEpisodeSyncV2Poll(); stopEpisodeSyncV2Poll();
checkEpisodeSyncV2Loaded(episodeSyncV2State); checkEpisodeSyncV2Loaded(episodeSyncV2State);
@@ -1384,7 +1585,7 @@
&& state.phase === 'prepare' && state.phase === 'prepare'
&& video === findVideo() && video === findVideo()
&& video.isConnected !== false && video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle) && sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused && video.paused
&& !video.seeking && !video.seeking
&& video.readyState >= 3 && video.readyState >= 3
@@ -1417,6 +1618,9 @@
} }
const state = episodeSyncV2State; const state = episodeSyncV2State;
if (!state || state.transactionId !== transaction.transactionId) return; if (!state || state.transactionId !== transaction.transactionId) return;
if (typeof transaction.expectedEpisodeId === 'string') {
state.expectedEpisodeId = transaction.expectedEpisodeId;
}
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt; state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
if (state.prepareStarted) return; if (state.prepareStarted) return;
stopEpisodeSyncV2Poll(); stopEpisodeSyncV2Poll();
@@ -1424,7 +1628,8 @@
state.prepareStarted = true; state.prepareStarted = true;
const video = findVideo(); const video = findVideo();
const currentTitle = getMediaTitle(); const currentTitle = getMediaTitle();
if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) { if (!video || !currentTitle
|| !sameEpisodeIdentity(currentTitle, state.expectedTitle, state.expectedEpisodeId)) {
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) { if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch'); retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
} }
@@ -1479,6 +1684,56 @@
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {}); clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
} }
function executeEpisodeSyncV2(transaction) {
const state = episodeSyncV2State;
if (!state || state.transactionId !== transaction.transactionId) {
return Promise.resolve({ status: 'failed_execute', reason: 'stale_transaction' });
}
if (state.executeResult) return Promise.resolve(state.executeResult);
if (state.executePromise) return state.executePromise;
const video = state.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = video
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
if (!canExecute) {
state.executeResult = { status: 'failed_execute', reason: 'prepared_state_changed' };
return Promise.resolve(state.executeResult);
}
state.executePromise = Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
if (!isEpisodeSyncV2Current(state)) {
return { status: 'failed_execute', reason: 'stale_transaction' };
}
if (!applied) {
reportLog('Episode Sync v2 execute could not start playback', 'warn');
state.executeResult = { status: 'failed_execute', reason: 'play_failed' };
return state.executeResult;
}
scheduleProactiveHeartbeat();
state.executeResult = { status: 'executed' };
return state.executeResult;
}).catch(error => {
reportLog(`Episode Sync v2 execute failed: ${error.message}`, 'warn');
if (isEpisodeSyncV2Current(state)) {
state.executeResult = { status: 'failed_execute', reason: 'play_failed' };
return state.executeResult;
}
return { status: 'failed_execute', reason: 'stale_transaction' };
}).finally(() => {
if (isEpisodeSyncV2Current(state)) state.executePromise = null;
});
return state.executePromise;
}
function getPlayerActionFixes() { function getPlayerActionFixes() {
return [ return [
{ {
@@ -1980,36 +2235,51 @@
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn'); reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
}); });
} else if (transaction.phase === 'execute') { } else if (transaction.phase === 'execute') {
const state = episodeSyncV2State;
const video = state?.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = state
&& state.transactionId === transaction.transactionId
&& state.phase === 'prepare'
&& video === findVideo()
&& video.isConnected !== false
&& sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
if (!canExecute) {
sendResponse({ status: 'failed_execute', reason: 'prepared_state_changed' });
return true;
}
executeEpisodeSyncV2(transaction).then(sendResponse).catch(() => {
sendResponse({ status: 'failed_execute', reason: 'unexpected_error' });
});
return true;
} else if (transaction.phase === 'complete') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) { 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()
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
clearEpisodeSyncV2Content({ resume: false }).catch(() => {}); 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') { } else if (transaction.phase === 'cancel') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) { if (episodeSyncV2State?.transactionId === transaction.transactionId) {
const settlePaused = transaction.settlePlaybackState === 'paused';
// A superseding room command follows this cancellation on // A superseding room command follows this cancellation on
// the same ordered socket. Do not race it with restoration // the same ordered socket. Do not race it with restoration
// of the pre-transaction play state. // of the pre-transaction play state.
clearEpisodeSyncV2Content({ resume: transaction.reason !== 'superseded' }).catch(() => {}); clearEpisodeSyncV2Content({
resume: !settlePaused && transaction.reason !== 'superseded'
}).then(async () => {
if (!settlePaused) return;
const video = findVideo();
if (!video || video.isConnected === false) return;
const paused = video.paused || await tryMediaAction(EVENTS.PAUSE);
const targetTime = Number.isFinite(transaction.targetTime)
? transaction.targetTime
: 0;
if (paused) await tryMediaAction(EVENTS.SEEK, { targetTime });
scheduleProactiveHeartbeat();
}).catch(() => {});
} }
} }
sendResponse({ status: 'ok' }); sendResponse({ status: 'ok' });
@@ -2215,6 +2485,10 @@
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
const episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'
&& hcmClassifyIntent() === 'deliberate'
? 'manual'
: undefined;
runtimeMessage({ runtimeMessage({
type: 'CONTENT_EVENT', type: 'CONTENT_EVENT',
action, action,
@@ -2222,7 +2496,8 @@
currentTime: current, currentTime: current,
targetTime: current, targetTime: current,
mediaTitle: mediaTitle, mediaTitle: mediaTitle,
timestamp: Date.now() timestamp: Date.now(),
episodeSyncIntent
} }
}).catch(() => {}); }).catch(() => {});
@@ -2230,6 +2505,114 @@
scheduleProactiveHeartbeat(); scheduleProactiveHeartbeat();
} }
function cancelPlayPauseCoalesce() {
if (playPauseCoalesceTimer) clearTimeout(playPauseCoalesceTimer);
playPauseCoalesceTimer = null;
pendingPlayPauseAction = null;
pendingPlayPauseVideo = null;
}
function v2LoadingEventIsChurn(video) {
const state = episodeSyncV2State;
if (!state || state.phase !== 'lobby') return false;
if (hcmClassifyIntent() === 'deliberate') return false;
// Before this player has stably reported the expected episode, native
// media events are part of loading by definition. Once loaded, require a
// fresh real input gesture before allowing a command to supersede v2.
if (!state.loadedReported) return true;
if (video !== findVideo() || video.readyState < 3 || video.seeking) return true;
return hcmClassifyIntent() !== 'deliberate';
}
function queueEpisodeTransitionEvent(action, video) {
let quarantine = episodeTransitionQuarantine;
if (!quarantine || quarantine.video !== video) {
discardEpisodeTransitionQuarantine();
quarantine = {
video,
sawPlayback: false,
seekPending: false,
timer: null
};
episodeTransitionQuarantine = quarantine;
quarantine.timer = setTimeout(flushEpisodeTransitionQuarantine, EPISODE_TRANSITION_QUARANTINE_MS);
}
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) quarantine.sawPlayback = true;
if (action === EVENTS.SEEK) quarantine.seekPending = true;
}
function flushEpisodeTransitionQuarantine() {
const quarantine = episodeTransitionQuarantine;
if (!quarantine) return;
episodeTransitionQuarantine = null;
if (quarantine.timer) clearTimeout(quarantine.timer);
const video = quarantine.video;
if (destroyed || pageSuspended || !video || video !== activeVideo || video !== findVideo()
|| video.isConnected === false) return;
// Give a late MediaSession update one final synchronous chance to turn
// this candidate into a confirmed episode transition.
if (checkEpisodeTransition('quarantine_timeout')) return;
if (v2LoadingEventIsChurn(video)) return;
if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) return;
if (episodeTransitionCandidate?.video === video
&& episodeTransitionCandidate.transitionLike) return;
if (episodeTransitionCandidate?.video === video) {
clearEpisodeTransitionCandidate({ commitCurrentTitle: true });
}
// No transition materialized within the bounded window: preserve the
// user's final settled intent with fresh time/title rather than replaying
// the stale first edge that opened the quarantine.
if (quarantine.seekPending) sendContentEvent(EVENTS.SEEK, video);
if (quarantine.sawPlayback) {
sendContentEvent(video.paused ? EVENTS.PAUSE : EVENTS.PLAY, video);
}
}
function shouldQuarantineEpisodeEvent(action, video, current) {
if (!_autoSyncEnabled) return 'relay';
if (v2LoadingEventIsChurn(video)) return 'quarantine';
// A fresh pointer/keyboard gesture is explicit user intent, including
// near an episode boundary. Never add transition latency to it.
if (hcmClassifyIntent() === 'deliberate') return 'relay';
if (episodeTransitionNoiseVideo === video && Date.now() < episodeTransitionNoiseUntil) {
return 'quarantine';
}
const title = getMediaTitle();
const source = getEpisodeSource(video);
const candidate = episodeTransitionCandidate?.video === video
? episodeTransitionCandidate
: null;
const baselineTitle = candidate?.baselineTitle || lastKnownMediaTitle;
const baselineSource = candidate?.baselineSource !== undefined
? candidate.baselineSource
: lastVideoSrc;
const titleChanged = !!baselineTitle && !!title && !sameEpisode(title, baselineTitle);
const sourceChanged = baselineSource !== undefined
&& !!baselineSource
&& !!source
&& source !== baselineSource;
const nearBoundary = isNearEpisodeBoundary(video, current);
const automaticTerminal = isAutomaticTerminalEvent(video, current);
if (!candidate?.quarantineEvents && !titleChanged && !sourceChanged && !nearBoundary) return 'relay';
const pending = ensureEpisodeTransitionCandidate(video, `media_${action}`, {
baselineSource,
nearBoundary,
transitionLike: automaticTerminal
|| titleChanged
|| (sourceChanged && (current < 5 || candidate?.nearBoundary === true)),
quarantineEvents: true
});
if (pending && sourceChanged && current < 5 && pending.nearBoundary) {
pending.transitionLike = true;
}
return checkEpisodeTransition(`media_${action}`) ? 'discard' : 'quarantine';
}
// Trailing-edge flush of a coalesced play/pause burst: emit the final settled // Trailing-edge flush of a coalesced play/pause burst: emit the final settled
// state. No-ops only when no burst followed the leading edge. We do NOT skip // state. No-ops only when no burst followed the leading edge. We do NOT skip
// when the state matches the leading send — a remote command may have changed // when the state matches the leading send — a remote command may have changed
@@ -2298,6 +2681,13 @@
// Play/Pause pass through — user may want to immediately pause after tabbing back. // Play/Pause pass through — user may want to immediately pause after tabbing back.
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return; if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
const transitionDisposition = shouldQuarantineEpisodeEvent(action, video, current);
if (transitionDisposition === 'discard') return;
if (transitionDisposition === 'quarantine') {
queueEpisodeTransitionEvent(action, video);
return;
}
// Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The // Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The
// synchronous gates above have already run; only the network emit is // synchronous gates above have already run; only the network emit is
// governed here. Leading edge sends the first event instantly; further // governed here. Leading edge sends the first event instantly; further
@@ -2493,9 +2883,31 @@
let lastVideoSrc = undefined; let lastVideoSrc = undefined;
// Episode detection handler for loadeddata event // Episode detection signals deliberately keep polling after either side of
// the common metadata race. Some players update MediaSession before media is
// ready; others fire loadeddata before updating the title.
const handleLoadedData = event => { const handleLoadedData = event => {
if (isCurrentVideoEvent(event)) checkEpisodeTransition(); if (isCurrentVideoEvent(event)) checkEpisodeTransition('loadeddata');
};
const handleLoadStart = event => {
if (!isCurrentVideoEvent(event)) return;
cancelPlayPauseCoalesce();
checkEpisodeTransition('loadstart');
};
const handleEmptied = event => {
if (!isCurrentVideoEvent(event)) return;
cancelPlayPauseCoalesce();
checkEpisodeTransition('emptied');
};
const handleEnded = event => {
if (!isCurrentVideoEvent(event) || !_autoSyncEnabled) return;
const video = event.currentTarget;
ensureEpisodeTransitionCandidate(video, 'ended', {
baselineSource: getEpisodeSource(video),
nearBoundary: true,
transitionLike: true,
quarantineEvents: true
});
}; };
function detachVideoListeners(video) { function detachVideoListeners(video) {
@@ -2507,6 +2919,9 @@
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking); if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
video.removeEventListener('seeked', handlers.seeked); video.removeEventListener('seeked', handlers.seeked);
video.removeEventListener('loadeddata', handlers.loadeddata); video.removeEventListener('loadeddata', handlers.loadeddata);
if (handlers.loadstart) video.removeEventListener('loadstart', handlers.loadstart);
if (handlers.emptied) video.removeEventListener('emptied', handlers.emptied);
if (handlers.ended) video.removeEventListener('ended', handlers.ended);
if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting); if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting);
delete video._koalaHandlers; delete video._koalaHandlers;
} }
@@ -2517,14 +2932,18 @@
function cancelPendingVideoEvents() { function cancelPendingVideoEvents() {
if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; } if (seekDebounceTimer) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; }
if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; } cancelPlayPauseCoalesce();
pendingPlayPauseAction = null; discardEpisodeTransitionQuarantine();
pendingPlayPauseVideo = null; clearEpisodeTransitionCandidate();
episodeTransitionNoiseVideo = null;
episodeTransitionNoiseUntil = 0;
} }
function setupListeners() { function setupListeners() {
if (destroyed) return; if (destroyed) return;
const video = findVideo(); const video = findVideo();
const previousVideo = activeVideo;
const playerChanged = !!previousVideo && previousVideo !== video;
if (activeVideo !== video) cancelPendingVideoEvents(); if (activeVideo !== video) cancelPendingVideoEvents();
for (const attached of [...attachedVideos]) { for (const attached of [...attachedVideos]) {
if (attached !== video) detachVideoListeners(attached); if (attached !== video) detachVideoListeners(attached);
@@ -2543,6 +2962,9 @@
seeking: handleSeeking, seeking: handleSeeking,
seeked: handleSeeked, seeked: handleSeeked,
loadeddata: handleLoadedData, loadeddata: handleLoadedData,
loadstart: handleLoadStart,
emptied: handleEmptied,
ended: handleEnded,
waiting: handleWaiting waiting: handleWaiting
}; };
video.addEventListener('play', handlePlay); video.addEventListener('play', handlePlay);
@@ -2550,9 +2972,13 @@
video.addEventListener('seeking', handleSeeking); video.addEventListener('seeking', handleSeeking);
video.addEventListener('seeked', handleSeeked); video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('emptied', handleEmptied);
video.addEventListener('ended', handleEnded);
video.addEventListener('waiting', handleWaiting); video.addEventListener('waiting', handleWaiting);
attachedVideos.add(video); attachedVideos.add(video);
video.dataset.koalaAttached = 'true'; video.dataset.koalaAttached = 'true';
if (playerChanged) checkEpisodeTransition('player_changed');
lastVideoSrc = video.currentSrc || video.src || null; lastVideoSrc = video.currentSrc || video.src || null;
if (!lastKnownMediaTitle) { if (!lastKnownMediaTitle) {
@@ -2594,7 +3020,8 @@
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) { if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) { if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
checkEpisodeTransition(); cancelPlayPauseCoalesce();
checkEpisodeTransition('source_changed');
} }
setupListeners(); setupListeners();
} }
+102 -11
View File
@@ -2,7 +2,15 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CAPABILITIES, EVENTS, EPISODE_SYNC_V2_STABILITY_MS } from '../shared/constants.js'; import {
CAPABILITIES,
EPISODE_LOBBY_TIMEOUT,
EPISODE_SYNC_V2_LOAD_TIMEOUT,
EPISODE_SYNC_V2_PREPARE_TIMEOUT,
EPISODE_SYNC_V2_EXECUTE_TIMEOUT,
EPISODE_SYNC_V2_STABILITY_MS,
EVENTS
} from '../shared/constants.js';
const extensionDir = path.dirname(fileURLToPath(import.meta.url)); const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
@@ -26,7 +34,7 @@ describe('Episode Sync v2 extension contract', () => {
expect(backgroundSource).toContain('EVENTS.EPISODE_SYNC_V2'); expect(backgroundSource).toContain('EVENTS.EPISODE_SYNC_V2');
}); });
it('never queues v2 coordination or auto-falls back to a client-owned legacy lobby', () => { it('keeps v2 live-only and provides a session-correlated legacy fallback', () => {
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2'); expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
const episodeChanged = between( const episodeChanged = between(
backgroundSource, backgroundSource,
@@ -34,9 +42,28 @@ describe('Episode Sync v2 extension contract', () => {
"message.type === 'EPISODE_READY_LOCAL'" "message.type === 'EPISODE_READY_LOCAL'"
); );
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)'); expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'"); expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', ...episodeIdentity })");
expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY'); expect(episodeChanged).toContain('createPendingEpisodeSyncV2Start(episodeIdentity, sender)');
expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY'); expect(episodeChanged).toContain('startLegacyEpisodeLobbyForTransition(episodeIdentity, pending)');
expect(backgroundSource).toContain("data.reason === 'capability_mismatch'");
expect(backgroundSource).toContain('isEpisodeSyncV2StartContextCurrent(pending');
expect(backgroundSource).toContain("emitLive(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle })");
const fallback = between(
backgroundSource,
'function startLegacyEpisodeLobbyForTransition(',
'function clearTargetTabForIdle('
);
expect(fallback.indexOf('if (episodeLobby && sameEpisode('))
.toBeLessThan(fallback.indexOf('emitLive(EVENTS.EPISODE_LOBBY'));
const rejectionFallback = between(
backgroundSource,
"if (phase === 'cancel' && !data.transactionId)",
"if (phase === 'lobby' || phase === 'prepare')"
);
expect(rejectionFallback.indexOf('if (!matchesPending)'))
.toBeLessThan(rejectionFallback.indexOf('episodeSyncV2PendingStart = null'));
expect(rejectionFallback).toContain('episodeSyncV2PendingStart = null');
}); });
it('correlates content reports to the current transaction, phase, target and episode', () => { it('correlates content reports to the current transaction, phase, target and episode', () => {
@@ -48,10 +75,26 @@ describe('Episode Sync v2 extension contract', () => {
expect(handler).toContain('message.transactionId !== transaction.transactionId'); expect(handler).toContain('message.transactionId !== transaction.transactionId');
expect(handler).toContain('transaction.phase !== expectedLocalPhase'); expect(handler).toContain('transaction.phase !== expectedLocalPhase');
expect(handler).toContain("!isCurrentContentSender(sender)"); expect(handler).toContain("!isCurrentContentSender(sender)");
expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)'); expect(handler).toContain('!sameEpisodeIdentity(');
expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2'); expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2');
}); });
it('separates legacy and v2 loading timeouts and converts remaining duration locally', () => {
expect(EPISODE_LOBBY_TIMEOUT).toBe(60_000);
expect(EPISODE_SYNC_V2_LOAD_TIMEOUT).toBe(120_000);
expect(EPISODE_SYNC_V2_PREPARE_TIMEOUT).toBe(15_000);
expect(EPISODE_SYNC_V2_EXECUTE_TIMEOUT).toBe(10_000);
const normalize = between(
backgroundSource,
'function normalizeEpisodeSyncV2(',
'function createPendingEpisodeSyncV2Start('
);
expect(normalize).toContain('createLocalEpisodeDeadline(value.remainingMs, phaseTimeout)');
expect(normalize).toContain('deadlineAt: localDeadline.deadlineAt');
expect(normalize).not.toContain('value.deadlineAt');
expect(normalize).toContain("phase === 'prepare' ? EPISODE_SYNC_V2_PREPARE_TIMEOUT : EPISODE_SYNC_V2_EXECUTE_TIMEOUT");
});
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => { it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
const stable = between( const stable = between(
contentSource, contentSource,
@@ -59,7 +102,7 @@ describe('Episode Sync v2 extension contract', () => {
'async function prepareEpisodeSyncV2(' 'async function prepareEpisodeSyncV2('
); );
expect(stable).toContain('video === findVideo()'); expect(stable).toContain('video === findVideo()');
expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)'); expect(stable).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)');
expect(stable).toContain('video.paused'); expect(stable).toContain('video.paused');
expect(stable).toContain('!video.seeking'); expect(stable).toContain('!video.seeking');
expect(stable).toContain('video.readyState >= 3'); expect(stable).toContain('video.readyState >= 3');
@@ -94,7 +137,7 @@ describe('Episode Sync v2 extension contract', () => {
expect(clear).toContain('state.wasPlayingBeforePrepare'); expect(clear).toContain('state.wasPlayingBeforePrepare');
expect(clear).toContain('!state.manualAction'); expect(clear).toContain('!state.manualAction');
expect(clear).toContain('video === findVideo()'); expect(clear).toContain('video === findVideo()');
expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)'); expect(clear).toContain('sameEpisodeIdentity(getMediaTitle(), state.expectedTitle, state.expectedEpisodeId)');
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)'); expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
}); });
@@ -104,14 +147,40 @@ describe('Episode Sync v2 extension contract', () => {
"message.type === 'EPISODE_SYNC_V2'", "message.type === 'EPISODE_SYNC_V2'",
'// Episode Auto-Sync: Legacy lobby notification from background' '// Episode Auto-Sync: Legacy lobby notification from background'
); );
expect(handler).toContain("resume: transaction.reason !== 'superseded'"); expect(handler).toContain("transaction.reason !== 'superseded'");
});
it('distinguishes deliberate loading intent and settles execute failures paused', () => {
const eventSender = between(
contentSource,
'function sendContentEvent(',
'function cancelPlayPauseCoalesce('
);
expect(eventSender).toContain("episodeSyncIntent = episodeSyncV2State?.phase === 'lobby'");
expect(eventSender).toContain("? 'manual'");
const loadingClassifier = between(
contentSource,
'function v2LoadingEventIsChurn(',
'function queueEpisodeTransitionEvent('
);
expect(loadingClassifier).toContain("hcmClassifyIntent() === 'deliberate'");
const handler = between(
contentSource,
"message.type === 'EPISODE_SYNC_V2'",
'// Episode Auto-Sync: Legacy lobby notification from background'
);
expect(handler).toContain("transaction.settlePlaybackState === 'paused'");
expect(handler).toContain('await tryMediaAction(EVENTS.PAUSE)');
expect(handler).toContain('await tryMediaAction(EVENTS.SEEK, { targetTime })');
}); });
it('revalidates the complete prepared state immediately before execute', () => { it('revalidates the complete prepared state immediately before execute', () => {
const handler = between( const handler = between(
contentSource, contentSource,
"transaction.phase === 'execute'", 'function executeEpisodeSyncV2(',
"transaction.phase === 'cancel'" 'function getPlayerActionFixes('
); );
expect(handler).toContain("state.phase === 'prepare'"); expect(handler).toContain("state.phase === 'prepare'");
expect(handler).toContain('video === findVideo()'); expect(handler).toContain('video === findVideo()');
@@ -122,8 +191,30 @@ describe('Episode Sync v2 extension contract', () => {
expect(handler).toContain('Math.abs(current) < 1'); expect(handler).toContain('Math.abs(current) < 1');
}); });
it('reports content execute outcome and retains state until relay completion', () => {
const execute = between(
backgroundSource,
'async function executeEpisodeSyncV2FromRelay(',
'function clearEpisodeSyncV2State('
);
expect(execute).toContain("response?.status === 'executed' ? 'executed' : 'failed_execute'");
expect(execute).toContain('phase: reportStatus');
expect(execute).not.toContain('clearEpisodeSyncV2State(');
const handler = between(
backgroundSource,
'case EVENTS.EPISODE_SYNC_V2:',
'case EVENTS.EPISODE_LOBBY:'
);
expect(handler).toContain("phase === 'execute' || phase === 'complete' || phase === 'cancel'");
expect(handler).toContain('await executeEpisodeSyncV2FromRelay(data)');
expect(handler).toContain("phase: 'complete'");
expect(handler).toContain("clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' })");
});
it('injects the shared stability window into packaged content scripts', () => { it('injects the shared stability window into packaged content scripts', () => {
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS'); expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
expect(buildSource).toContain('episodeSyncStabilityVal'); expect(buildSource).toContain('episodeSyncStabilityVal');
expect(buildSource).toContain(".replace(/export const /g, 'const ')");
}); });
}); });
+70 -4
View File
@@ -4,6 +4,16 @@
* Keep in sync with the injection block in content.js! * Keep in sync with the injection block in content.js!
*/ */
// The relay clamps episode titles to 100 UTF-16 code units. Clamp before the
// wire and before strict comparison as well; otherwise a valid long local title
// can never match the relay's authoritative value.
export const EPISODE_WIRE_TITLE_LENGTH = 100;
export function toEpisodeWireTitle(title) {
if (typeof title !== 'string' || title.length === 0) return null;
return title.substring(0, EPISODE_WIRE_TITLE_LENGTH);
}
export function extractEpisodeId(title) { export function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null; if (!title || typeof title !== 'string') return null;
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
@@ -39,8 +49,64 @@ function episodeContext(title) {
// it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced // it to agree so two unrelated S01E06 videos cannot cross-sync. Privacy-reduced
// S/E-only titles still fall back to the canonical episode ID. // S/E-only titles still fall back to the canonical episode ID.
export function sameEpisodeStrict(titleA, titleB) { export function sameEpisodeStrict(titleA, titleB) {
if (!sameEpisode(titleA, titleB)) return false; const wireTitleA = toEpisodeWireTitle(titleA);
const contextA = episodeContext(titleA); const wireTitleB = toEpisodeWireTitle(titleB);
const contextB = episodeContext(titleB); if (!sameEpisode(wireTitleA, wireTitleB)) return false;
return !contextA || !contextB || contextA === contextB; const contextA = episodeContext(wireTitleA);
const contextB = episodeContext(wireTitleB);
if (!contextA || !contextB || contextA === contextB) return true;
// Players disagree about whether MediaSession.title contains the series,
// episode title, or both. Accept a complete token-boundary subset while
// still rejecting unrelated contextual titles for the same S/E number.
const [shorter, longer] = contextA.length <= contextB.length
? [contextA, contextB]
: [contextB, contextA];
return shorter.length >= 4 && ` ${longer} `.includes(` ${shorter} `);
}
export function createEpisodeWireIdentity(title) {
const expectedEpisodeId = extractEpisodeId(title);
const expectedTitle = toEpisodeWireTitle(title);
if (!expectedTitle || !expectedEpisodeId) return null;
return { expectedTitle, expectedEpisodeId };
}
export function sameEpisodeIdentity(localTitle, expectedTitle, expectedEpisodeId = null) {
const normalizedExpectedId = typeof expectedEpisodeId === 'string'
? expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: null;
if (normalizedExpectedId && extractEpisodeId(localTitle) !== normalizedExpectedId) return false;
return sameEpisodeStrict(localTitle, expectedTitle);
}
export function createLocalEpisodeDeadline(remainingMs, fallbackMs, now = Date.now()) {
const safeFallback = Number.isFinite(fallbackMs) && fallbackMs > 0 ? fallbackMs : 0;
const safeRemaining = Number.isFinite(remainingMs)
? Math.max(0, Math.min(safeFallback, remainingMs))
: safeFallback;
return {
remainingMs: safeRemaining,
deadlineAt: now + safeRemaining
};
}
export function isEpisodeSyncV2StartContextCurrent(pending, current, now = Date.now()) {
return !!pending
&& !!current
&& now - pending.requestedAt >= 0
&& now - pending.requestedAt <= 15000
&& pending.roomId === current.roomId
&& pending.connectionGeneration === current.connectionGeneration
&& pending.targetGeneration === current.targetGeneration
&& pending.tabId === current.tabId;
}
export function matchesEpisodeSyncV2StartRejection(pending, current, rejection, now = Date.now()) {
if (!isEpisodeSyncV2StartContextCurrent(pending, current, now)) return false;
const rejectionTitle = toEpisodeWireTitle(rejection?.expectedTitle);
const rejectionEpisodeId = typeof rejection?.expectedEpisodeId === 'string'
? rejection.expectedEpisodeId.trim().toUpperCase().substring(0, 16)
: extractEpisodeId(rejectionTitle);
return rejectionTitle === pending.expectedTitle
&& (!rejectionEpisodeId || rejectionEpisodeId === pending.expectedEpisodeId);
} }
+92 -1
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js'; import {
EPISODE_WIRE_TITLE_LENGTH,
createLocalEpisodeDeadline,
createEpisodeWireIdentity,
extractEpisodeId,
sameEpisode,
sameEpisodeIdentity,
sameEpisodeStrict,
isEpisodeSyncV2StartContextCurrent,
matchesEpisodeSyncV2StartRejection,
toEpisodeWireTitle
} from './episode-utils.js';
describe('episode title matching', () => { describe('episode title matching', () => {
it.each([ it.each([
@@ -58,5 +69,85 @@ describe('episode title matching', () => {
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S01E06 - Visiting Ours')).toBe(true); expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S01E06 - Visiting Ours')).toBe(true);
expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false); expect(sameEpisodeStrict('S1:E6 - Visiting Ours', 'S1:E6 - Another Show')).toBe(false);
expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true); expect(sameEpisodeStrict('S1:E6', 'S01E06 - Visiting Ours')).toBe(true);
expect(sameEpisodeStrict(
'Arrested Development - S1:E6 - Visiting Ours',
'S01E06 - Visiting Ours'
)).toBe(true);
expect(sameEpisodeStrict(
'Arrested Development - S1:E6 - Visiting Ours',
'Different Series - S01E06 - Visiting Ours'
)).toBe(false);
});
it('mirrors the relay title clamp before strict comparison', () => {
const title = `S01E06 - ${'Very Long Episode Context '.repeat(8)}`;
const wireTitle = toEpisodeWireTitle(title);
expect(EPISODE_WIRE_TITLE_LENGTH).toBe(100);
expect(wireTitle).toHaveLength(100);
expect(sameEpisodeStrict(title, wireTitle)).toBe(true);
});
it('keeps the full-title episode id in a bounded wire identity', () => {
const title = `${'Long Series Context '.repeat(8)} S01E06`;
const identity = createEpisodeWireIdentity(title);
expect(identity).toEqual({
expectedTitle: title.substring(0, 100),
expectedEpisodeId: 'S01E06'
});
expect(sameEpisodeIdentity(title, identity.expectedTitle, identity.expectedEpisodeId)).toBe(true);
expect(sameEpisodeIdentity(title.replace('S01E06', 'S01E07'), identity.expectedTitle, identity.expectedEpisodeId)).toBe(false);
});
it('bounds oversized and UTF-16 titles exactly like relay substring sanitization', () => {
const title = `${'x'.repeat(99)}😀S01E06${'y'.repeat(5000)}`;
expect(toEpisodeWireTitle(title)).toBe(title.substring(0, 100));
expect(toEpisodeWireTitle(title).length).toBe(100);
expect(toEpisodeWireTitle(null)).toBeNull();
});
it('converts relay remaining duration to a bounded local deadline without wall-clock trust', () => {
expect(createLocalEpisodeDeadline(30_000, 120_000, 1_000)).toEqual({
remainingMs: 30_000,
deadlineAt: 31_000
});
expect(createLocalEpisodeDeadline(undefined, 120_000, 1_000)).toEqual({
remainingMs: 120_000,
deadlineAt: 121_000
});
expect(createLocalEpisodeDeadline(999_999, 120_000, 1_000).remainingMs).toBe(120_000);
expect(createLocalEpisodeDeadline(-1, 120_000, 1_000).remainingMs).toBe(0);
});
it('rejects stale or mismatched capability fallback responses', () => {
const pending = {
roomId: 'ROOM-A',
connectionGeneration: 4,
targetGeneration: 7,
tabId: 12,
expectedTitle: 'S01E06 - Visiting Ours',
expectedEpisodeId: 'S01E06',
requestedAt: 10_000
};
const current = {
roomId: 'ROOM-A',
connectionGeneration: 4,
targetGeneration: 7,
tabId: 12
};
const rejection = {
expectedTitle: 'S01E06 - Visiting Ours',
expectedEpisodeId: 's01e06'
};
expect(isEpisodeSyncV2StartContextCurrent(pending, current, 20_000)).toBe(true);
expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 20_000)).toBe(true);
expect(matchesEpisodeSyncV2StartRejection(pending, { ...current, targetGeneration: 8 }, rejection, 20_000)).toBe(false);
expect(matchesEpisodeSyncV2StartRejection(pending, current, rejection, 25_001)).toBe(false);
expect(matchesEpisodeSyncV2StartRejection(pending, current, {
...rejection,
expectedTitle: 'S01E07 - Different'
}, 20_000)).toBe(false);
}); });
}); });
+1
View File
@@ -170,6 +170,7 @@ function copyExtensionFiles(targetDir, browserName) {
const stripped = euContent const stripped = euContent
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '') .replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
.replace(/export function /g, 'function ') .replace(/export function /g, 'function ')
.replace(/export const /g, 'const ')
.trim(); .trim();
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`; const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection'); content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection');
+196 -47
View File
@@ -10,7 +10,7 @@ import {
materializeMediaIntent, materializeMediaIntent,
reserveLatestMediaIntentSequence reserveLatestMediaIntentSequence
} from '../extension/offline-media-intent.js'; } from '../extension/offline-media-intent.js';
import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; import { EPISODE_SYNC_V2_LOAD_TIMEOUT, FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -35,6 +35,23 @@ async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
assert.equal(event,'room_data'); assert.equal(event,'room_data');
return data; return data;
} }
async function advanceEpisodeSyncToExecute(peerA, peerB, expectedTitle, expectedEpisodeId = undefined) {
s(peerA, 'episode_sync_v2', { phase: 'start', expectedTitle, expectedEpisodeId });
const lobby = await w(peerA, 'episode_sync_v2');
await w(peerB, 'episode_sync_v2');
s(peerA, 'episode_sync_v2', { phase: 'loaded', transactionId: lobby.transactionId });
await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2');
s(peerB, 'episode_sync_v2', { phase: 'loaded', transactionId: lobby.transactionId });
await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2');
s(peerA, 'episode_sync_v2', { phase: 'prepared', transactionId: lobby.transactionId });
await w(peerA, 'episode_sync_v2'); await w(peerB, 'episode_sync_v2');
s(peerB, 'episode_sync_v2', { phase: 'prepared', transactionId: lobby.transactionId });
const executeA = await w(peerA, 'episode_sync_v2');
const executeB = await w(peerB, 'episode_sync_v2');
assert.equal(executeA.phase, 'execute');
assert.equal(executeB.phase, 'execute');
return { lobby, executeA, executeB };
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } 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 // Test suite opens >10 connections/min — clear the IP connection counter so the
@@ -208,13 +225,22 @@ try {
await j(episodeB, episodeRid, 'episode-b', null, episodeCaps); await j(episodeB, episodeRid, 'episode-b', null, episodeCaps);
episodeA._m.length = episodeB._m.length = 0; episodeA._m.length = episodeB._m.length = 0;
s(episodeA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E06 - Visiting Ours' }); s(episodeA, 'episode_sync_v2', {
phase: 'start',
expectedTitle: 'S01E06 - Visiting Ours',
expectedEpisodeId: 's01e06'
});
const episodeLobbyA = await w(episodeA, 'episode_sync_v2'); const episodeLobbyA = await w(episodeA, 'episode_sync_v2');
const episodeLobbyB = await w(episodeB, 'episode_sync_v2'); const episodeLobbyB = await w(episodeB, 'episode_sync_v2');
assert.equal(episodeLobbyA.phase, 'lobby'); assert.equal(episodeLobbyA.phase, 'lobby');
assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId); assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId);
assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']); assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']);
assert.deepEqual(episodeLobbyA.loadedPeers, [], 'initiator is not pre-marked loaded'); assert.deepEqual(episodeLobbyA.loadedPeers, [], 'initiator is not pre-marked loaded');
assert.equal(episodeLobbyA.expectedEpisodeId, 'S01E06');
assert.ok(episodeLobbyA.remainingMs > 0 && episodeLobbyA.remainingMs <= EPISODE_SYNC_V2_LOAD_TIMEOUT);
assert.ok(episodeLobbyA.remainingMs > 119_000,
'v2 loading advertises the independent 120s deadline, not the 60s legacy deadline');
assert.equal(episodeLobbyA.deadlineAt, undefined, 'relay wall clock is not exposed to clients');
const episodeTxId = episodeLobbyA.transactionId; const episodeTxId = episodeLobbyA.transactionId;
s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' }); s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' });
@@ -262,12 +288,47 @@ try {
const executeB = await w(episodeB, 'episode_sync_v2'); const executeB = await w(episodeB, 'episode_sync_v2');
assert.equal(executeA.phase, 'execute'); assert.equal(executeA.phase, 'execute');
assert.equal(executeB.phase, 'execute'); assert.equal(executeB.phase, 'execute');
assert.equal(mod.rooms.get(episodeRid).episodeSyncV2, null); assert.equal(mod.rooms.get(episodeRid).episodeSyncV2.phase, 'executing');
assert.equal(mod.rooms.get(episodeRid).mediaState.playbackState, 'playing'); assert.equal(mod.rooms.get(episodeRid).mediaState, null,
assert.equal(mod.rooms.get(episodeRid).mediaState.currentTime, 0); 'canonical playing is not committed before every execute ACK');
let duplicateExecute = false; let duplicateExecute = false;
try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ } try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ }
assert.equal(duplicateExecute, false, 'execute is emitted exactly once'); assert.equal(duplicateExecute, false, 'execute is emitted exactly once');
s(episodeA, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId });
await delay(50);
assert.deepEqual(mod.rooms.get(episodeRid).episodeSyncV2.executedPeers, ['episode-a']);
assert.equal(mod.rooms.get(episodeRid).mediaState, null,
'one execute ACK cannot complete the room');
s(episodeA, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId });
await delay(50);
assert.deepEqual(mod.rooms.get(episodeRid).episodeSyncV2.executedPeers, ['episode-a'],
'duplicate execute ACK is idempotent');
s(episodeB, 'episode_sync_v2', { phase: 'executed', transactionId: episodeTxId });
const completeA = await w(episodeA, 'episode_sync_v2');
const completeB = await w(episodeB, 'episode_sync_v2');
assert.equal(completeA.phase, 'complete');
assert.equal(completeB.phase, 'complete');
assert.deepEqual(completeA.executedPeers, ['episode-a', 'episode-b']);
assert.equal(completeA.remainingMs, 0);
assert.equal(mod.rooms.get(episodeRid).episodeSyncV2, null);
assert.equal(mod.rooms.get(episodeRid).mediaState.playbackState, 'playing');
assert.equal(mod.rooms.get(episodeRid).mediaState.currentTime, 0);
s(episodeA, 'episode_sync_v2', {
phase: 'start',
expectedTitle: 'S01E07',
expectedEpisodeId: '<script>S01E07'
});
const sanitizedEpisodeLobby = await w(episodeA, 'episode_sync_v2');
await w(episodeB, 'episode_sync_v2');
assert.equal(sanitizedEpisodeLobby.expectedEpisodeId, null,
'invalid episode identity is removed instead of reflected');
s(episodeA, 'episode_sync_v2', {
phase: 'cancel',
transactionId: sanitizedEpisodeLobby.transactionId
});
await w(episodeA, 'episode_sync_v2'); await w(episodeB, 'episode_sync_v2');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -311,25 +372,81 @@ try {
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' }); s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2'); await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'play', { currentTime: 2, seq: 1 }); s(episodeAbortA, 'play', { currentTime: 2, seq: 1 });
const manualCancelA = await w(episodeAbortA, 'episode_sync_v2'); const loadingPlayB = await w(episodeAbortB, 'play');
const manualCancelB = await w(episodeAbortB, 'episode_sync_v2'); assert.equal(loadingPlayB.currentTime, 2);
const manualPlayB = await w(episodeAbortB, 'play'); assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2.phase, 'loading',
assert.equal(manualCancelA.reason, 'superseded'); 'raw source-swap playback does not cancel the loading barrier');
assert.equal(manualCancelB.reason, 'superseded'); s(episodeAbortA, 'episode_sync_v2', {
assert.equal(manualPlayB.currentTime, 2); phase: 'cancel',
transactionId: mod.rooms.get(episodeAbortRid).episodeSyncV2.transactionId
});
const explicitCancelA = await w(episodeAbortA, 'episode_sync_v2');
const explicitCancelB = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(explicitCancelA.reason, 'peer_cancelled');
assert.equal(explicitCancelB.reason, 'peer_cancelled');
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02-MANUAL' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'pause', { currentTime: 3, seq: 2, episodeSyncIntent: 'manual' });
const loadingManualCancelA = await w(episodeAbortA, 'episode_sync_v2');
const loadingManualCancelB = await w(episodeAbortB, 'episode_sync_v2');
const loadingManualPauseB = await w(episodeAbortB, 'pause');
assert.equal(loadingManualCancelA.reason, 'superseded');
assert.equal(loadingManualCancelB.reason, 'superseded');
assert.equal(loadingManualPauseB.currentTime, 3);
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null,
'an explicitly classified manual action supersedes loading');
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E03' });
const prepareLobby = await w(episodeAbortA, 'episode_sync_v2');
await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'episode_sync_v2', { phase: 'loaded', transactionId: prepareLobby.transactionId });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortB, 'episode_sync_v2', { phase: 'loaded', transactionId: prepareLobby.transactionId });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'pause', { currentTime: 0, seq: 3 });
const prepareCancelA = await w(episodeAbortA, 'episode_sync_v2');
const prepareCancelB = await w(episodeAbortB, 'episode_sync_v2');
const preparePauseB = await w(episodeAbortB, 'pause');
assert.equal(prepareCancelA.reason, 'superseded');
assert.equal(prepareCancelB.reason, 'superseded');
assert.equal(preparePauseB.currentTime, 0);
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E04' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortA, 'force_sync_prepare', { targetTime: 44, seq: 4 });
const forceCancelA = await w(episodeAbortA, 'episode_sync_v2');
const forceCancelB = await w(episodeAbortB, 'episode_sync_v2');
const forcePrepareB = await w(episodeAbortB, 'force_sync_prepare');
assert.equal(forceCancelA.reason, 'superseded');
assert.equal(forceCancelB.reason, 'superseded');
assert.equal(forcePrepareB.targetTime, 44);
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
s(episodeAbortA, 'force_sync_execute', { seq: 5 });
await w(episodeAbortB, 'force_sync_execute');
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E05' });
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
s(episodeAbortB, 'peer_status', { status: 'heartbeat', desynced: true });
const desyncCancelA = await w(episodeAbortA, 'episode_sync_v2');
const desyncCancelB = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(desyncCancelA.reason, 'participant_desynced');
assert.equal(desyncCancelB.failedPeerId, 'abort-b');
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null); assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
const abortRoom = mod.rooms.get(episodeAbortRid); const abortRoom = mod.rooms.get(episodeAbortRid);
abortRoom.controlMode = 'host-only'; abortRoom.controlMode = 'host-only';
abortRoom.controllers = new Set(['abort-a']); abortRoom.controllers = new Set(['abort-a']);
s(episodeAbortB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E03' }); s(episodeAbortB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E06' });
const guestStartCancel = await w(episodeAbortB, 'episode_sync_v2'); const guestStartCancel = await w(episodeAbortB, 'episode_sync_v2');
assert.equal(guestStartCancel.reason, 'not_controller'); assert.equal(guestStartCancel.reason, 'not_controller');
assert.equal(abortRoom.episodeSyncV2, null); assert.equal(abortRoom.episodeSyncV2, null);
close(); close();
resetConnectionRate(); resetConnectionRate();
// Frozen participants: a late legacy joiner is excluded and does not block completion. // Membership is frozen: every successful late join or reconnect cancels
// instead of reusing ACKs from a stale participant/socket snapshot.
const frozenRid = 'episode-frozen-'+Date.now(); const frozenRid = 'episode-frozen-'+Date.now();
const frozenA = await c(), frozenB = await c(); const frozenA = await c(), frozenB = await c();
await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps); await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps);
@@ -342,20 +459,26 @@ try {
const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late'); const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late');
assert.equal(frozenLateRoom.episodeSyncV2, null); assert.equal(frozenLateRoom.episodeSyncV2, null);
assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']); assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']);
frozenLate._m.length = 0; const lateCancelA = await w(frozenA, 'episode_sync_v2');
for (const peer of [frozenA, frozenB]) { const lateCancelB = await w(frozenB, 'episode_sync_v2');
s(peer, 'episode_sync_v2', { phase: 'loaded', transactionId: frozenLobbyA.transactionId }); assert.equal(lateCancelA.reason, 'membership_changed');
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); } assert.equal(lateCancelB.reason, 'membership_changed');
} assert.equal(mod.rooms.get(frozenRid).episodeSyncV2, null);
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
for (const peer of [frozenA, frozenB]) { s(frozenLate, 'leave_room', {});
s(peer, 'episode_sync_v2', { phase: 'prepared', transactionId: frozenLobbyA.transactionId }); await w(frozenA, 'peer_status');
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); } await w(frozenB, 'peer_status');
} frozenA._m.length = frozenB._m.length = 0;
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); s(frozenA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E08' });
let lateReceivedV2 = false; await w(frozenA, 'episode_sync_v2');
try { await w(frozenLate, 'episode_sync_v2', 300); lateReceivedV2 = true; } catch { /* expected */ } await w(frozenB, 'episode_sync_v2');
assert.equal(lateReceivedV2, false); const frozenBRejoin = await c();
const rejoinRoom = await j(frozenBRejoin, frozenRid, 'frozen-b', null, episodeCaps);
assert.equal(rejoinRoom.episodeSyncV2, null);
const rejoinCancelA = await w(frozenA, 'episode_sync_v2');
assert.equal(rejoinCancelA.reason, 'membership_changed');
assert.equal(rejoinCancelA.failedPeerId, 'frozen-b');
assert.equal(mod.rooms.get(frozenRid).episodeSyncV2, null);
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -377,7 +500,32 @@ try {
assert.equal(timeoutCancelB.phase, 'cancel'); assert.equal(timeoutCancelB.phase, 'cancel');
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null); assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E10' }); const failedExecute = await advanceEpisodeSyncToExecute(cancelA, cancelB, 'S01E10', 'S01E10');
s(cancelB, 'episode_sync_v2', {
phase: 'failed_execute',
transactionId: failedExecute.lobby.transactionId
});
const failedExecuteCancelA = await w(cancelA, 'episode_sync_v2');
const failedExecuteCancelB = await w(cancelB, 'episode_sync_v2');
assert.equal(failedExecuteCancelA.reason, 'execute_failed');
assert.equal(failedExecuteCancelB.failedPeerId, 'cancel-b');
assert.equal(failedExecuteCancelA.settlePlaybackState, 'paused');
assert.equal(failedExecuteCancelA.targetTime, 0);
assert.equal(mod.rooms.get(cancelRid).mediaState.playbackState, 'paused');
assert.equal(mod.rooms.get(cancelRid).mediaState.currentTime, 0);
const executeTimeout = await advanceEpisodeSyncToExecute(cancelA, cancelB, 'S01E11', 'S01E11');
mod.rooms.get(cancelRid).episodeSyncV2.deadlineAt = 1;
mod.expireEpisodeSyncV2Transactions(Date.now());
const executeTimeoutCancelA = await w(cancelA, 'episode_sync_v2');
const executeTimeoutCancelB = await w(cancelB, 'episode_sync_v2');
assert.equal(executeTimeoutCancelA.reason, 'execute_timeout');
assert.equal(executeTimeoutCancelB.settlePlaybackState, 'paused');
assert.equal(executeTimeoutCancelA.transactionId, executeTimeout.lobby.transactionId);
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
assert.equal(mod.rooms.get(cancelRid).mediaState.playbackState, 'paused');
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E12' });
const departureLobby = await w(cancelA, 'episode_sync_v2'); await w(cancelB, 'episode_sync_v2'); const departureLobby = await w(cancelA, 'episode_sync_v2'); await w(cancelB, 'episode_sync_v2');
assert.notEqual(cancelLobby.transactionId, departureLobby.transactionId, 'new transaction receives a fresh identity'); assert.notEqual(cancelLobby.transactionId, departureLobby.transactionId, 'new transaction receives a fresh identity');
s(cancelB, 'leave_room', {}); s(cancelB, 'leave_room', {});
@@ -389,27 +537,22 @@ try {
close(); close();
resetConnectionRate(); resetConnectionRate();
// New relay hardening for old extensions: only the accepted lobby owner may // Origin/main compatibility: legacy clients did not distinguish automatic
// relay legacy PREPARE/EXECUTE/CANCEL. // and manual Force Sync. A non-lobby-owner command must therefore keep its
const legacyEpisodeRid = 'episode-legacy-owner-'+Date.now(); // historical relay semantics instead of being silently discarded.
const legacyEpisodeRid = 'episode-legacy-compat-'+Date.now();
const legacyEpisodeA = await c(), legacyEpisodeB = await c(); const legacyEpisodeA = await c(), legacyEpisodeB = await c();
await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a'); await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a');
await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b'); await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b');
legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0; legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0;
s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' }); s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' });
await w(legacyEpisodeB, 'episode_lobby'); await w(legacyEpisodeB, 'episode_lobby');
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 0 }); s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 17 });
let nonOwnerPrepareRelayed = false; const nonOwnerPrepare = await w(legacyEpisodeA, 'force_sync_prepare');
try { await w(legacyEpisodeA, 'force_sync_prepare', 300); nonOwnerPrepareRelayed = true; } catch { /* expected */ } assert.equal(nonOwnerPrepare.targetTime, 17);
assert.equal(nonOwnerPrepareRelayed, false);
s(legacyEpisodeA, 'force_sync_prepare', { targetTime: 0 });
await w(legacyEpisodeB, 'force_sync_prepare');
s(legacyEpisodeB, 'force_sync_execute', {}); s(legacyEpisodeB, 'force_sync_execute', {});
let nonOwnerExecuteRelayed = false; await w(legacyEpisodeA, 'force_sync_execute');
try { await w(legacyEpisodeA, 'force_sync_execute', 300); nonOwnerExecuteRelayed = true; } catch { /* expected */ } assert.equal(mod.rooms.get(legacyEpisodeRid).mediaState.currentTime, 17);
assert.equal(nonOwnerExecuteRelayed, false);
s(legacyEpisodeA, 'force_sync_execute', {});
await w(legacyEpisodeB, 'force_sync_execute');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -1238,12 +1381,18 @@ try {
mxo._m.length = mxn._m.length = 0; mxo._m.length = mxn._m.length = 0;
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute'); s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby'); s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
s(mxn,'pause',{currentTime:2}); await w(mxo,'episode_lobby_cancel'); await w(mxo,'pause'); s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
assert.equal(mod.rooms.get(mxrid).activeLobby.expectedTitle, 'S1E1',
'legacy lobby survives ordinary PAUSE from an old/new peer');
let transitionPauseCancelledLobby = false;
try { await w(mxo,'episode_lobby_cancel',300); transitionPauseCancelledLobby = true; } catch { /* expected */ }
assert.equal(transitionPauseCancelledLobby, false);
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek'); s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
assert.equal(mod.rooms.get(mxrid).activeLobby.expectedTitle, 'S1E1',
'legacy lobby survives source-swap SEEK');
s(mxn,'episode_lobby_cancel',{}); s(mxn,'episode_lobby_cancel',{});
let staleMixedCancelDropped = false; await w(mxo,'episode_lobby_cancel');
try { await w(mxo,'episode_lobby_cancel',300); } catch { staleMixedCancelDropped = true; } assert.equal(mod.rooms.get(mxrid).activeLobby, null);
assert.ok(staleMixedCancelDropped, 'stale legacy lobby cancel is dropped after manual playback supersedes it');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -1305,7 +1454,7 @@ try {
console.log('All WebSocket integration tests passed (incl. host control mode)'); console.log('All WebSocket integration tests passed (incl. host control mode)');
} catch(e) { } catch(e) {
console.error('FAILED:', e.message); console.error('FAILED:', e.stack || e.message);
process.exitCode=1; process.exitCode=1;
} finally { } finally {
close(); close();
+121 -50
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import crypto from 'crypto'; import crypto from 'crypto';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT } from '../shared/constants.js'; import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME, EPISODE_SYNC_V2_LOAD_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT, EPISODE_SYNC_V2_EXECUTE_TIMEOUT } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js'; import { createChatEnvelope } from './chat.js';
import { import {
commitForceSyncMediaState, commitForceSyncMediaState,
@@ -189,15 +189,18 @@ const SEQUENCED_ROOM_EVENTS = new Set([
EVENTS.FORCE_SYNC_EXECUTE EVENTS.FORCE_SYNC_EXECUTE
]); ]);
const EPISODE_SYNC_V2_SUPERSEDING_EVENTS = new Set([ const EPISODE_SYNC_V2_HARD_SUPERSEDING_EVENTS = new Set([
EVENTS.PLAY,
EVENTS.PAUSE,
EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_EXECUTE, EVENTS.FORCE_SYNC_EXECUTE,
EVENTS.EPISODE_LOBBY EVENTS.EPISODE_LOBBY
]); ]);
const EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS = new Set([
EVENTS.PLAY,
EVENTS.PAUSE,
EVENTS.SEEK
]);
// Features this relay supports, advertised to clients in ROOM_DATA so they can // 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 // 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). // flag here when a new server-gated feature ships (e.g. co-host promotion).
@@ -236,19 +239,33 @@ function clientSupportsEpisodeSyncV2(socket) {
&& socket.data.clientCapabilities.includes(CAPABILITIES.EPISODE_SYNC_V2); && socket.data.clientCapabilities.includes(CAPABILITIES.EPISODE_SYNC_V2);
} }
function normalizeExpectedEpisodeId(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().toUpperCase().substring(0, 16);
return /^(?:S\d{1,4}E\d{1,4}|EP\d{1,6})$/.test(normalized)
? normalized
: null;
}
function publicEpisodeSyncV2(transaction, phase = null) { function publicEpisodeSyncV2(transaction, phase = null) {
if (!transaction) return null; if (!transaction) return null;
const publicPhase = phase || (transaction.phase === 'loading' ? 'lobby' : 'prepare'); const publicPhase = phase || (transaction.phase === 'loading'
? 'lobby'
: (transaction.phase === 'preparing' ? 'prepare' : 'execute'));
return { return {
transactionId: transaction.transactionId, transactionId: transaction.transactionId,
phase: publicPhase, phase: publicPhase,
expectedTitle: transaction.expectedTitle, expectedTitle: transaction.expectedTitle,
expectedEpisodeId: transaction.expectedEpisodeId || null,
initiatorPeerId: transaction.initiatorPeerId, initiatorPeerId: transaction.initiatorPeerId,
participants: [...transaction.participants], participants: [...transaction.participants],
loadedPeers: [...transaction.loadedPeers], loadedPeers: [...transaction.loadedPeers],
preparedPeers: [...transaction.preparedPeers], preparedPeers: [...transaction.preparedPeers],
executedPeers: [...(transaction.executedPeers || [])],
createdAt: transaction.createdAt, createdAt: transaction.createdAt,
deadlineAt: transaction.deadlineAt, remainingMs: Number.isFinite(transaction.deadlineAt)
? Math.max(0, transaction.deadlineAt - Date.now())
: 0,
revision: transaction.revision revision: transaction.revision
}; };
} }
@@ -269,17 +286,30 @@ function clearEpisodeSyncV2Timer(transaction) {
transaction.timeout = null; transaction.timeout = null;
} }
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null) { function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null, { settlePaused = false } = {}) {
const transaction = room?.episodeSyncV2; const transaction = room?.episodeSyncV2;
if (!transaction) return false; if (!transaction) return false;
clearEpisodeSyncV2Timer(transaction); clearEpisodeSyncV2Timer(transaction);
const shouldSettlePaused = settlePaused || transaction.phase === 'executing';
if (shouldSettlePaused) {
updateMediaStateFromControl(
room,
EVENTS.PAUSE,
{ currentTime: 0, mediaTitle: transaction.expectedTitle },
transaction.initiatorPeerId,
{ now: Date.now(), senderPlaybackState: 'paused', senderMediaTitle: transaction.expectedTitle }
);
}
room.episodeSyncV2 = null; room.episodeSyncV2 = null;
room.lastEpisodeSyncV2Id = transaction.transactionId; room.lastEpisodeSyncV2Id = transaction.transactionId;
emitEpisodeSyncV2ToParticipants(roomId, room, transaction, { emitEpisodeSyncV2ToParticipants(roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'cancel'), ...publicEpisodeSyncV2(transaction, 'cancel'),
senderId: transaction.initiatorPeerId, senderId: transaction.initiatorPeerId,
reason: typeof reason === 'string' ? reason.substring(0, 32) : 'cancelled', reason: typeof reason === 'string' ? reason.substring(0, 32) : 'cancelled',
failedPeerId: typeof failedPeerId === 'string' ? failedPeerId.substring(0, 16) : undefined failedPeerId: typeof failedPeerId === 'string' ? failedPeerId.substring(0, 16) : undefined,
settlePlaybackState: shouldSettlePaused ? 'paused' : undefined,
targetTime: shouldSettlePaused ? 0 : undefined,
remainingMs: 0
}); });
return true; return true;
} }
@@ -289,7 +319,12 @@ function scheduleEpisodeSyncV2Deadline(roomId, room, transaction, timeoutMs) {
transaction.deadlineAt = Date.now() + timeoutMs; transaction.deadlineAt = Date.now() + timeoutMs;
transaction.timeout = setTimeout(() => { transaction.timeout = setTimeout(() => {
if (room.episodeSyncV2 !== transaction) return; if (room.episodeSyncV2 !== transaction) return;
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout'); const timeoutReason = transaction.phase === 'loading'
? 'load_timeout'
: (transaction.phase === 'preparing' ? 'prepare_timeout' : 'execute_timeout');
cancelEpisodeSyncV2(roomId, room, timeoutReason, null, {
settlePaused: transaction.phase === 'executing'
});
}, timeoutMs); }, timeoutMs);
transaction.timeout.unref?.(); transaction.timeout.unref?.();
} }
@@ -306,7 +341,12 @@ export function expireEpisodeSyncV2Transactions(now = Date.now()) {
for (const [roomId, room] of rooms) { for (const [roomId, room] of rooms) {
const transaction = room.episodeSyncV2; const transaction = room.episodeSyncV2;
if (transaction && Number.isFinite(transaction.deadlineAt) && transaction.deadlineAt <= now) { if (transaction && Number.isFinite(transaction.deadlineAt) && transaction.deadlineAt <= now) {
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout'); const timeoutReason = transaction.phase === 'loading'
? 'load_timeout'
: (transaction.phase === 'preparing' ? 'prepare_timeout' : 'execute_timeout');
cancelEpisodeSyncV2(roomId, room, timeoutReason, null, {
settlePaused: transaction.phase === 'executing'
});
} }
} }
} }
@@ -357,7 +397,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
// V2 freezes its participant set. Losing any participant invalidates the // V2 freezes its participant set. Losing any participant invalidates the
// barrier; continuing with a smaller set could execute before a reconnecting // barrier; continuing with a smaller set could execute before a reconnecting
// player has actually prepared. // player has actually prepared.
if (room.episodeSyncV2?.participants.includes(peerId) && !peerJoinLocks.has(peerId)) { if (room.episodeSyncV2?.participants.includes(peerId)) {
cancelEpisodeSyncV2(roomId, room, 'participant_left', peerId); cancelEpisodeSyncV2(roomId, room, 'participant_left', peerId);
} }
@@ -385,10 +425,8 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId); room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) { if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby room.activeLobby = null; // Dissolve lobby
room.legacyEpisodeSyncOwner = null;
} }
} }
if (room.legacyEpisodeSyncOwner === peerId) room.legacyEpisodeSyncOwner = null;
// 3.6. Host Control Mode: if the host left (and isn't still connected via another // 3.6. Host Control Mode: if the host left (and isn't still connected via another
// socket), fall back to 'everyone' so the room never gets stuck locked, and // socket), fall back to 'everyone' so the room never gets stuck locked, and
@@ -530,6 +568,9 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining // Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id); const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) { if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
if (rooms.get(roomId)?.episodeSyncV2) {
cancelEpisodeSyncV2(roomId, rooms.get(roomId), 'membership_changed', peerId);
}
socket.data.clientCapabilities = clientCapabilities; socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam return; // Already in this room with same peerId, ignore to prevent spam
} }
@@ -597,7 +638,6 @@ io.on('connection', (socket) => {
// transaction explicitly replaced by newer room playback. // transaction explicitly replaced by newer room playback.
forceSyncSuperseded: false, forceSyncSuperseded: false,
activeLobby: null, activeLobby: null,
legacyEpisodeSyncOwner: null,
episodeSyncV2: null, episodeSyncV2: null,
lastEpisodeSyncV2Id: null lastEpisodeSyncV2Id: null
}; };
@@ -646,6 +686,14 @@ io.on('connection', (socket) => {
return; return;
} }
// A v2 barrier freezes the exact socket membership at START.
// Any successful late join or reconnect invalidates that
// snapshot; cancel before dedupe/add so no prepared/executed ACK
// from the previous membership can complete the transaction.
if (room.episodeSyncV2) {
cancelEpisodeSyncV2(roomId, room, 'membership_changed', peerId);
}
// Peer Deduplication: Remove existing socket for the same peerId // Peer Deduplication: Remove existing socket for the same peerId
const dedupeSids = []; const dedupeSids = [];
for (const [sid, data] of room.peerData.entries()) { for (const [sid, data] of room.peerData.entries()) {
@@ -865,23 +913,20 @@ io.on('connection', (socket) => {
return; return;
} }
// A user/manual legacy command wins over automation. Cancel // Loading deliberately tolerates raw player PLAY/PAUSE/SEEK
// the v2 barrier first so prepared peers can restore safely, // churn from source swaps. During PREPARE those commands are
// then relay the newer command normally. // newer room intent and release already-paused peers. Force
if (room.episodeSyncV2 && EPISODE_SYNC_V2_SUPERSEDING_EVENTS.has(eventName)) { // Sync and an explicit legacy lobby supersede every phase.
cancelEpisodeSyncV2(mapping.roomId, room, 'superseded', mapping.peerId); if (room.episodeSyncV2) {
} const hardSupersede = EPISODE_SYNC_V2_HARD_SUPERSEDING_EVENTS.has(eventName);
const deliberateLoadingSupersede = room.episodeSyncV2.phase === 'loading'
// Legacy clients all used to self-promote after lobby ready. && data?.episodeSyncIntent === 'manual'
// Preserve their wire contract, but bind the room-wide PREPARE, && EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS.has(eventName);
// EXECUTE and CANCEL to the accepted lobby owner. const prepareSupersede = room.episodeSyncV2.phase === 'preparing'
if (room.legacyEpisodeSyncOwner && EPISODE_SYNC_V2_PREPARE_SUPERSEDING_EVENTS.has(eventName);
&& (eventName === EVENTS.FORCE_SYNC_PREPARE if (hardSupersede || deliberateLoadingSupersede || prepareSupersede) {
|| eventName === EVENTS.FORCE_SYNC_EXECUTE cancelEpisodeSyncV2(mapping.roomId, room, 'superseded', mapping.peerId);
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL) }
&& mapping.peerId !== room.legacyEpisodeSyncOwner) {
log('ROOM', `Dropped legacy episode ${eventName} from non-owner ${mapping.peerId}`);
return;
} }
const mediaStateNow = Date.now(); const mediaStateNow = Date.now();
@@ -911,16 +956,6 @@ io.on('connection', (socket) => {
room.forceSyncSuperseded = true; room.forceSyncSuperseded = true;
room.forceSyncInitiator = null; room.forceSyncInitiator = null;
room.forceSyncTarget = null; room.forceSyncTarget = null;
if (canonicalStateUpdated) room.legacyEpisodeSyncOwner = null;
}
if (canonicalStateUpdated && room.activeLobby) {
room.activeLobby = null;
room.legacyEpisodeSyncOwner = null;
io.to(mapping.roomId).emit(EVENTS.EPISODE_LOBBY_CANCEL, {
senderId: mapping.peerId,
peerId: mapping.peerId,
reason: 'superseded'
});
} }
if (eventName === EVENTS.FORCE_SYNC_PREPARE) { if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
// A malformed PREPARE must neither pause peers nor grant // A malformed PREPARE must neither pause peers nor grant
@@ -971,7 +1006,6 @@ io.on('connection', (socket) => {
room.forceSyncInitiator = null; room.forceSyncInitiator = null;
room.forceSyncTarget = null; room.forceSyncTarget = null;
room.forceSyncSuperseded = false; room.forceSyncSuperseded = false;
room.legacyEpisodeSyncOwner = null;
} }
socket.to(mapping.roomId).emit(eventName, relayPayload); socket.to(mapping.roomId).emit(eventName, relayPayload);
@@ -983,14 +1017,12 @@ io.on('connection', (socket) => {
initiatorPeerId: mapping.peerId, initiatorPeerId: mapping.peerId,
readyPeers: [mapping.peerId] readyPeers: [mapping.peerId]
}; };
room.legacyEpisodeSyncOwner = mapping.peerId;
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) { } else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) { if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
room.activeLobby.readyPeers.push(mapping.peerId); room.activeLobby.readyPeers.push(mapping.peerId);
} }
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) { } else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
room.activeLobby = null; room.activeLobby = null;
if (eventName === EVENTS.EPISODE_LOBBY_CANCEL) room.legacyEpisodeSyncOwner = null;
} }
} }
} }
@@ -1022,6 +1054,7 @@ io.on('connection', (socket) => {
const expectedTitle = typeof data.expectedTitle === 'string' const expectedTitle = typeof data.expectedTitle === 'string'
? data.expectedTitle.substring(0, 100) ? data.expectedTitle.substring(0, 100)
: ''; : '';
const expectedEpisodeId = normalizeExpectedEpisodeId(data.expectedEpisodeId);
const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY
|| (room.controllers && room.controllers.has(mapping.peerId)); || (room.controllers && room.controllers.has(mapping.peerId));
const senderData = room.peerData.get(socket.id); const senderData = room.peerData.get(socket.id);
@@ -1030,6 +1063,7 @@ io.on('connection', (socket) => {
transactionId: null, transactionId: null,
senderId: mapping.peerId, senderId: mapping.peerId,
expectedTitle, expectedTitle,
expectedEpisodeId,
reason reason
}); });
if (!expectedTitle) return rejectStart('invalid_title'); if (!expectedTitle) return rejectStart('invalid_title');
@@ -1062,17 +1096,19 @@ io.on('connection', (socket) => {
transactionId: crypto.randomUUID(), transactionId: crypto.randomUUID(),
phase: 'loading', phase: 'loading',
expectedTitle, expectedTitle,
expectedEpisodeId,
initiatorPeerId: mapping.peerId, initiatorPeerId: mapping.peerId,
participants, participants,
loadedPeers: [], loadedPeers: [],
preparedPeers: [], preparedPeers: [],
executedPeers: [],
createdAt: now, createdAt: now,
deadlineAt: now + EPISODE_LOBBY_TIMEOUT, deadlineAt: now + EPISODE_SYNC_V2_LOAD_TIMEOUT,
revision: 1, revision: 1,
timeout: null timeout: null
}; };
room.episodeSyncV2 = transaction; room.episodeSyncV2 = transaction;
scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_LOBBY_TIMEOUT); scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_SYNC_V2_LOAD_TIMEOUT);
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, { emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'lobby'), ...publicEpisodeSyncV2(transaction, 'lobby'),
senderId: transaction.initiatorPeerId senderId: transaction.initiatorPeerId
@@ -1089,7 +1125,19 @@ io.on('connection', (socket) => {
} }
if (phase === 'cancel' || phase === 'failed') { if (phase === 'cancel' || phase === 'failed') {
cancelEpisodeSyncV2(mapping.roomId, room, phase === 'failed' ? 'peer_failed' : 'peer_cancelled', mapping.peerId); cancelEpisodeSyncV2(
mapping.roomId,
room,
phase === 'failed' ? 'peer_failed' : 'peer_cancelled',
mapping.peerId
);
return;
}
if (phase === 'failed_execute' && transaction.phase === 'executing') {
cancelEpisodeSyncV2(mapping.roomId, room, 'execute_failed', mapping.peerId, {
settlePaused: true
});
return; return;
} }
@@ -1129,6 +1177,28 @@ io.on('connection', (socket) => {
return; return;
} }
transaction.phase = 'executing';
transaction.revision++;
scheduleEpisodeSyncV2Deadline(
mapping.roomId,
room,
transaction,
EPISODE_SYNC_V2_EXECUTE_TIMEOUT
);
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'execute'),
senderId: transaction.initiatorPeerId,
targetTime: 0
});
return;
}
if (phase === 'executed' && transaction.phase === 'executing') {
if (transaction.executedPeers.includes(mapping.peerId)) return;
transaction.executedPeers.push(mapping.peerId);
transaction.revision++;
if (transaction.executedPeers.length < transaction.participants.length) return;
clearEpisodeSyncV2Timer(transaction); clearEpisodeSyncV2Timer(transaction);
room.episodeSyncV2 = null; room.episodeSyncV2 = null;
room.lastEpisodeSyncV2Id = transaction.transactionId; room.lastEpisodeSyncV2Id = transaction.transactionId;
@@ -1143,9 +1213,10 @@ io.on('connection', (socket) => {
transaction.expectedTitle transaction.expectedTitle
); );
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, { emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
...publicEpisodeSyncV2(transaction, 'execute'), ...publicEpisodeSyncV2(transaction, 'complete'),
senderId: transaction.initiatorPeerId, senderId: transaction.initiatorPeerId,
targetTime: 0 targetTime: 0,
remainingMs: 0
}); });
} }
} catch (err) { } catch (err) {
+4 -2
View File
@@ -32,7 +32,7 @@ Browser extensions cannot import files outside their own root directory, so the
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority. - `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers. - `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots. - `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
- `CAPABILITIES.EPISODE_SYNC_V2`: relay owns an ID-correlated load/prepare/execute episode barrier. - `CAPABILITIES.EPISODE_SYNC_V2`: relay owns an ID-correlated load/prepare/execute/complete episode barrier.
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`. Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
@@ -58,7 +58,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition lobby started | | `EPISODE_LOBBY` | Bidirectional relay | Episode transition lobby started |
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready | | `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
| `EPISODE_LOBBY_CANCEL` | Bidirectional relay | Active episode lobby cancelled | | `EPISODE_LOBBY_CANCEL` | Bidirectional relay | Active episode lobby cancelled |
| `EPISODE_SYNC_V2` | Bidirectional, capability-gated | Relay-authoritative episode transaction (`start/lobby/loaded/prepare/prepared/execute/cancel`) | | `EPISODE_SYNC_V2` | Bidirectional, capability-gated | Relay-authoritative episode transaction (`start/lobby/loaded/prepare/prepared/execute/executed/complete/cancel`) |
| `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown | | `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown |
| `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks | | `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks |
@@ -68,7 +68,9 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs. - `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness. - `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. - `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
- `EPISODE_SYNC_V2_LOAD_TIMEOUT`: relay-owned loading window for the v2 barrier.
- `EPISODE_SYNC_V2_PREPARE_TIMEOUT`: max wait for every frozen participant to pause, seek, buffer, and verify stable state. - `EPISODE_SYNC_V2_PREPARE_TIMEOUT`: max wait for every frozen participant to pause, seek, buffer, and verify stable state.
- `EPISODE_SYNC_V2_EXECUTE_TIMEOUT`: max wait for every participant to acknowledge playback execution.
- `EPISODE_SYNC_V2_STABILITY_MS`: continuous ready-state window required before `prepared`. - `EPISODE_SYNC_V2_STABILITY_MS`: continuous ready-state window required before `prepared`.
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions. - `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
+4
View File
@@ -105,5 +105,9 @@ export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (mus
// transport grace. The target remains valid until newer room playback replaces it. // 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 FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
// V2 has a relay-owned loading phase. Keep this separate from the bundled
// legacy timer so rolling old/new rooms retain the legacy 60s wire contract.
export const EPISODE_SYNC_V2_LOAD_TIMEOUT = 120000;
export const EPISODE_SYNC_V2_PREPARE_TIMEOUT = 15000; // pause, seek, buffer and 1s stable verification export const EPISODE_SYNC_V2_PREPARE_TIMEOUT = 15000; // pause, seek, buffer and 1s stable verification
export const EPISODE_SYNC_V2_EXECUTE_TIMEOUT = 10000; // apply playback and acknowledge before commit
export const EPISODE_SYNC_V2_STABILITY_MS = 1000; export const EPISODE_SYNC_V2_STABILITY_MS = 1000;
@@ -0,0 +1,159 @@
import { test, expect } from './helpers/extension-fixture.mjs';
async function withExtensionPage(context, extensionId, fn) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
try {
return await fn(page);
} finally {
await page.close();
}
}
async function selectTargetTab(context, extensionId, pageUrl) {
return withExtensionPage(context, extensionId, page => page.evaluate(async url => {
const [tab] = await chrome.tabs.query({ url });
if (!tab) throw new Error(`no tab matched ${url}`);
await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id });
return tab.id;
}, pageUrl));
}
async function extensionMessage(context, extensionId, message) {
return withExtensionPage(context, extensionId, page => page.evaluate(
payload => chrome.runtime.sendMessage(payload),
message
));
}
async function prepareEpisodePage(page, title, currentTime) {
await page.waitForFunction(() => window.__fixtureReady === true);
await page.evaluate(async ({ title, currentTime }) => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title });
const video = document.querySelector('#player');
video.muted = true;
video.playbackRate = 0.1;
video.currentTime = currentTime;
await video.play();
}, { title, currentTime });
}
async function contentLogs(context, extensionId) {
const logs = await extensionMessage(context, extensionId, { type: 'GET_LOGS' });
return logs.filter(entry => entry.message.includes('[Content]')).map(entry => entry.message);
}
test('keeps ordinary play/pause outside an episode boundary on the immediate path @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 3);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').evaluate(video => video.pause());
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 750 }).toBe(before.length + 1);
});
test('quarantines a suspicious boundary pause, then relays the final intent when no episode changes @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 9.25);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').evaluate(video => video.pause());
await page.waitForTimeout(500);
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 4000 }).toBe(before.length + 1);
const after = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
expect(after[0]).toMatchObject({ action: 'pause' });
});
test('relays a deliberate boundary pause immediately @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 9.25);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.locator('#player').dispatchEvent('pointerdown');
await page.locator('#player').evaluate(video => video.pause());
await expect.poll(async () => {
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
return history.length;
}, { timeout: 750 }).toBe(before.length + 1);
});
test('does not consume title-before-loadeddata or loadeddata-before-title transitions @episode-transition', async ({ context, extensionId, baseURL }) => {
const runOrdering = async ordering => {
const url = `${baseURL}/pages/simple-player.html?ordering=${ordering}-${Date.now()}`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 1);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
if (ordering === 'title-first') {
await page.evaluate(() => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
document.querySelector('#player').dispatchEvent(new window.Event('loadeddata'));
});
} else {
await page.evaluate(() => document.querySelector('#player').dispatchEvent(new window.Event('loadeddata')));
await page.waitForTimeout(150);
await page.evaluate(() => {
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
});
}
await expect.poll(async () => {
const logs = await contentLogs(context, extensionId);
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
}, { timeout: 3000 }).toBe(true);
await page.close();
await extensionMessage(context, extensionId, { type: 'CLEAR_LOGS' });
};
await runOrdering('title-first');
await runOrdering('loadeddata-first');
});
test('discards source-swap pause/play churn after the episode is confirmed @episode-transition', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await prepareEpisodePage(page, 'Series S01E05', 11);
await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
await page.evaluate(async () => {
const video = document.querySelector('#player');
video.pause();
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
video.src = '../media/player-1080p-30s.mp4';
video.load();
await new Promise(resolve => video.addEventListener('loadeddata', resolve, { once: true }));
await video.play();
});
await expect.poll(async () => {
const logs = await contentLogs(context, extensionId);
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
}).toBe(true);
await page.waitForTimeout(2300);
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
});
+92
View File
@@ -1598,10 +1598,102 @@ test('completes Episode Sync v2 only after the packed player is stably prepared'
lobby.transactionId lobby.transactionId
); );
expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 }); expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 });
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.executedPeers || [])
.toContain(extensionPeerId);
expect(relay.rooms.get(roomId)?.mediaState).toBeNull();
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'executed',
transactionId: lobby.transactionId
});
const complete = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'complete',
lobby.transactionId
);
expect(complete.executedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2).toBeNull(); await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2).toBeNull();
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false); await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(3); await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(3);
// A peer can fail after this packed player has already started. The
// terminal cancel must actively settle it back to paused at 0:00.
coordinator.messages.length = 0;
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'start',
expectedTitle: 'S1:E6 - Visiting Ours',
expectedEpisodeId: 'S01E06'
});
const rollbackLobby = await waitForLegacyRelayEvent(coordinator, 'episode_sync_v2');
expect(rollbackLobby.phase).toBe('lobby');
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'loaded',
transactionId: rollbackLobby.transactionId
});
const rollbackPrepare = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'prepare',
rollbackLobby.transactionId
);
expect(rollbackPrepare.loadedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
try {
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.preparedPeers || [])
.toContain(extensionPeerId);
} catch (error) {
const [logs, playerState] = await Promise.all([
getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => []),
page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime,
readyState: video.readyState,
seeking: video.seeking
}))
]);
const transaction = relay.rooms.get(roomId)?.episodeSyncV2;
console.error(`Episode v2 rollback prepare diagnostics: ${JSON.stringify({
transaction: transaction ? {
transactionId: transaction.transactionId,
phase: transaction.phase,
loadedPeers: transaction.loadedPeers,
preparedPeers: transaction.preparedPeers,
deadlineAt: transaction.deadlineAt
} : null,
playerState,
logs: logs.slice(-20)
})}`);
throw error;
}
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'prepared',
transactionId: rollbackLobby.transactionId
});
await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'execute',
rollbackLobby.transactionId
);
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
phase: 'failed_execute',
transactionId: rollbackLobby.transactionId,
reason: 'fixture_failure'
});
const rollbackCancel = await waitForLegacyRelayPhase(
coordinator,
'episode_sync_v2',
'cancel',
rollbackLobby.transactionId
);
expect(rollbackCancel).toMatchObject({
reason: 'execute_failed',
settlePlaybackState: 'paused',
targetTime: 0
});
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(1);
const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => { const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => {
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; } try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
})()); })());