mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 14:37:55 +00:00
fix(sync): make episode transitions relay-authoritative
This commit is contained in:
+40
-2
@@ -54,7 +54,7 @@ Payload:
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"clientCapabilities": ["chat-v1", "media-state-v1"],
|
||||
"clientCapabilities": ["chat-v1", "media-state-v1", "episode-sync-v2"],
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
@@ -84,7 +84,8 @@ Payload:
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"mediaState": "canonical media state object or null",
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1"]
|
||||
"episodeSyncV2": "active transaction object for a frozen participant, or null",
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1", "episode-sync-v2"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -526,5 +527,42 @@ If sender and target are still in the same room, the relay emits:
|
||||
- `co-host`
|
||||
- `chat`
|
||||
- `chat-v1`
|
||||
- `media-state-v1`
|
||||
- `episode-sync-v2`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
|
||||
## Episode Sync v2
|
||||
|
||||
Relays advertise `"episode-sync-v2"`. Updated clients use the additive
|
||||
`episode_sync_v2` event without changing `PROTOCOL_VERSION`; automatic episode
|
||||
sync is skipped safely when the relay omits the capability or any current room
|
||||
peer did not announce it. Manual Force Sync remains on the legacy event family.
|
||||
|
||||
The relay creates the transaction ID, freezes non-desynced participants, owns
|
||||
deadlines, and is the only component that advances:
|
||||
|
||||
```text
|
||||
start -> lobby/loading -> prepare -> execute
|
||||
\-> cancel
|
||||
```
|
||||
|
||||
Client phases are `start`, `loaded`, `prepared`, `failed`, and `cancel`. Relay
|
||||
phases are `lobby`, `prepare`, `execute`, and `cancel`. Every post-start frame is
|
||||
correlated by `transactionId`; duplicate or stale frames are idempotently
|
||||
ignored. No participant is pre-marked loaded. `execute` is emitted exactly once
|
||||
only after every frozen participant reports `loaded`, then pauses, seeks to
|
||||
0:00, reaches `readyState >= 3`, and remains on the same player/title in paused,
|
||||
non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`.
|
||||
|
||||
Any timeout, failed player action, participant departure, manual media command,
|
||||
room/target change, or explicit cancellation produces `cancel`; v2 never
|
||||
executes after a timeout. Peers resume only when that transaction paused a
|
||||
previously playing player and no newer local user action superseded it. Peers
|
||||
joining after `start` are excluded from the frozen barrier and receive no v2
|
||||
frames for that transaction.
|
||||
|
||||
Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE,
|
||||
and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire
|
||||
actions; an unmodified old non-initiator can still run its own local timeout, so
|
||||
full v2 guarantees require capable extensions on every peer.
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('async room-session guards', () => {
|
||||
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
|
||||
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
|
||||
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
expect(roomData).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
|
||||
});
|
||||
|
||||
it('does not return chat context after its room or target changed', () => {
|
||||
|
||||
+292
-74
@@ -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 { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { sameEpisode, sameEpisodeStrict, extractEpisodeId } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
|
||||
@@ -290,7 +290,8 @@ function serverSupportsChat() {
|
||||
}
|
||||
const CLIENT_CAPABILITIES = Object.freeze([
|
||||
CAPABILITIES.CHAT_V1,
|
||||
CAPABILITIES.MEDIA_STATE_V1
|
||||
CAPABILITIES.MEDIA_STATE_V1,
|
||||
CAPABILITIES.EPISODE_SYNC_V2
|
||||
]);
|
||||
|
||||
function persistCanonicalMediaRecovery() {
|
||||
@@ -462,7 +463,7 @@ function ensureState() {
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
|
||||
'selectedTabId', 'selectedTabTitle', 'selectionErrorTabId', 'selectionErrorMessage',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'episodeLobby', 'episodeSyncV2', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
|
||||
'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery'
|
||||
], (data) => {
|
||||
// A late callback must not resurrect a room, queue or canonical
|
||||
@@ -583,6 +584,20 @@ function ensureState() {
|
||||
}
|
||||
}
|
||||
|
||||
const restoredEpisodeSyncV2 = currentRoom
|
||||
? normalizeEpisodeSyncV2(
|
||||
data.episodeSyncV2 || currentRoom.episodeSyncV2,
|
||||
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||
)
|
||||
: null;
|
||||
if (restoredEpisodeSyncV2) {
|
||||
episodeSyncV2 = restoredEpisodeSyncV2;
|
||||
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
|
||||
episodeLobbyTimeout = null;
|
||||
episodeLobby = null;
|
||||
currentRoom.activeLobby = null;
|
||||
}
|
||||
|
||||
if (Number.isSafeInteger(data.localSeq) && data.localSeq >= 0) localSeq = data.localSeq;
|
||||
eventQueue = normalizePersistedEventQueue(
|
||||
[...eventQueue, ...(Array.isArray(data.eventQueue) ? data.eventQueue : [])],
|
||||
@@ -661,6 +676,7 @@ let forceSyncTimeout = null;
|
||||
// Episode Auto-Sync Lobby
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
let episodeSyncV2 = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
@@ -1192,6 +1208,46 @@ async function clearTargetSelectionForLifecycle({
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeEpisodeSyncV2(value, allowedPeerIds = null) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const transactionId = typeof value.transactionId === 'string'
|
||||
? value.transactionId.substring(0, 64)
|
||||
: '';
|
||||
const phase = value.phase === 'lobby' || value.phase === 'prepare' ? value.phase : '';
|
||||
const expectedTitle = typeof value.expectedTitle === 'string'
|
||||
? value.expectedTitle.substring(0, 100)
|
||||
: '';
|
||||
const initiatorPeerId = typeof value.initiatorPeerId === 'string'
|
||||
? value.initiatorPeerId.substring(0, 16)
|
||||
: '';
|
||||
if (!transactionId || !phase || !expectedTitle || !initiatorPeerId
|
||||
|| !Array.isArray(value.participants)
|
||||
|| !Array.isArray(value.loadedPeers)
|
||||
|| !Array.isArray(value.preparedPeers)) return null;
|
||||
const normalizePeers = peers => [...new Set(peers
|
||||
.filter(candidate => typeof candidate === 'string' && candidate)
|
||||
.map(candidate => candidate.substring(0, 16)))];
|
||||
const participants = normalizePeers(value.participants);
|
||||
const participantSet = new Set(participants);
|
||||
const loadedPeers = normalizePeers(value.loadedPeers).filter(candidate => participantSet.has(candidate));
|
||||
const preparedPeers = normalizePeers(value.preparedPeers).filter(candidate => participantSet.has(candidate));
|
||||
if (participants.length < 2
|
||||
|| !participantSet.has(initiatorPeerId)
|
||||
|| (allowedPeerIds && participants.some(candidate => !allowedPeerIds.has(candidate)))) return null;
|
||||
return {
|
||||
transactionId,
|
||||
phase,
|
||||
expectedTitle,
|
||||
initiatorPeerId,
|
||||
participants,
|
||||
loadedPeers,
|
||||
preparedPeers,
|
||||
createdAt: Number.isFinite(value.createdAt) ? value.createdAt : Date.now(),
|
||||
deadlineAt: Number.isFinite(value.deadlineAt) ? value.deadlineAt : null,
|
||||
revision: Number.isSafeInteger(value.revision) && value.revision > 0 ? value.revision : 1
|
||||
};
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
|
||||
return clearTargetSelectionForLifecycle({
|
||||
expectedTabId,
|
||||
@@ -1212,6 +1268,8 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
||||
// Stop room-specific polling before the content script itself is removed.
|
||||
// Every terminal room exit must pass through the exact target identity while
|
||||
// it is still available, regardless of who initiated the exit.
|
||||
if (notifyServer) cancelEpisodeSyncV2('room_exit');
|
||||
else clearEpisodeSyncV2State({ reason: 'room_exit' });
|
||||
clearEpisodeLobbyState();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
@@ -1249,6 +1307,7 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
episodeLobby: null,
|
||||
episodeSyncV2: null,
|
||||
hcmDesynced: false,
|
||||
reconnectFailed: false,
|
||||
reconnectAttempts: 0,
|
||||
@@ -1845,7 +1904,7 @@ async function flushEventQueue(replaySettingsOverride = undefined) {
|
||||
}
|
||||
applyQueuedRoomPolicy(flushRoomId, {
|
||||
canControl: !(controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController()),
|
||||
activeLobby: !!episodeLobby,
|
||||
activeLobby: !!(episodeLobby || episodeSyncV2),
|
||||
desynced: hcmDesynced,
|
||||
authoritativeLobby: !!currentRoom?.activeLobby
|
||||
}, 'Queue replay authority changed');
|
||||
@@ -2020,9 +2079,9 @@ async function performPendingCanonicalMediaStateApply() {
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: local guest is desynced`, 'info');
|
||||
return { status: 'ignored_desynced' };
|
||||
}
|
||||
if (episodeLobby) {
|
||||
if (episodeLobby || episodeSyncV2) {
|
||||
markCanonicalMediaStateHandled(roomId, mediaState.revision);
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: episode lobby is active`, 'info');
|
||||
addLog(`Canonical media state r${mediaState.revision} skipped: episode sync is active`, 'info');
|
||||
return { status: 'ignored_episode_lobby' };
|
||||
}
|
||||
|
||||
@@ -2193,6 +2252,29 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
currentRoom.peers = [];
|
||||
}
|
||||
|
||||
const roomPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId));
|
||||
const authoritativeEpisodeSyncV2 = serverSupports(CAPABILITIES.EPISODE_SYNC_V2)
|
||||
? normalizeEpisodeSyncV2(data.episodeSyncV2, roomPeerIds)
|
||||
: null;
|
||||
if (data.episodeSyncV2 && !authoritativeEpisodeSyncV2) {
|
||||
addLog('Ignored malformed Episode Sync v2 transaction in ROOM_DATA', 'warn');
|
||||
}
|
||||
if (authoritativeEpisodeSyncV2) {
|
||||
const shouldNotifyContent = !episodeSyncV2
|
||||
|| episodeSyncV2.transactionId !== authoritativeEpisodeSyncV2.transactionId
|
||||
|| episodeSyncV2.phase !== authoritativeEpisodeSyncV2.phase;
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
episodeSyncV2 = authoritativeEpisodeSyncV2;
|
||||
currentRoom.episodeSyncV2 = authoritativeEpisodeSyncV2;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
|
||||
} else if (episodeSyncV2) {
|
||||
clearEpisodeSyncV2State({ reason: 'relay_state_ended' });
|
||||
} else {
|
||||
currentRoom.episodeSyncV2 = null;
|
||||
}
|
||||
|
||||
// ROOM_DATA is authoritative for an already-active server lobby,
|
||||
// but a locally-created offline lobby has not reached the relay
|
||||
// yet and must remain owned by its initiator until queued replay.
|
||||
@@ -2200,7 +2282,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
entry?.event === EVENTS.EPISODE_LOBBY
|
||||
&& (!entry.roomId || entry.roomId === data.roomId)
|
||||
);
|
||||
const authoritativeLobby = normalizeEpisodeLobby(
|
||||
const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(
|
||||
data.activeLobby,
|
||||
Date.now(),
|
||||
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||
@@ -2274,12 +2356,12 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
&& !amController();
|
||||
const queuePolicy = applyQueuedRoomPolicy(data.roomId, {
|
||||
canControl: !lostRoomAuthority,
|
||||
activeLobby: !!episodeLobby,
|
||||
activeLobby: !!(episodeLobby || episodeSyncV2),
|
||||
desynced: hcmDesynced,
|
||||
authoritativeLobby: !!authoritativeLobby
|
||||
authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)
|
||||
}, lostRoomAuthority
|
||||
? 'Host Control role changed while offline'
|
||||
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
|
||||
: ((episodeLobby || episodeSyncV2) ? 'Active Episode Sync takes precedence' : 'Reconnect queue policy'));
|
||||
|
||||
await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent);
|
||||
await flushEventQueue(replaySettings);
|
||||
@@ -2608,6 +2690,76 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_SYNC_V2: {
|
||||
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
|
||||
addLog('Ignored Episode Sync v2 event from relay without advertised capability', 'warn');
|
||||
break;
|
||||
}
|
||||
const phase = data.phase;
|
||||
if (phase === 'cancel' && !data.transactionId) {
|
||||
addLog(`Episode Sync v2 unavailable: ${data.reason || 'rejected'}`, 'warn');
|
||||
break;
|
||||
}
|
||||
if (phase === 'lobby' || phase === 'prepare') {
|
||||
const activePeerIds = new Set((currentRoom?.peers || []).map(candidate =>
|
||||
typeof candidate === 'object' ? candidate.peerId : candidate
|
||||
));
|
||||
const incoming = normalizeEpisodeSyncV2(data, activePeerIds);
|
||||
if (!incoming
|
||||
|| !incoming.participants.includes(peerId)
|
||||
|| data.senderId !== incoming.initiatorPeerId) {
|
||||
addLog('Ignored malformed Episode Sync v2 state', 'warn');
|
||||
break;
|
||||
}
|
||||
if (episodeSyncV2
|
||||
&& episodeSyncV2.transactionId === incoming.transactionId
|
||||
&& incoming.revision < episodeSyncV2.revision) {
|
||||
addLog(`Ignored stale Episode Sync v2 revision ${incoming.revision}`, 'warn');
|
||||
break;
|
||||
}
|
||||
const shouldNotifyContent = !episodeSyncV2
|
||||
|| episodeSyncV2.transactionId !== incoming.transactionId
|
||||
|| episodeSyncV2.phase !== incoming.phase;
|
||||
if (episodeSyncV2 && episodeSyncV2.transactionId !== incoming.transactionId) {
|
||||
clearEpisodeSyncV2State({ reason: 'transaction_replaced' });
|
||||
}
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
episodeSyncV2 = incoming;
|
||||
if (currentRoom) currentRoom.episodeSyncV2 = incoming;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (shouldNotifyContent) sendEpisodeSyncV2ToContent().catch(() => {});
|
||||
addLog(`Episode Sync v2 ${incoming.phase}: "${incoming.expectedTitle}" (${incoming.transactionId.substring(0, 8)})`, 'info');
|
||||
break;
|
||||
}
|
||||
if ((phase === 'execute' || phase === 'cancel')
|
||||
&& episodeSyncV2
|
||||
&& data.transactionId === episodeSyncV2.transactionId) {
|
||||
const completed = episodeSyncV2;
|
||||
if (phase === 'execute') {
|
||||
sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: { ...completed, phase: 'execute', targetTime: 0 }
|
||||
}).catch(() => {});
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers.forEach(candidate => {
|
||||
if (candidate && typeof candidate === 'object') {
|
||||
candidate.playbackState = 'playing';
|
||||
candidate.currentTime = 0;
|
||||
candidate.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
clearEpisodeSyncV2State({ notifyContent: false, reason: 'executed' });
|
||||
addLog(`Episode Sync v2 executed for "${completed.expectedTitle}"`, 'success');
|
||||
} else {
|
||||
clearEpisodeSyncV2State({ reason: data.reason || 'cancelled' });
|
||||
addLog(`Episode Sync v2 cancelled: ${data.reason || 'cancelled'}`, 'warn');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EVENTS.EPISODE_LOBBY:
|
||||
if (typeof data.senderId === 'string'
|
||||
&& typeof data.expectedTitle === 'string'
|
||||
@@ -2768,11 +2920,13 @@ function executeForceSync() {
|
||||
}
|
||||
|
||||
function completeForceSyncBeforeTargetChange(nextTabId) {
|
||||
if (!isForceSyncInitiator) return;
|
||||
const selectedTabId = normalizeTabId(currentTabId);
|
||||
const normalizedNextTabId = normalizeTabId(nextTabId);
|
||||
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
|
||||
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('target_changed');
|
||||
if (!isForceSyncInitiator) return;
|
||||
|
||||
addLog('Finishing Force Sync before target change', 'info');
|
||||
executeForceSync();
|
||||
}
|
||||
@@ -2782,8 +2936,66 @@ function persistEpisodeLobby() {
|
||||
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
|
||||
}
|
||||
|
||||
function episodeLobbyForUi() {
|
||||
if (!episodeSyncV2) return episodeLobby;
|
||||
return {
|
||||
expectedTitle: episodeSyncV2.expectedTitle,
|
||||
initiatorPeerId: episodeSyncV2.initiatorPeerId,
|
||||
readyPeers: episodeSyncV2.phase === 'prepare'
|
||||
? [...episodeSyncV2.preparedPeers]
|
||||
: [...episodeSyncV2.loadedPeers],
|
||||
createdAt: episodeSyncV2.createdAt,
|
||||
mode: 'v2',
|
||||
phase: episodeSyncV2.phase,
|
||||
transactionId: episodeSyncV2.transactionId
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastLobbyUpdate() {
|
||||
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
|
||||
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobbyForUi() }).catch(() => {});
|
||||
}
|
||||
|
||||
function persistEpisodeSyncV2() {
|
||||
if (!storageInitialized) return;
|
||||
chrome.storage.session.set({ episodeSyncV2, currentRoom }).catch(() => {});
|
||||
}
|
||||
|
||||
function sendEpisodeSyncV2ToContent(transaction = episodeSyncV2) {
|
||||
if (!transaction || !currentTabId) return Promise.resolve();
|
||||
return sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: { ...transaction }
|
||||
});
|
||||
}
|
||||
|
||||
function clearEpisodeSyncV2State({ notifyContent = true, reason = 'cancelled' } = {}) {
|
||||
const previous = episodeSyncV2;
|
||||
episodeSyncV2 = null;
|
||||
if (currentRoom) currentRoom.episodeSyncV2 = null;
|
||||
persistEpisodeSyncV2();
|
||||
broadcastLobbyUpdate();
|
||||
if (notifyContent && previous && currentTabId) {
|
||||
sendMessageToCurrentContent({
|
||||
type: 'EPISODE_SYNC_V2',
|
||||
transaction: {
|
||||
...previous,
|
||||
phase: 'cancel',
|
||||
reason
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEpisodeSyncV2(reason = 'cancelled') {
|
||||
if (!episodeSyncV2) return false;
|
||||
const transaction = episodeSyncV2;
|
||||
emitLive(EVENTS.EPISODE_SYNC_V2, {
|
||||
phase: 'cancel',
|
||||
transactionId: transaction.transactionId,
|
||||
reason
|
||||
});
|
||||
clearEpisodeSyncV2State({ reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearEpisodeLobbyState() {
|
||||
@@ -2808,7 +3020,7 @@ function cancelEpisodeLobby(reason) {
|
||||
supersedeCanonicalMediaRecovery('local episode_lobby_cancel');
|
||||
|
||||
// Broadcast cancellation to room
|
||||
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
emitLive(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
|
||||
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
||||
@@ -2869,7 +3081,7 @@ function executeEpisodeLobby() {
|
||||
expectedAcksCount: expectedAcksCount
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
const syncPayload = { targetTime: 0.0, mediaTitle: title };
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
|
||||
@@ -2885,6 +3097,7 @@ function executeEpisodeLobby() {
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
if (episodeLobby.initiatorPeerId !== peerId) return;
|
||||
const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : [];
|
||||
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
||||
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
||||
@@ -2895,7 +3108,7 @@ function checkEpisodeLobbyCompletion() {
|
||||
.filter(Boolean));
|
||||
const readyParticipatingCount = episodeLobby.readyPeers
|
||||
.filter(candidate => participatingPeerIds.has(candidate)).length;
|
||||
if (readyParticipatingCount >= participatingPeerIds.size) {
|
||||
if (participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size) {
|
||||
executeEpisodeLobby();
|
||||
}
|
||||
}
|
||||
@@ -4456,6 +4669,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {});
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
cancelEpisodeSyncV2('room_switch');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
clearCanonicalMediaRecovery();
|
||||
@@ -4680,7 +4894,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
pendingTargetHost: pendingTarget?.host ?? null,
|
||||
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
|
||||
pendingTargetRequestId: pendingTarget?.requestId ?? null,
|
||||
episodeLobby: episodeLobby,
|
||||
episodeLobby: episodeLobbyForUi(),
|
||||
reconnectAttempts,
|
||||
reconnectSlowMode: reconnectFailed,
|
||||
queuedLogicalEvents: eventQueue.length,
|
||||
@@ -4914,6 +5128,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
if (hcmDesynced && episodeSyncV2?.participants.includes(peerId)) {
|
||||
cancelEpisodeSyncV2('desynced');
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||
@@ -5254,7 +5471,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
emitLive(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
@@ -5441,12 +5658,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
||||
// server drops the guest's EPISODE_LOBBY, so the lobby would never complete
|
||||
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
||||
// host-only the controllers (owner + co-hosts) drive episode sync; a plain
|
||||
// guest just follows / snaps back. Use amController() for parity with the
|
||||
// CONTENT_EVENT gate and the server's controllers-based check.
|
||||
// Host Control Mode: only controllers may initiate the relay-owned
|
||||
// transaction. Plain guests wait for the controller's accepted lobby.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !amController()) {
|
||||
addLog(`Episode change ("${lobbyTitle}") — host-only guest, not creating a lobby (controller drives).`, 'info');
|
||||
sendResponse({ status: 'host_only_guest_skip' });
|
||||
@@ -5463,57 +5676,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, lobbyTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, {
|
||||
peerId,
|
||||
title: lobbyTitle,
|
||||
expectedTitle: episodeLobby.expectedTitle
|
||||
});
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
sendResponse({ status: 'ready_sent' });
|
||||
// Automatic episode sync v2 is relay-owned. Never self-pause or fall
|
||||
// back to the legacy client-owned lobby: on an old/mixed relay that path
|
||||
// can create multiple Force Sync initiators. Manual Force Sync remains
|
||||
// available and unchanged.
|
||||
if (!serverSupports(CAPABILITIES.EPISODE_SYNC_V2)) {
|
||||
addLog(`Episode change ("${lobbyTitle}") — relay lacks Episode Sync v2; automatic sync skipped safely.`, 'warn');
|
||||
sendResponse({ status: 'episode_sync_v2_unsupported' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any existing lobby for a different episode
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
|
||||
// Create new lobby
|
||||
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
|
||||
episodeLobby = {
|
||||
expectedTitle: lobbyTitle,
|
||||
initiatorPeerId: peerId,
|
||||
readyPeers: [peerId], // We are already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Episode lobby created: "${lobbyTitle}"`, 'info');
|
||||
|
||||
// Tell content script to pause the video and start polling
|
||||
// (This is the only place we pause — after confirming the feature is enabled)
|
||||
if (sender.tab && sender.tab.id) {
|
||||
sendMessageToFrame(sender.tab.id, sender.frameId, {
|
||||
type: 'PAUSE_FOR_LOBBY',
|
||||
expectedTitle: lobbyTitle
|
||||
}, null, sender.documentId).catch(() => {});
|
||||
if (episodeSyncV2 && sameEpisode(episodeSyncV2.expectedTitle, lobbyTitle)) {
|
||||
sendResponse({ status: 'transaction_active', transactionId: episodeSyncV2.transactionId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Broadcast to room
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: lobbyTitle });
|
||||
|
||||
// Start timeout (Q1: Option B — cancel on timeout)
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
|
||||
|
||||
// Immediate check — maybe we're the only one in the room
|
||||
checkEpisodeLobbyCompletion();
|
||||
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('new_episode');
|
||||
if (!emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start', expectedTitle: lobbyTitle })) {
|
||||
addLog(`Episode change ("${lobbyTitle}") — not connected; automatic sync was not queued.`, 'warn');
|
||||
sendResponse({ status: 'episode_sync_v2_offline' });
|
||||
return;
|
||||
}
|
||||
addLog(`Episode Sync v2 requested: "${lobbyTitle}"`, 'info');
|
||||
sendResponse({ status: 'episode_sync_v2_requested' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
if (sender.tab) {
|
||||
if (!isCurrentContentSender(sender)) {
|
||||
@@ -5542,7 +5725,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
lobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, {
|
||||
emitLive(EVENTS.EPISODE_READY, {
|
||||
peerId,
|
||||
title: readyTitle,
|
||||
expectedTitle: lobby.expectedTitle
|
||||
@@ -5552,6 +5735,35 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'EPISODE_SYNC_V2_LOCAL') {
|
||||
if (sender.tab && !isCurrentContentSender(sender)) {
|
||||
sendResponse({ status: 'ignored_stale_target' });
|
||||
return;
|
||||
}
|
||||
const transaction = episodeSyncV2;
|
||||
const localPhase = message.phase;
|
||||
const expectedLocalPhase = localPhase === 'loaded'
|
||||
? 'lobby'
|
||||
: (localPhase === 'prepared' ? 'prepare' : null);
|
||||
if (!transaction
|
||||
|| message.transactionId !== transaction.transactionId
|
||||
|| (expectedLocalPhase && transaction.phase !== expectedLocalPhase)
|
||||
|| !['loaded', 'prepared', 'failed'].includes(localPhase)) {
|
||||
sendResponse({ status: 'ignored_stale_transaction' });
|
||||
return;
|
||||
}
|
||||
const localTitle = message.payload?.title;
|
||||
if (localPhase !== 'failed'
|
||||
&& (typeof localTitle !== 'string' || !sameEpisodeStrict(localTitle, transaction.expectedTitle))) {
|
||||
sendResponse({ status: 'ignored_episode_mismatch' });
|
||||
return;
|
||||
}
|
||||
const sent = emitLive(EVENTS.EPISODE_SYNC_V2, {
|
||||
phase: localPhase,
|
||||
transactionId: transaction.transactionId,
|
||||
reason: typeof message.reason === 'string' ? message.reason.substring(0, 32) : undefined
|
||||
});
|
||||
sendResponse({ status: sent ? 'sent' : 'offline' });
|
||||
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
||||
const privacyRoomId = currentRoom?.roomId || null;
|
||||
const privacyLobby = episodeLobby;
|
||||
@@ -5566,6 +5778,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
cancelEpisodeLobby('Title privacy changed');
|
||||
}
|
||||
}
|
||||
if (episodeSyncV2) cancelEpisodeSyncV2('title_privacy_changed');
|
||||
if (currentRoom) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
@@ -5650,13 +5863,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
requestCanonicalMediaRecoveryAttempt();
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
if (episodeSyncV2) {
|
||||
sendResponse({ episodeSyncV2: { ...episodeSyncV2 }, lobbyActive: false });
|
||||
} else if (episodeLobby) {
|
||||
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
|
||||
} else {
|
||||
sendResponse({ lobbyActive: false });
|
||||
}
|
||||
} else if (message.type === 'CANCEL_EPISODE_LOBBY') {
|
||||
if (episodeLobby) {
|
||||
if (episodeSyncV2) {
|
||||
cancelEpisodeSyncV2('cancelled_by_user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (episodeLobby) {
|
||||
cancelEpisodeLobby('Cancelled by user');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('canonical ROOM_DATA recovery contract', () => {
|
||||
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
|
||||
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
expect(apply).toContain('if (episodeLobby)');
|
||||
expect(apply).toContain('if (episodeLobby || episodeSyncV2)');
|
||||
expect(roomData).toContain('if (hasPendingLocalIntent)');
|
||||
expect(backgroundSource).toContain('awaitingRoomData = true');
|
||||
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
|
||||
|
||||
+332
-2
@@ -84,6 +84,7 @@
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
const MAX_MEDIA_TIME = 86400;
|
||||
const EPISODE_SYNC_V2_STABILITY_MS = 1000;
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
@@ -334,6 +335,9 @@
|
||||
let episodeTransitionDebounce = null;
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
let episodeSyncV2State = null;
|
||||
let episodeSyncV2PollTimer = null;
|
||||
let episodeSyncV2Generation = 0;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
let _audioSettings = null;
|
||||
let _audioProcessingAllowed = true;
|
||||
@@ -1104,6 +1108,28 @@
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
|
||||
function episodeContext(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
|
||||
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Transactional episode sync is deliberately stricter than ordinary playback
|
||||
// filtering: if both peers expose contextual text (episode/show name), require
|
||||
// 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.
|
||||
function sameEpisodeStrict(titleA, titleB) {
|
||||
if (!sameEpisode(titleA, titleB)) return false;
|
||||
const contextA = episodeContext(titleA);
|
||||
const contextB = episodeContext(titleB);
|
||||
return !contextA || !contextB || contextA === contextB;
|
||||
}
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
@@ -1215,6 +1241,244 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isEpisodeSyncV2Current(state) {
|
||||
return !destroyed
|
||||
&& episodeSyncV2State === state
|
||||
&& state.generation === episodeSyncV2Generation;
|
||||
}
|
||||
|
||||
function stopEpisodeSyncV2Poll() {
|
||||
if (episodeSyncV2PollTimer) {
|
||||
clearInterval(episodeSyncV2PollTimer);
|
||||
episodeSyncV2PollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportEpisodeSyncV2Local(state, phase, reason = null) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.reportInFlight) return false;
|
||||
state.reportInFlight = true;
|
||||
try {
|
||||
const response = await runtimeMessage({
|
||||
type: 'EPISODE_SYNC_V2_LOCAL',
|
||||
transactionId: state.transactionId,
|
||||
phase,
|
||||
reason,
|
||||
payload: { title: getMediaTitle() }
|
||||
});
|
||||
if (!isEpisodeSyncV2Current(state)) return false;
|
||||
if (response?.status === 'sent') {
|
||||
if (phase === 'loaded') state.loadedReported = true;
|
||||
if (phase === 'prepared') state.preparedReported = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isEpisodeSyncV2Current(state)) state.reportInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function retryEpisodeSyncV2Report(state, phase, reason = null) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.reportRetryScheduled) return;
|
||||
if (Number.isFinite(state.deadlineAt) && Date.now() >= state.deadlineAt) return;
|
||||
state.reportRetryScheduled = true;
|
||||
scheduleLifecycleTimeout(async () => {
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
state.reportRetryScheduled = false;
|
||||
const sent = await reportEpisodeSyncV2Local(state, phase, reason);
|
||||
if (!sent) retryEpisodeSyncV2Report(state, phase, reason);
|
||||
}, 750);
|
||||
}
|
||||
|
||||
async function clearEpisodeSyncV2Content({ resume = false, manualAction = false } = {}) {
|
||||
const state = episodeSyncV2State;
|
||||
if (!state) return;
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = null;
|
||||
stopEpisodeSyncV2Poll();
|
||||
state.manualAction = state.manualAction || manualAction;
|
||||
const video = state.video;
|
||||
const mayResume = resume
|
||||
&& state.pausedByTransaction
|
||||
&& state.wasPlayingBeforePrepare
|
||||
&& !state.manualAction
|
||||
&& video
|
||||
&& video === findVideo()
|
||||
&& video.isConnected !== false
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
||||
&& video.paused;
|
||||
if (mayResume) {
|
||||
const resumed = await tryMediaAction(EVENTS.PLAY);
|
||||
if (!resumed) reportLog('Episode Sync v2: could not restore pre-transaction playback', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function checkEpisodeSyncV2Loaded(state) {
|
||||
if (!isEpisodeSyncV2Current(state) || state.phase !== 'lobby' || state.loadedReported) return;
|
||||
const video = findVideo();
|
||||
const title = getMediaTitle();
|
||||
const matches = video
|
||||
&& title
|
||||
&& sameEpisodeStrict(title, state.expectedTitle)
|
||||
&& video.readyState >= 1
|
||||
&& getSyncCurrentTime(video) !== null;
|
||||
if (!matches) {
|
||||
state.loadCandidateVideo = null;
|
||||
state.loadCandidateAt = 0;
|
||||
return;
|
||||
}
|
||||
if (state.loadCandidateVideo !== video) {
|
||||
state.loadCandidateVideo = video;
|
||||
state.loadCandidateAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - state.loadCandidateAt < 500) return;
|
||||
reportEpisodeSyncV2Local(state, 'loaded').then(sent => {
|
||||
if (sent) reportLog(`Episode Sync v2: loaded "${title}"`, 'success');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function startEpisodeSyncV2Lobby(transaction) {
|
||||
if (!transaction?.transactionId || !transaction.expectedTitle) return;
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
episodeSyncV2State.phase = 'lobby';
|
||||
episodeSyncV2State.deadlineAt = Number.isFinite(transaction.deadlineAt)
|
||||
? transaction.deadlineAt
|
||||
: episodeSyncV2State.deadlineAt;
|
||||
return;
|
||||
}
|
||||
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = {
|
||||
transactionId: transaction.transactionId,
|
||||
expectedTitle: transaction.expectedTitle,
|
||||
phase: 'lobby',
|
||||
generation: episodeSyncV2Generation,
|
||||
loadedReported: false,
|
||||
preparedReported: false,
|
||||
reportInFlight: false,
|
||||
reportRetryScheduled: false,
|
||||
deadlineAt: Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : null,
|
||||
loadCandidateVideo: null,
|
||||
loadCandidateAt: 0,
|
||||
video: null,
|
||||
wasPlayingBeforePrepare: false,
|
||||
pausedByTransaction: false,
|
||||
manualAction: false,
|
||||
programmaticPausePending: false,
|
||||
prepareStarted: false
|
||||
};
|
||||
stopEpisodeSyncV2Poll();
|
||||
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
episodeSyncV2PollTimer = setInterval(() => {
|
||||
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function waitForEpisodeSyncV2Stable(state, video, targetTime = 0, timeoutMs = 12000) {
|
||||
return new Promise(resolve => {
|
||||
const startedAt = Date.now();
|
||||
let stableSince = 0;
|
||||
const timer = setInterval(() => {
|
||||
const current = getSyncCurrentTime(video);
|
||||
const ready = isEpisodeSyncV2Current(state)
|
||||
&& state.phase === 'prepare'
|
||||
&& video === findVideo()
|
||||
&& video.isConnected !== false
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
|
||||
&& video.paused
|
||||
&& !video.seeking
|
||||
&& video.readyState >= 3
|
||||
&& current !== null
|
||||
&& Math.abs(current - targetTime) < 1;
|
||||
if (ready) {
|
||||
if (!stableSince) stableSince = Date.now();
|
||||
if (Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(true);
|
||||
}
|
||||
} else {
|
||||
stableSince = 0;
|
||||
}
|
||||
if (!isEpisodeSyncV2Current(state) || Date.now() - startedAt >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
seekPollTimers.add(timer);
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareEpisodeSyncV2(transaction) {
|
||||
if (!transaction?.transactionId || !transaction.expectedTitle) return;
|
||||
if (!episodeSyncV2State || episodeSyncV2State.transactionId !== transaction.transactionId) {
|
||||
startEpisodeSyncV2Lobby({ ...transaction, phase: 'lobby' });
|
||||
}
|
||||
const state = episodeSyncV2State;
|
||||
if (!state || state.transactionId !== transaction.transactionId) return;
|
||||
state.deadlineAt = Number.isFinite(transaction.deadlineAt) ? transaction.deadlineAt : state.deadlineAt;
|
||||
if (state.prepareStarted) return;
|
||||
stopEpisodeSyncV2Poll();
|
||||
state.phase = 'prepare';
|
||||
state.prepareStarted = true;
|
||||
const video = findVideo();
|
||||
const currentTitle = getMediaTitle();
|
||||
if (!video || !currentTitle || !sameEpisodeStrict(currentTitle, state.expectedTitle)) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'episode_mismatch')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'episode_mismatch');
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.video = video;
|
||||
state.wasPlayingBeforePrepare = !video.paused;
|
||||
if (!video.paused) {
|
||||
state.programmaticPausePending = true;
|
||||
const paused = await tryMediaAction(EVENTS.PAUSE);
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
state.pausedByTransaction = paused === true;
|
||||
if (!paused) {
|
||||
state.programmaticPausePending = false;
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'pause_failed')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'pause_failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const current = getSyncCurrentTime(video);
|
||||
if (current === null || Math.abs(current) >= 0.25) {
|
||||
const sought = await tryMediaAction(EVENTS.SEEK, { targetTime: 0 });
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
if (!sought) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'seek_failed')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'seek_failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const stable = await waitForEpisodeSyncV2Stable(state, video, 0);
|
||||
if (!isEpisodeSyncV2Current(state)) return;
|
||||
if (!stable) {
|
||||
if (!await reportEpisodeSyncV2Local(state, 'failed', 'stability_timeout')) {
|
||||
retryEpisodeSyncV2Report(state, 'failed', 'stability_timeout');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await reportEpisodeSyncV2Local(state, 'prepared')) {
|
||||
reportLog(`Episode Sync v2: prepared "${currentTitle}"`, 'success');
|
||||
} else {
|
||||
retryEpisodeSyncV2Report(state, 'prepared');
|
||||
}
|
||||
}
|
||||
|
||||
function failEpisodeSyncV2ForManualAction(action) {
|
||||
const state = episodeSyncV2State;
|
||||
if (!state || state.phase !== 'prepare') return;
|
||||
state.manualAction = true;
|
||||
reportEpisodeSyncV2Local(state, 'failed', `user_${action}`).catch(() => {});
|
||||
clearEpisodeSyncV2Content({ manualAction: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function getPlayerActionFixes() {
|
||||
return [
|
||||
{
|
||||
@@ -1702,7 +1966,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_SYNC_V2') {
|
||||
const transaction = message.transaction;
|
||||
if (!transaction?.transactionId || !transaction.phase) {
|
||||
sendResponse({ status: 'invalid_transaction' });
|
||||
return true;
|
||||
}
|
||||
if (transaction.phase === 'lobby') {
|
||||
reportLog(`Episode Sync v2 lobby: waiting for "${transaction.expectedTitle}"`, 'info');
|
||||
startEpisodeSyncV2Lobby(transaction);
|
||||
} else if (transaction.phase === 'prepare') {
|
||||
prepareEpisodeSyncV2(transaction).catch(error => {
|
||||
reportLog(`Episode Sync v2 prepare failed: ${error.message}`, 'warn');
|
||||
});
|
||||
} else if (transaction.phase === 'execute') {
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
const state = episodeSyncV2State;
|
||||
const video = state.video || findVideo();
|
||||
const canExecute = video
|
||||
&& video === findVideo()
|
||||
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle);
|
||||
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 if (transaction.phase === 'cancel') {
|
||||
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
|
||||
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Legacy lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
// Host Control Mode: a desynced guest is watching on their own and must
|
||||
// not join the lobby flow. Otherwise they'd pause on title match, report
|
||||
@@ -1962,6 +2263,19 @@
|
||||
return;
|
||||
}
|
||||
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
|
||||
if (episodeSyncV2State?.phase === 'prepare'
|
||||
&& action === EVENTS.PAUSE
|
||||
&& episodeSyncV2State.programmaticPausePending
|
||||
&& video === episodeSyncV2State.video
|
||||
&& video.paused) {
|
||||
episodeSyncV2State.programmaticPausePending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// An unsuppressed native action during PREPARE is a newer user intent.
|
||||
// Fail the automation before relaying that action; the relay broadcasts
|
||||
// CANCEL first and then applies this manual PLAY/PAUSE/SEEK normally.
|
||||
failEpisodeSyncV2ForManualAction(action);
|
||||
|
||||
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
||||
cancelCanonicalMediaApply(action, video);
|
||||
@@ -2037,6 +2351,7 @@
|
||||
closeAudioContext();
|
||||
if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; }
|
||||
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
|
||||
stopEpisodeSyncV2Poll();
|
||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
|
||||
// Drop any pending coalesced play/pause: we are tearing down and will
|
||||
@@ -2064,6 +2379,12 @@
|
||||
connectKeepAlivePort();
|
||||
schedulePeriodicHeartbeat();
|
||||
scheduleProactiveHeartbeat();
|
||||
if (episodeSyncV2State?.phase === 'lobby') {
|
||||
checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
episodeSyncV2PollTimer = setInterval(() => {
|
||||
if (episodeSyncV2State) checkEpisodeSyncV2Loaded(episodeSyncV2State);
|
||||
}, 500);
|
||||
}
|
||||
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -2445,6 +2766,9 @@
|
||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; }
|
||||
stopLobbyPoll();
|
||||
stopEpisodeSyncV2Poll();
|
||||
episodeSyncV2Generation++;
|
||||
episodeSyncV2State = null;
|
||||
hcmDeferredSnapPending = false;
|
||||
|
||||
observer.disconnect();
|
||||
@@ -2501,7 +2825,13 @@
|
||||
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
||||
runtimeMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
||||
if (destroyed || chrome.runtime.lastError) return;
|
||||
if (res && res.lobbyActive && res.expectedTitle) {
|
||||
if (res?.episodeSyncV2) {
|
||||
if (res.episodeSyncV2.phase === 'prepare') {
|
||||
prepareEpisodeSyncV2(res.episodeSyncV2).catch(() => {});
|
||||
} else {
|
||||
startEpisodeSyncV2Lobby(res.episodeSyncV2);
|
||||
}
|
||||
} else if (res && res.lobbyActive && res.expectedTitle) {
|
||||
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
|
||||
startLobbyPoll(res.expectedTitle);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('episode lobby completion races', () => {
|
||||
it('validates restored and authoritative lobby state before using readyPeers', () => {
|
||||
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
|
||||
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
|
||||
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
expect(backgroundSource).toContain('const authoritativeLobby = authoritativeEpisodeSyncV2 ? null : normalizeEpisodeLobby(');
|
||||
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
|
||||
});
|
||||
|
||||
@@ -74,9 +74,10 @@ describe('episode lobby completion races', () => {
|
||||
|
||||
it('counts only ready peers who still participate in lobby completion', () => {
|
||||
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
|
||||
expect(completion).toContain('episodeLobby.initiatorPeerId !== peerId');
|
||||
expect(completion).toContain('const participatingPeerIds = new Set(peers');
|
||||
expect(completion).toContain('participatingPeerIds.has(candidate)');
|
||||
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
|
||||
expect(completion).toContain('participatingPeerIds.size > 0 && readyParticipatingCount >= participatingPeerIds.size');
|
||||
});
|
||||
|
||||
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CAPABILITIES, EVENTS, EPISODE_SYNC_V2_STABILITY_MS } from '../shared/constants.js';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
||||
const offlineSource = fs.readFileSync(path.join(extensionDir, 'offline-media-intent.js'), 'utf8');
|
||||
const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8');
|
||||
|
||||
function between(source, startNeedle, endNeedle) {
|
||||
const start = source.indexOf(startNeedle);
|
||||
const end = source.indexOf(endNeedle, start + startNeedle.length);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
describe('Episode Sync v2 extension contract', () => {
|
||||
it('advertises the additive capability without changing the protocol', () => {
|
||||
expect(CAPABILITIES.EPISODE_SYNC_V2).toBe('episode-sync-v2');
|
||||
expect(EVENTS.EPISODE_SYNC_V2).toBe('episode_sync_v2');
|
||||
expect(backgroundSource).toContain('CAPABILITIES.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', () => {
|
||||
expect(offlineSource).toContain('EVENTS.EPISODE_SYNC_V2');
|
||||
const episodeChanged = between(
|
||||
backgroundSource,
|
||||
"message.type === 'EPISODE_CHANGED'",
|
||||
"message.type === 'EPISODE_READY_LOCAL'"
|
||||
);
|
||||
expect(episodeChanged).toContain('serverSupports(CAPABILITIES.EPISODE_SYNC_V2)');
|
||||
expect(episodeChanged).toContain("emitLive(EVENTS.EPISODE_SYNC_V2, { phase: 'start'");
|
||||
expect(episodeChanged).not.toContain('PAUSE_FOR_LOBBY');
|
||||
expect(episodeChanged).not.toContain('emit(EVENTS.EPISODE_LOBBY');
|
||||
});
|
||||
|
||||
it('correlates content reports to the current transaction, phase, target and episode', () => {
|
||||
const handler = between(
|
||||
backgroundSource,
|
||||
"message.type === 'EPISODE_SYNC_V2_LOCAL'",
|
||||
"message.type === 'TITLE_PRIVACY_CHANGED'"
|
||||
);
|
||||
expect(handler).toContain('message.transactionId !== transaction.transactionId');
|
||||
expect(handler).toContain('transaction.phase !== expectedLocalPhase');
|
||||
expect(handler).toContain("!isCurrentContentSender(sender)");
|
||||
expect(handler).toContain('!sameEpisodeStrict(localTitle, transaction.expectedTitle)');
|
||||
expect(handler).toContain('emitLive(EVENTS.EPISODE_SYNC_V2');
|
||||
});
|
||||
|
||||
it('requires the same player and episode to remain paused, seeked, buffered and stable', () => {
|
||||
const stable = between(
|
||||
contentSource,
|
||||
'function waitForEpisodeSyncV2Stable(',
|
||||
'async function prepareEpisodeSyncV2('
|
||||
);
|
||||
expect(stable).toContain('video === findVideo()');
|
||||
expect(stable).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
|
||||
expect(stable).toContain('video.paused');
|
||||
expect(stable).toContain('!video.seeking');
|
||||
expect(stable).toContain('video.readyState >= 3');
|
||||
expect(stable).toContain('Math.abs(current - targetTime) < 1');
|
||||
expect(stable).toContain('Date.now() - stableSince >= EPISODE_SYNC_V2_STABILITY_MS');
|
||||
expect(EPISODE_SYNC_V2_STABILITY_MS).toBe(1000);
|
||||
});
|
||||
|
||||
it('reports prepared only after awaited pause, seek and stable verification', () => {
|
||||
const prepare = between(
|
||||
contentSource,
|
||||
'async function prepareEpisodeSyncV2(',
|
||||
'function failEpisodeSyncV2ForManualAction('
|
||||
);
|
||||
const pause = prepare.indexOf('await tryMediaAction(EVENTS.PAUSE)');
|
||||
const seek = prepare.indexOf('await tryMediaAction(EVENTS.SEEK');
|
||||
const stable = prepare.indexOf('await waitForEpisodeSyncV2Stable');
|
||||
const prepared = prepare.indexOf("reportEpisodeSyncV2Local(state, 'prepared')");
|
||||
expect(pause).toBeGreaterThan(-1);
|
||||
expect(seek).toBeGreaterThan(pause);
|
||||
expect(stable).toBeGreaterThan(seek);
|
||||
expect(prepared).toBeGreaterThan(stable);
|
||||
});
|
||||
|
||||
it('restores playback only when this transaction paused a playing unchanged player', () => {
|
||||
const clear = between(
|
||||
contentSource,
|
||||
'async function clearEpisodeSyncV2Content(',
|
||||
'function checkEpisodeSyncV2Loaded('
|
||||
);
|
||||
expect(clear).toContain('state.pausedByTransaction');
|
||||
expect(clear).toContain('state.wasPlayingBeforePrepare');
|
||||
expect(clear).toContain('!state.manualAction');
|
||||
expect(clear).toContain('video === findVideo()');
|
||||
expect(clear).toContain('sameEpisodeStrict(getMediaTitle(), state.expectedTitle)');
|
||||
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
|
||||
});
|
||||
|
||||
it('injects the shared stability window into packaged content scripts', () => {
|
||||
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
|
||||
expect(buildSource).toContain('episodeSyncStabilityVal');
|
||||
});
|
||||
});
|
||||
@@ -22,3 +22,25 @@ export function sameEpisode(titleA, titleB) {
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
|
||||
function episodeContext(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.replace(/S(?:eason\s*)?\d+[^a-zA-Z0-9]*E(?:pisode\s*)?\d+/ig, ' ')
|
||||
.replace(/(?:Episode|Folge|Ep\.?)\s*\d+|#\s*\d+/ig, ' ')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Transactional episode sync is deliberately stricter than ordinary playback
|
||||
// filtering: if both peers expose contextual text (episode/show name), require
|
||||
// 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.
|
||||
export function sameEpisodeStrict(titleA, titleB) {
|
||||
if (!sameEpisode(titleA, titleB)) return false;
|
||||
const contextA = episodeContext(titleA);
|
||||
const contextB = episodeContext(titleB);
|
||||
return !contextA || !contextB || contextA === contextB;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
|
||||
import { extractEpisodeId, sameEpisode, sameEpisodeStrict } from './episode-utils.js';
|
||||
|
||||
describe('episode title matching', () => {
|
||||
it.each([
|
||||
@@ -53,4 +53,10 @@ describe('episode title matching', () => {
|
||||
])('rejects different titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps transactional matching strict when both titles expose context', () => {
|
||||
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', 'S01E06 - Visiting Ours')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,10 @@ describe('offline media intent background integration', () => {
|
||||
expect(flushIndex).toBeGreaterThan(-1);
|
||||
expect(policyIndex).toBeLessThan(canonicalIndex);
|
||||
expect(canonicalIndex).toBeLessThan(flushIndex);
|
||||
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
||||
expect(roomData).toContain('activeLobby: !!(episodeLobby || episodeSyncV2)');
|
||||
expect(roomData).toContain('desynced: hcmDesynced');
|
||||
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
||||
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
|
||||
expect(roomData).toContain('authoritativeLobby: !!(authoritativeLobby || authoritativeEpisodeSyncV2)');
|
||||
});
|
||||
|
||||
it('clears queued room intent on failed join, leave and room switch paths', () => {
|
||||
|
||||
@@ -5,7 +5,13 @@ export const MAX_LOGICAL_QUEUE_SIZE = 50;
|
||||
|
||||
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
|
||||
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
|
||||
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
|
||||
const STALE_OFFLINE_EVENTS = new Set([
|
||||
EVENTS.PING,
|
||||
EVENTS.PONG,
|
||||
EVENTS.PEER_STATUS,
|
||||
EVENTS.EVENT_ACK,
|
||||
EVENTS.EPISODE_SYNC_V2
|
||||
]);
|
||||
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
|
||||
const HOST_GATED_EVENTS = new Set([
|
||||
...MEDIA_EVENTS,
|
||||
|
||||
@@ -108,6 +108,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
|
||||
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
|
||||
const maxMediaTimeMatch = constantsContent.match(/export const MAX_MEDIA_TIME\s*=\s*(\d+);/);
|
||||
const episodeSyncStabilityMatch = constantsContent.match(/export const EPISODE_SYNC_V2_STABILITY_MS\s*=\s*(\d+);/);
|
||||
|
||||
if (!eventsMatch) {
|
||||
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
|
||||
@@ -118,10 +119,14 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
if (!maxMediaTimeMatch) {
|
||||
throw new Error('CRITICAL: Could not find MAX_MEDIA_TIME in shared/constants.js');
|
||||
}
|
||||
if (!episodeSyncStabilityMatch) {
|
||||
throw new Error('CRITICAL: Could not find EPISODE_SYNC_V2_STABILITY_MS in shared/constants.js');
|
||||
}
|
||||
|
||||
const eventsObject = eventsMatch[1];
|
||||
const heartbeatVal = heartbeatMatch[1];
|
||||
const maxMediaTimeVal = maxMediaTimeMatch[1];
|
||||
const episodeSyncStabilityVal = episodeSyncStabilityMatch[1];
|
||||
|
||||
const items = fs.readdirSync(extDir);
|
||||
for (const item of items) {
|
||||
@@ -141,7 +146,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n ${eEnd}`;
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n const EPISODE_SYNC_V2_STABILITY_MS = ${episodeSyncStabilityVal};\n ${eEnd}`;
|
||||
|
||||
content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection');
|
||||
|
||||
|
||||
+219
-2
@@ -194,11 +194,225 @@ try {
|
||||
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
|
||||
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
|
||||
assert.ok(capData.capabilities.includes('media-state-v1'), 'ROOM_DATA advertises canonical media state v1');
|
||||
assert.ok(capData.capabilities.includes('episode-sync-v2'), 'ROOM_DATA advertises Episode Sync v2');
|
||||
assert.equal(capData.mediaState, null, 'a new room starts without invented canonical media state');
|
||||
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Episode Sync v2: relay-owned transaction, exact participants, no timeout execute ---
|
||||
const episodeCaps = ['chat-v1', 'media-state-v1', 'episode-sync-v2'];
|
||||
const episodeRid = 'episode-v2-'+Date.now();
|
||||
const episodeA = await c(), episodeB = await c();
|
||||
await j(episodeA, episodeRid, 'episode-a', null, episodeCaps);
|
||||
await j(episodeB, episodeRid, 'episode-b', null, episodeCaps);
|
||||
episodeA._m.length = episodeB._m.length = 0;
|
||||
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E06 - Visiting Ours' });
|
||||
const episodeLobbyA = await w(episodeA, 'episode_sync_v2');
|
||||
const episodeLobbyB = await w(episodeB, 'episode_sync_v2');
|
||||
assert.equal(episodeLobbyA.phase, 'lobby');
|
||||
assert.equal(episodeLobbyA.transactionId, episodeLobbyB.transactionId);
|
||||
assert.deepEqual(episodeLobbyA.participants, ['episode-a', 'episode-b']);
|
||||
assert.deepEqual(episodeLobbyA.loadedPeers, [], 'initiator is not pre-marked loaded');
|
||||
const episodeTxId = episodeLobbyA.transactionId;
|
||||
|
||||
s(episodeB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S99E99' });
|
||||
const competingEpisodeStart = await w(episodeB, 'episode_sync_v2');
|
||||
assert.equal(competingEpisodeStart.transactionId, episodeTxId);
|
||||
assert.equal(competingEpisodeStart.expectedTitle, 'S01E06 - Visiting Ours');
|
||||
let competingEpisodeBroadcast = false;
|
||||
try { await w(episodeA, 'episode_sync_v2', 300); competingEpisodeBroadcast = true; } catch { /* expected */ }
|
||||
assert.equal(competingEpisodeBroadcast, false, 'competing start only receives authoritative correction');
|
||||
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'loaded', transactionId: 'stale-transaction' });
|
||||
await delay(100);
|
||||
assert.deepEqual(mod.rooms.get(episodeRid).episodeSyncV2.loadedPeers, [], 'stale transaction frame is ignored');
|
||||
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'loaded', transactionId: episodeTxId });
|
||||
const loadedA = await w(episodeA, 'episode_sync_v2');
|
||||
await w(episodeB, 'episode_sync_v2');
|
||||
assert.equal(loadedA.phase, 'lobby');
|
||||
assert.deepEqual(loadedA.loadedPeers, ['episode-a']);
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'loaded', transactionId: episodeTxId });
|
||||
let duplicateLoadedRelayed = false;
|
||||
try { await w(episodeB, 'episode_sync_v2', 300); duplicateLoadedRelayed = true; } catch { /* expected */ }
|
||||
assert.equal(duplicateLoadedRelayed, false, 'duplicate loaded is idempotent');
|
||||
|
||||
s(episodeB, 'episode_sync_v2', { phase: 'loaded', transactionId: episodeTxId });
|
||||
const prepareA = await w(episodeA, 'episode_sync_v2');
|
||||
const prepareB = await w(episodeB, 'episode_sync_v2');
|
||||
assert.equal(prepareA.phase, 'prepare');
|
||||
assert.equal(prepareB.phase, 'prepare');
|
||||
assert.deepEqual(prepareA.loadedPeers, ['episode-a', 'episode-b']);
|
||||
assert.deepEqual(prepareA.preparedPeers, []);
|
||||
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'prepared', transactionId: episodeTxId });
|
||||
const onePrepared = await w(episodeB, 'episode_sync_v2');
|
||||
await w(episodeA, 'episode_sync_v2');
|
||||
assert.equal(onePrepared.phase, 'prepare');
|
||||
assert.deepEqual(onePrepared.preparedPeers, ['episode-a']);
|
||||
s(episodeA, 'episode_sync_v2', { phase: 'prepared', transactionId: episodeTxId });
|
||||
let duplicatePreparedRelayed = false;
|
||||
try { await w(episodeB, 'episode_sync_v2', 300); duplicatePreparedRelayed = true; } catch { /* expected */ }
|
||||
assert.equal(duplicatePreparedRelayed, false, 'duplicate prepared is idempotent');
|
||||
|
||||
s(episodeB, 'episode_sync_v2', { phase: 'prepared', transactionId: episodeTxId });
|
||||
const executeA = await w(episodeA, 'episode_sync_v2');
|
||||
const executeB = await w(episodeB, 'episode_sync_v2');
|
||||
assert.equal(executeA.phase, 'execute');
|
||||
assert.equal(executeB.phase, 'execute');
|
||||
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);
|
||||
let duplicateExecute = false;
|
||||
try { await w(episodeB, 'episode_sync_v2', 300); duplicateExecute = true; } catch { /* expected */ }
|
||||
assert.equal(duplicateExecute, false, 'execute is emitted exactly once');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// Mixed rooms degrade safely: no v2 transaction and no unknown frame to legacy.
|
||||
const mixedEpisodeRid = 'episode-mixed-'+Date.now();
|
||||
const mixedEpisodeNew = await c(), mixedEpisodeOld = await c();
|
||||
await j(mixedEpisodeNew, mixedEpisodeRid, 'episode-new', null, episodeCaps);
|
||||
await j(mixedEpisodeOld, mixedEpisodeRid, 'episode-old');
|
||||
mixedEpisodeNew._m.length = mixedEpisodeOld._m.length = 0;
|
||||
s(mixedEpisodeNew, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E07' });
|
||||
const mixedEpisodeCancel = await w(mixedEpisodeNew, 'episode_sync_v2');
|
||||
assert.equal(mixedEpisodeCancel.phase, 'cancel');
|
||||
assert.equal(mixedEpisodeCancel.reason, 'capability_mismatch');
|
||||
assert.equal(mod.rooms.get(mixedEpisodeRid).episodeSyncV2, null);
|
||||
let legacySawV2 = false;
|
||||
try { await w(mixedEpisodeOld, 'episode_sync_v2', 300); legacySawV2 = true; } catch { /* expected */ }
|
||||
assert.equal(legacySawV2, false, 'legacy peer receives no v2 frame');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// Failure/manual intent cancel the barrier; host-only authorization is
|
||||
// evaluated at START and never delegated to clients.
|
||||
const episodeAbortRid = 'episode-abort-'+Date.now();
|
||||
const episodeAbortA = await c(), episodeAbortB = await c();
|
||||
await j(episodeAbortA, episodeAbortRid, 'abort-a', null, episodeCaps);
|
||||
await j(episodeAbortB, episodeAbortRid, 'abort-b', null, episodeCaps);
|
||||
episodeAbortA._m.length = episodeAbortB._m.length = 0;
|
||||
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E01' });
|
||||
const failedLobby = await w(episodeAbortA, 'episode_sync_v2');
|
||||
await w(episodeAbortB, 'episode_sync_v2');
|
||||
s(episodeAbortB, 'episode_sync_v2', {
|
||||
phase: 'failed',
|
||||
transactionId: failedLobby.transactionId,
|
||||
reason: 'pause_failed'
|
||||
});
|
||||
const failedCancelA = await w(episodeAbortA, 'episode_sync_v2');
|
||||
const failedCancelB = await w(episodeAbortB, 'episode_sync_v2');
|
||||
assert.equal(failedCancelA.reason, 'peer_failed');
|
||||
assert.equal(failedCancelB.failedPeerId, 'abort-b');
|
||||
|
||||
s(episodeAbortA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E02' });
|
||||
await w(episodeAbortA, 'episode_sync_v2'); await w(episodeAbortB, 'episode_sync_v2');
|
||||
s(episodeAbortA, 'play', { currentTime: 2, seq: 1 });
|
||||
const manualCancelA = await w(episodeAbortA, 'episode_sync_v2');
|
||||
const manualCancelB = await w(episodeAbortB, 'episode_sync_v2');
|
||||
const manualPlayB = await w(episodeAbortB, 'play');
|
||||
assert.equal(manualCancelA.reason, 'superseded');
|
||||
assert.equal(manualCancelB.reason, 'superseded');
|
||||
assert.equal(manualPlayB.currentTime, 2);
|
||||
assert.equal(mod.rooms.get(episodeAbortRid).episodeSyncV2, null);
|
||||
|
||||
const abortRoom = mod.rooms.get(episodeAbortRid);
|
||||
abortRoom.controlMode = 'host-only';
|
||||
abortRoom.controllers = new Set(['abort-a']);
|
||||
s(episodeAbortB, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S02E03' });
|
||||
const guestStartCancel = await w(episodeAbortB, 'episode_sync_v2');
|
||||
assert.equal(guestStartCancel.reason, 'not_controller');
|
||||
assert.equal(abortRoom.episodeSyncV2, null);
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// Frozen participants: a late legacy joiner is excluded and does not block completion.
|
||||
const frozenRid = 'episode-frozen-'+Date.now();
|
||||
const frozenA = await c(), frozenB = await c();
|
||||
await j(frozenA, frozenRid, 'frozen-a', null, episodeCaps);
|
||||
await j(frozenB, frozenRid, 'frozen-b', null, episodeCaps);
|
||||
frozenA._m.length = frozenB._m.length = 0;
|
||||
s(frozenA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E08' });
|
||||
const frozenLobbyA = await w(frozenA, 'episode_sync_v2');
|
||||
await w(frozenB, 'episode_sync_v2');
|
||||
const frozenLate = await c();
|
||||
const frozenLateRoom = await j(frozenLate, frozenRid, 'frozen-late');
|
||||
assert.equal(frozenLateRoom.episodeSyncV2, null);
|
||||
assert.deepEqual(frozenLobbyA.participants, ['frozen-a', 'frozen-b']);
|
||||
frozenLate._m.length = 0;
|
||||
for (const peer of [frozenA, frozenB]) {
|
||||
s(peer, 'episode_sync_v2', { phase: 'loaded', transactionId: frozenLobbyA.transactionId });
|
||||
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
|
||||
}
|
||||
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
|
||||
for (const peer of [frozenA, frozenB]) {
|
||||
s(peer, 'episode_sync_v2', { phase: 'prepared', transactionId: frozenLobbyA.transactionId });
|
||||
if (peer === frozenA) { await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2'); }
|
||||
}
|
||||
await w(frozenA, 'episode_sync_v2'); await w(frozenB, 'episode_sync_v2');
|
||||
let lateReceivedV2 = false;
|
||||
try { await w(frozenLate, 'episode_sync_v2', 300); lateReceivedV2 = true; } catch { /* expected */ }
|
||||
assert.equal(lateReceivedV2, false);
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// Departure and deadline are cancellation-only; neither can emit execute.
|
||||
const cancelRid = 'episode-cancel-'+Date.now();
|
||||
const cancelA = await c(), cancelB = await c();
|
||||
await j(cancelA, cancelRid, 'cancel-a', null, episodeCaps);
|
||||
await j(cancelB, cancelRid, 'cancel-b', null, episodeCaps);
|
||||
cancelA._m.length = cancelB._m.length = 0;
|
||||
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E09' });
|
||||
const cancelLobby = await w(cancelA, 'episode_sync_v2');
|
||||
await w(cancelB, 'episode_sync_v2');
|
||||
mod.rooms.get(cancelRid).episodeSyncV2.deadlineAt = 1;
|
||||
mod.expireEpisodeSyncV2Transactions(Date.now());
|
||||
const timeoutCancelA = await w(cancelA, 'episode_sync_v2');
|
||||
const timeoutCancelB = await w(cancelB, 'episode_sync_v2');
|
||||
assert.equal(timeoutCancelA.phase, 'cancel');
|
||||
assert.equal(timeoutCancelA.reason, 'load_timeout');
|
||||
assert.equal(timeoutCancelB.phase, 'cancel');
|
||||
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
|
||||
|
||||
s(cancelA, 'episode_sync_v2', { phase: 'start', expectedTitle: 'S01E10' });
|
||||
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');
|
||||
s(cancelB, 'leave_room', {});
|
||||
const departureCancel = await w(cancelA, 'episode_sync_v2');
|
||||
assert.equal(departureCancel.phase, 'cancel');
|
||||
assert.equal(departureCancel.reason, 'participant_left');
|
||||
assert.equal(departureCancel.failedPeerId, 'cancel-b');
|
||||
assert.equal(mod.rooms.get(cancelRid).episodeSyncV2, null);
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// New relay hardening for old extensions: only the accepted lobby owner may
|
||||
// relay legacy PREPARE/EXECUTE/CANCEL.
|
||||
const legacyEpisodeRid = 'episode-legacy-owner-'+Date.now();
|
||||
const legacyEpisodeA = await c(), legacyEpisodeB = await c();
|
||||
await j(legacyEpisodeA, legacyEpisodeRid, 'legacy-a');
|
||||
await j(legacyEpisodeB, legacyEpisodeRid, 'legacy-b');
|
||||
legacyEpisodeA._m.length = legacyEpisodeB._m.length = 0;
|
||||
s(legacyEpisodeA, 'episode_lobby', { expectedTitle: 'S01E11' });
|
||||
await w(legacyEpisodeB, 'episode_lobby');
|
||||
s(legacyEpisodeB, 'force_sync_prepare', { targetTime: 0 });
|
||||
let nonOwnerPrepareRelayed = false;
|
||||
try { await w(legacyEpisodeA, 'force_sync_prepare', 300); nonOwnerPrepareRelayed = true; } catch { /* expected */ }
|
||||
assert.equal(nonOwnerPrepareRelayed, false);
|
||||
s(legacyEpisodeA, 'force_sync_prepare', { targetTime: 0 });
|
||||
await w(legacyEpisodeB, 'force_sync_prepare');
|
||||
s(legacyEpisodeB, 'force_sync_execute', {});
|
||||
let nonOwnerExecuteRelayed = false;
|
||||
try { await w(legacyEpisodeA, 'force_sync_execute', 300); nonOwnerExecuteRelayed = true; } catch { /* expected */ }
|
||||
assert.equal(nonOwnerExecuteRelayed, false);
|
||||
s(legacyEpisodeA, 'force_sync_execute', {});
|
||||
await w(legacyEpisodeB, 'force_sync_execute');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Mixed-version rollout: pre-media-state extension + current extension ---
|
||||
// Legacy intentionally omits clientCapabilities entirely and uses only the
|
||||
// pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current
|
||||
@@ -1024,9 +1238,12 @@ try {
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
||||
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
|
||||
s(mxn,'pause',{currentTime:2}); await w(mxo,'episode_lobby_cancel'); await w(mxo,'pause');
|
||||
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
|
||||
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
|
||||
s(mxn,'episode_lobby_cancel',{});
|
||||
let staleMixedCancelDropped = false;
|
||||
try { await w(mxo,'episode_lobby_cancel',300); } catch { staleMixedCancelDropped = true; }
|
||||
assert.ok(staleMixedCancelDropped, 'stale legacy lobby cancel is dropped after manual playback supersedes it');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
|
||||
+313
-5
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME, EPISODE_LOBBY_TIMEOUT, EPISODE_SYNC_V2_PREPARE_TIMEOUT } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
commitForceSyncMediaState,
|
||||
@@ -173,7 +173,8 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
EVENTS.EPISODE_LOBBY_CANCEL,
|
||||
EVENTS.EPISODE_SYNC_V2
|
||||
]);
|
||||
|
||||
// Current clients sequence room-moving media commands. The relay mirrors the
|
||||
@@ -188,6 +189,15 @@ const SEQUENCED_ROOM_EVENTS = new Set([
|
||||
EVENTS.FORCE_SYNC_EXECUTE
|
||||
]);
|
||||
|
||||
const EPISODE_SYNC_V2_SUPERSEDING_EVENTS = new Set([
|
||||
EVENTS.PLAY,
|
||||
EVENTS.PAUSE,
|
||||
EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY
|
||||
]);
|
||||
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
@@ -196,7 +206,8 @@ const SERVER_CAPABILITIES = [
|
||||
CAPABILITIES.CO_HOST,
|
||||
CAPABILITIES.CHAT,
|
||||
CAPABILITIES.CHAT_V1,
|
||||
CAPABILITIES.MEDIA_STATE_V1
|
||||
CAPABILITIES.MEDIA_STATE_V1,
|
||||
CAPABILITIES.EPISODE_SYNC_V2
|
||||
];
|
||||
|
||||
function normalizeClientCapabilities(value) {
|
||||
@@ -205,7 +216,8 @@ function normalizeClientCapabilities(value) {
|
||||
.filter(capability => typeof capability === 'string')
|
||||
.map(capability => capability.substring(0, 32))
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1
|
||||
|| capability === CAPABILITIES.MEDIA_STATE_V1)
|
||||
|| capability === CAPABILITIES.MEDIA_STATE_V1
|
||||
|| capability === CAPABILITIES.EPISODE_SYNC_V2)
|
||||
)];
|
||||
}
|
||||
|
||||
@@ -219,6 +231,86 @@ function clientSupportsMediaState(socket) {
|
||||
&& socket.data.clientCapabilities.includes(CAPABILITIES.MEDIA_STATE_V1);
|
||||
}
|
||||
|
||||
function clientSupportsEpisodeSyncV2(socket) {
|
||||
return Array.isArray(socket?.data?.clientCapabilities)
|
||||
&& socket.data.clientCapabilities.includes(CAPABILITIES.EPISODE_SYNC_V2);
|
||||
}
|
||||
|
||||
function publicEpisodeSyncV2(transaction, phase = null) {
|
||||
if (!transaction) return null;
|
||||
const publicPhase = phase || (transaction.phase === 'loading' ? 'lobby' : 'prepare');
|
||||
return {
|
||||
transactionId: transaction.transactionId,
|
||||
phase: publicPhase,
|
||||
expectedTitle: transaction.expectedTitle,
|
||||
initiatorPeerId: transaction.initiatorPeerId,
|
||||
participants: [...transaction.participants],
|
||||
loadedPeers: [...transaction.loadedPeers],
|
||||
preparedPeers: [...transaction.preparedPeers],
|
||||
createdAt: transaction.createdAt,
|
||||
deadlineAt: transaction.deadlineAt,
|
||||
revision: transaction.revision
|
||||
};
|
||||
}
|
||||
|
||||
function emitEpisodeSyncV2ToParticipants(roomId, room, transaction, payload) {
|
||||
const participantIds = new Set(transaction.participants);
|
||||
for (const socketId of room.peers) {
|
||||
const participant = room.peerData.get(socketId);
|
||||
const participantSocket = io.sockets.sockets.get(socketId);
|
||||
if (!participantIds.has(participant?.peerId) || !clientSupportsEpisodeSyncV2(participantSocket)) continue;
|
||||
participantSocket.emit(EVENTS.EPISODE_SYNC_V2, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEpisodeSyncV2Timer(transaction) {
|
||||
if (!transaction?.timeout) return;
|
||||
clearTimeout(transaction.timeout);
|
||||
transaction.timeout = null;
|
||||
}
|
||||
|
||||
function cancelEpisodeSyncV2(roomId, room, reason, failedPeerId = null) {
|
||||
const transaction = room?.episodeSyncV2;
|
||||
if (!transaction) return false;
|
||||
clearEpisodeSyncV2Timer(transaction);
|
||||
room.episodeSyncV2 = null;
|
||||
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
||||
emitEpisodeSyncV2ToParticipants(roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'cancel'),
|
||||
senderId: transaction.initiatorPeerId,
|
||||
reason: typeof reason === 'string' ? reason.substring(0, 32) : 'cancelled',
|
||||
failedPeerId: typeof failedPeerId === 'string' ? failedPeerId.substring(0, 16) : undefined
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleEpisodeSyncV2Deadline(roomId, room, transaction, timeoutMs) {
|
||||
clearEpisodeSyncV2Timer(transaction);
|
||||
transaction.deadlineAt = Date.now() + timeoutMs;
|
||||
transaction.timeout = setTimeout(() => {
|
||||
if (room.episodeSyncV2 !== transaction) return;
|
||||
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout');
|
||||
}, timeoutMs);
|
||||
transaction.timeout.unref?.();
|
||||
}
|
||||
|
||||
function roomFullySupportsEpisodeSyncV2(room) {
|
||||
if (!room || room.peers.size === 0) return false;
|
||||
for (const socketId of room.peers) {
|
||||
if (!clientSupportsEpisodeSyncV2(io.sockets.sockets.get(socketId))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function expireEpisodeSyncV2Transactions(now = Date.now()) {
|
||||
for (const [roomId, room] of rooms) {
|
||||
const transaction = room.episodeSyncV2;
|
||||
if (transaction && Number.isFinite(transaction.deadlineAt) && transaction.deadlineAt <= now) {
|
||||
cancelEpisodeSyncV2(roomId, room, transaction.phase === 'loading' ? 'load_timeout' : 'prepare_timeout');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
// from generating one broadcast per toggle across all peers.
|
||||
@@ -262,6 +354,13 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
|
||||
|
||||
const { peerId } = peerData;
|
||||
|
||||
// V2 freezes its participant set. Losing any participant invalidates the
|
||||
// barrier; continuing with a smaller set could execute before a reconnecting
|
||||
// player has actually prepared.
|
||||
if (room.episodeSyncV2?.participants.includes(peerId) && !peerJoinLocks.has(peerId)) {
|
||||
cancelEpisodeSyncV2(roomId, room, 'participant_left', peerId);
|
||||
}
|
||||
|
||||
// 1. Remove from room data structures
|
||||
room.peers.delete(socketId);
|
||||
room.peerIds.delete(socketId);
|
||||
@@ -286,8 +385,10 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
|
||||
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||
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
|
||||
// socket), fall back to 'everyone' so the room never gets stuck locked, and
|
||||
@@ -338,6 +439,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
clearEpisodeSyncV2Timer(room.episodeSyncV2);
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
@@ -493,7 +595,11 @@ io.on('connection', (socket) => {
|
||||
forceSyncTarget: null,
|
||||
// Distinguishes an unknown target after relay restart from a
|
||||
// transaction explicitly replaced by newer room playback.
|
||||
forceSyncSuperseded: false
|
||||
forceSyncSuperseded: false,
|
||||
activeLobby: null,
|
||||
legacyEpisodeSyncOwner: null,
|
||||
episodeSyncV2: null,
|
||||
lastEpisodeSyncV2Id: null
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
@@ -587,6 +693,10 @@ io.on('connection', (socket) => {
|
||||
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : [],
|
||||
mediaState: snapshotMediaState(room.mediaState, snapshotAt),
|
||||
episodeSyncV2: room.episodeSyncV2?.participants.includes(peerId)
|
||||
&& clientSupportsEpisodeSyncV2(socket)
|
||||
? publicEpisodeSyncV2(room.episodeSyncV2)
|
||||
: null,
|
||||
capabilities: SERVER_CAPABILITIES
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
@@ -714,6 +824,13 @@ io.on('connection', (socket) => {
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
|
||||
if (room.episodeSyncV2
|
||||
&& eventName === EVENTS.PEER_STATUS
|
||||
&& relayPayload.desynced === true
|
||||
&& room.episodeSyncV2.participants.includes(mapping.peerId)) {
|
||||
cancelEpisodeSyncV2(mapping.roomId, room, 'participant_desynced', mapping.peerId);
|
||||
}
|
||||
|
||||
// The first live lobby owns the room until completion or
|
||||
// cancellation. Drop concurrent lobby starts and stale ready
|
||||
// frames instead of letting clients build divergent lobbies.
|
||||
@@ -743,6 +860,29 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (eventName === EVENTS.EPISODE_LOBBY_CANCEL && !room.activeLobby) {
|
||||
log('ROOM', `Dropped stale episode lobby cancel from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A user/manual legacy command wins over automation. Cancel
|
||||
// the v2 barrier first so prepared peers can restore safely,
|
||||
// then relay the newer command normally.
|
||||
if (room.episodeSyncV2 && EPISODE_SYNC_V2_SUPERSEDING_EVENTS.has(eventName)) {
|
||||
cancelEpisodeSyncV2(mapping.roomId, room, 'superseded', mapping.peerId);
|
||||
}
|
||||
|
||||
// Legacy clients all used to self-promote after lobby ready.
|
||||
// Preserve their wire contract, but bind the room-wide PREPARE,
|
||||
// EXECUTE and CANCEL to the accepted lobby owner.
|
||||
if (room.legacyEpisodeSyncOwner
|
||||
&& (eventName === EVENTS.FORCE_SYNC_PREPARE
|
||||
|| eventName === EVENTS.FORCE_SYNC_EXECUTE
|
||||
|| 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();
|
||||
|
||||
@@ -771,6 +911,16 @@ io.on('connection', (socket) => {
|
||||
room.forceSyncSuperseded = true;
|
||||
room.forceSyncInitiator = 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) {
|
||||
// A malformed PREPARE must neither pause peers nor grant
|
||||
@@ -821,6 +971,7 @@ io.on('connection', (socket) => {
|
||||
room.forceSyncInitiator = null;
|
||||
room.forceSyncTarget = null;
|
||||
room.forceSyncSuperseded = false;
|
||||
room.legacyEpisodeSyncOwner = null;
|
||||
}
|
||||
|
||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||
@@ -832,12 +983,14 @@ io.on('connection', (socket) => {
|
||||
initiatorPeerId: mapping.peerId,
|
||||
readyPeers: [mapping.peerId]
|
||||
};
|
||||
room.legacyEpisodeSyncOwner = mapping.peerId;
|
||||
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
||||
if (!room.activeLobby.readyPeers.includes(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) {
|
||||
room.activeLobby = null;
|
||||
if (eventName === EVENTS.EPISODE_LOBBY_CANCEL) room.legacyEpisodeSyncOwner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -847,6 +1000,159 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EPISODE_SYNC_V2, (data) => {
|
||||
try {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
const room = mapping ? rooms.get(mapping.roomId) : null;
|
||||
if (!mapping || !room || !clientSupportsEpisodeSyncV2(socket)) return;
|
||||
|
||||
const phase = typeof data.phase === 'string' ? data.phase.substring(0, 16) : '';
|
||||
const transactionId = typeof data.transactionId === 'string'
|
||||
? data.transactionId.substring(0, 64)
|
||||
: '';
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
if (phase === 'start') {
|
||||
const expectedTitle = typeof data.expectedTitle === 'string'
|
||||
? data.expectedTitle.substring(0, 100)
|
||||
: '';
|
||||
const isController = room.controlMode !== CONTROL_MODES.HOST_ONLY
|
||||
|| (room.controllers && room.controllers.has(mapping.peerId));
|
||||
const senderData = room.peerData.get(socket.id);
|
||||
const rejectStart = (reason) => socket.emit(EVENTS.EPISODE_SYNC_V2, {
|
||||
phase: 'cancel',
|
||||
transactionId: null,
|
||||
senderId: mapping.peerId,
|
||||
expectedTitle,
|
||||
reason
|
||||
});
|
||||
if (!expectedTitle) return rejectStart('invalid_title');
|
||||
if (!isController) return rejectStart('not_controller');
|
||||
if (senderData?.desynced) return rejectStart('desynced');
|
||||
if (!roomFullySupportsEpisodeSyncV2(room)) return rejectStart('capability_mismatch');
|
||||
if (room.activeLobby || room.forceSyncTarget) return rejectStart('legacy_sync_active');
|
||||
if (room.episodeSyncV2) {
|
||||
if (room.episodeSyncV2.participants.includes(mapping.peerId)) {
|
||||
socket.emit(EVENTS.EPISODE_SYNC_V2, {
|
||||
...publicEpisodeSyncV2(room.episodeSyncV2),
|
||||
senderId: room.episodeSyncV2.initiatorPeerId
|
||||
});
|
||||
} else {
|
||||
rejectStart('transaction_busy');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const participants = [...new Set([...room.peerData.values()]
|
||||
.filter(candidate => candidate && !candidate.desynced)
|
||||
.map(candidate => candidate.peerId)
|
||||
.filter(Boolean))];
|
||||
if (participants.length < 2 || !participants.includes(mapping.peerId)) {
|
||||
return rejectStart('not_enough_participants');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const transaction = {
|
||||
transactionId: crypto.randomUUID(),
|
||||
phase: 'loading',
|
||||
expectedTitle,
|
||||
initiatorPeerId: mapping.peerId,
|
||||
participants,
|
||||
loadedPeers: [],
|
||||
preparedPeers: [],
|
||||
createdAt: now,
|
||||
deadlineAt: now + EPISODE_LOBBY_TIMEOUT,
|
||||
revision: 1,
|
||||
timeout: null
|
||||
};
|
||||
room.episodeSyncV2 = transaction;
|
||||
scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_LOBBY_TIMEOUT);
|
||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'lobby'),
|
||||
senderId: transaction.initiatorPeerId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = room.episodeSyncV2;
|
||||
if (!transaction
|
||||
|| !transactionId
|
||||
|| transaction.transactionId !== transactionId
|
||||
|| !transaction.participants.includes(mapping.peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'cancel' || phase === 'failed') {
|
||||
cancelEpisodeSyncV2(mapping.roomId, room, phase === 'failed' ? 'peer_failed' : 'peer_cancelled', mapping.peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'loaded' && transaction.phase === 'loading') {
|
||||
if (transaction.loadedPeers.includes(mapping.peerId)) return;
|
||||
transaction.loadedPeers.push(mapping.peerId);
|
||||
transaction.revision++;
|
||||
if (transaction.loadedPeers.length < transaction.participants.length) {
|
||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'lobby'),
|
||||
senderId: transaction.initiatorPeerId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
transaction.phase = 'preparing';
|
||||
transaction.revision++;
|
||||
scheduleEpisodeSyncV2Deadline(mapping.roomId, room, transaction, EPISODE_SYNC_V2_PREPARE_TIMEOUT);
|
||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'prepare'),
|
||||
senderId: transaction.initiatorPeerId,
|
||||
targetTime: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'prepared' && transaction.phase === 'preparing') {
|
||||
if (transaction.preparedPeers.includes(mapping.peerId)) return;
|
||||
transaction.preparedPeers.push(mapping.peerId);
|
||||
transaction.revision++;
|
||||
if (transaction.preparedPeers.length < transaction.participants.length) {
|
||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'prepare'),
|
||||
senderId: transaction.initiatorPeerId,
|
||||
targetTime: 0
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearEpisodeSyncV2Timer(transaction);
|
||||
room.episodeSyncV2 = null;
|
||||
room.lastEpisodeSyncV2Id = transaction.transactionId;
|
||||
room.forceSyncInitiator = null;
|
||||
room.forceSyncTarget = null;
|
||||
room.forceSyncSuperseded = false;
|
||||
commitForceSyncMediaState(
|
||||
room,
|
||||
0,
|
||||
transaction.initiatorPeerId,
|
||||
Date.now(),
|
||||
transaction.expectedTitle
|
||||
);
|
||||
emitEpisodeSyncV2ToParticipants(mapping.roomId, room, transaction, {
|
||||
...publicEpisodeSyncV2(transaction, 'execute'),
|
||||
senderId: transaction.initiatorPeerId,
|
||||
targetTime: 0
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `EPISODE_SYNC_V2 handler error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.GET_ROOMS, () => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
|
||||
@@ -1101,6 +1407,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
export function cleanupInactiveRooms(now = Date.now()) {
|
||||
expireEpisodeSyncV2Transactions(now);
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
|
||||
@@ -1137,6 +1444,7 @@ export function cleanupInactiveRooms(now = Date.now()) {
|
||||
// 2. Prune empty or inactive rooms
|
||||
const currentRoom = rooms.get(roomId);
|
||||
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
||||
clearEpisodeSyncV2Timer(currentRoom.episodeSyncV2);
|
||||
io.to(roomId).emit(EVENTS.ERROR, {
|
||||
code: ERROR_CODES.ROOM_CLOSED,
|
||||
message: 'Room closed'
|
||||
|
||||
@@ -32,6 +32,7 @@ Browser extensions cannot import files outside their own root directory, so the
|
||||
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
|
||||
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
|
||||
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
|
||||
- `CAPABILITIES.EPISODE_SYNC_V2`: relay owns an ID-correlated load/prepare/execute episode barrier.
|
||||
|
||||
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
|
||||
|
||||
@@ -57,6 +58,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
|
||||
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition lobby started |
|
||||
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
|
||||
| `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`) |
|
||||
| `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown |
|
||||
| `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks |
|
||||
|
||||
@@ -66,6 +68,8 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
|
||||
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
|
||||
- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness.
|
||||
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
|
||||
- `EPISODE_SYNC_V2_PREPARE_TIMEOUT`: max wait for every frozen participant to pause, seek, buffer, and verify stable state.
|
||||
- `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.
|
||||
|
||||
## Do Not Break
|
||||
|
||||
+5
-1
@@ -56,6 +56,7 @@ export const EVENTS = {
|
||||
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||
EPISODE_SYNC_V2: "episode_sync_v2", // Capability-gated, relay-authoritative episode transaction
|
||||
|
||||
// Ephemeral end-to-end encrypted chat
|
||||
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||
@@ -91,7 +92,8 @@ export const CAPABILITIES = {
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1', // versioned client/server chat wire contract
|
||||
MEDIA_STATE_V1: 'media-state-v1' // server-authoritative room playback recovery snapshot
|
||||
MEDIA_STATE_V1: 'media-state-v1', // server-authoritative room playback recovery snapshot
|
||||
EPISODE_SYNC_V2: 'episode-sync-v2' // transaction IDs + relay-owned load/prepare/execute phases
|
||||
};
|
||||
|
||||
// Relay and extension media-time validation must use the same upper bound.
|
||||
@@ -103,3 +105,5 @@ 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.
|
||||
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_SYNC_V2_PREPARE_TIMEOUT = 15000; // pause, seek, buffer and 1s stable verification
|
||||
export const EPISODE_SYNC_V2_STABILITY_MS = 1000;
|
||||
|
||||
@@ -147,8 +147,39 @@ async function waitForLegacyRelayEvent(socket, event, timeoutMs = 10_000) {
|
||||
return JSON.parse(socket.messages.splice(index, 1)[0].substring(2))[1];
|
||||
}
|
||||
|
||||
async function joinLegacyRelayRoom(socket, roomId, peerId) {
|
||||
sendLegacyRelayEvent(socket, 'join_room', { roomId, peerId, protocolVersion: PROTOCOL_VERSION });
|
||||
async function waitForLegacyRelayPhase(socket, event, phase, transactionId, timeoutMs = 10_000) {
|
||||
await expect.poll(() => socket.messages.some(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try {
|
||||
const [candidateEvent, payload] = JSON.parse(message.substring(2));
|
||||
return candidateEvent === event
|
||||
&& payload?.phase === phase
|
||||
&& payload?.transactionId === transactionId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}), { timeout: timeoutMs }).toBe(true);
|
||||
const index = socket.messages.findIndex(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try {
|
||||
const [candidateEvent, payload] = JSON.parse(message.substring(2));
|
||||
return candidateEvent === event
|
||||
&& payload?.phase === phase
|
||||
&& payload?.transactionId === transactionId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return JSON.parse(socket.messages.splice(index, 1)[0].substring(2))[1];
|
||||
}
|
||||
|
||||
async function joinLegacyRelayRoom(socket, roomId, peerId, clientCapabilities = undefined) {
|
||||
sendLegacyRelayEvent(socket, 'join_room', {
|
||||
roomId,
|
||||
peerId,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities
|
||||
});
|
||||
return waitForLegacyRelayEvent(socket, 'room_data');
|
||||
}
|
||||
|
||||
@@ -1485,6 +1516,106 @@ test('drops the selection when the user clears it', async ({ context, extensionI
|
||||
});
|
||||
});
|
||||
|
||||
test('completes Episode Sync v2 only after the packed player is stably prepared', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(45_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
let coordinator = null;
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const roomId = `e2e-episode-v2-${Date.now()}`;
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
await page.evaluate(async () => {
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'S1:E6 - Visiting Ours' });
|
||||
const video = document.querySelector('#player');
|
||||
video.muted = true;
|
||||
video.currentTime = 3;
|
||||
await video.play();
|
||||
});
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
|
||||
coordinator = await connectLegacyRelayClient(port);
|
||||
const episodeCaps = ['chat-v1', 'media-state-v1', 'episode-sync-v2'];
|
||||
const coordinatorPeerId = 'episode-coord';
|
||||
await joinLegacyRelayRoom(coordinator, roomId, coordinatorPeerId, episodeCaps);
|
||||
coordinator.messages.length = 0;
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'episode-player',
|
||||
autoSyncNextEpisode: true
|
||||
}));
|
||||
await expectConnectedRoom(context, extensionId, roomId);
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.peers.size).toBe(2);
|
||||
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'start',
|
||||
expectedTitle: 'S1:E6 - Visiting Ours'
|
||||
});
|
||||
const lobby = await waitForLegacyRelayEvent(coordinator, 'episode_sync_v2');
|
||||
expect(lobby).toMatchObject({ phase: 'lobby', loadedPeers: [], preparedPeers: [] });
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'loaded',
|
||||
transactionId: lobby.transactionId
|
||||
});
|
||||
|
||||
const prepare = await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'prepare',
|
||||
lobby.transactionId
|
||||
);
|
||||
expect(prepare.loadedPeers).toEqual(expect.arrayContaining([coordinatorPeerId]));
|
||||
const extensionPeerId = prepare.participants.find(peerId => peerId !== coordinatorPeerId);
|
||||
expect(extensionPeerId).toBeTruthy();
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime,
|
||||
readyState: video.readyState,
|
||||
seeking: video.seeking
|
||||
}))).toMatchObject({ paused: true, readyState: 4, seeking: false });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(1);
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.preparedPeers || [])
|
||||
.toContain(extensionPeerId);
|
||||
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'prepared',
|
||||
transactionId: lobby.transactionId
|
||||
});
|
||||
const execute = await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'execute',
|
||||
lobby.transactionId
|
||||
);
|
||||
expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 });
|
||||
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.currentTime)).toBeLessThan(3);
|
||||
|
||||
const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => {
|
||||
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
|
||||
})());
|
||||
expect(legacyForceFrames).toEqual([]);
|
||||
|
||||
const state = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
expect(state.episodeLobby).toBeNull();
|
||||
expect(state.targetTabId).toBe(tabId);
|
||||
} finally {
|
||||
try { coordinator?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('clears the selected target after inactivity removal and room closure', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(45_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
|
||||
Reference in New Issue
Block a user