mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 06:27:56 +00:00
fix(sync): harden async session and lobby races
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||||
|
|
||||||
|
function sourceBetween(startNeedle, endNeedle) {
|
||||||
|
const start = backgroundSource.indexOf(startNeedle);
|
||||||
|
const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(end).toBeGreaterThan(start);
|
||||||
|
return backgroundSource.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('async room-session guards', () => {
|
||||||
|
it('normalizes persisted room, peer, lobby and Force Sync state before restoration', () => {
|
||||||
|
const restore = sourceBetween('function ensureState()', '// Start restoration immediately');
|
||||||
|
expect(restore).toContain("typeof data.currentRoom.roomId === 'string'");
|
||||||
|
expect(restore).toContain('.map(createPeerData)');
|
||||||
|
expect(restore).toContain('data.currentRoom.activeLobby,');
|
||||||
|
expect(restore).toContain('Array.isArray(data.forceSyncAcks)');
|
||||||
|
expect(restore).toContain('currentRoom && Number.isFinite(data.forceSyncDeadline)');
|
||||||
|
expect(restore).toContain('const restoredEpisodeLobby = currentRoom');
|
||||||
|
expect(restore).toContain('data.episodeLobby,');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
|
||||||
|
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
|
||||||
|
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return chat context after its room or target changed', () => {
|
||||||
|
const handler = sourceBetween("message.type === 'GET_CHAT_CONTEXT'", "message.type === 'CHAT_SEND'");
|
||||||
|
expect(handler).toContain('const roomId = currentRoom.roomId');
|
||||||
|
expect(handler).toContain('const isCurrentSession = () =>');
|
||||||
|
expect(handler.indexOf('if (!isCurrentSession() || settings.roomId !== roomId)'))
|
||||||
|
.toBeGreaterThan(handler.indexOf('await loadLocale'));
|
||||||
|
expect(handler).not.toContain('roomId: currentRoom.roomId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops heartbeats and episode transitions that cross a room or target switch', () => {
|
||||||
|
const heartbeat = sourceBetween("message.type === 'HEARTBEAT'", "message.type === 'INJECT_CONTENT_SCRIPT'");
|
||||||
|
expect(heartbeat).toContain('const heartbeatRoomId = currentRoom?.roomId || null');
|
||||||
|
expect(heartbeat).toContain("status: 'ignored_stale_session'");
|
||||||
|
expect(heartbeat.indexOf('currentRoom?.roomId !== heartbeatRoomId'))
|
||||||
|
.toBeLessThan(heartbeat.indexOf('emit(EVENTS.PEER_STATUS'));
|
||||||
|
|
||||||
|
const episode = sourceBetween("message.type === 'EPISODE_CHANGED'", "message.type === 'EPISODE_READY_LOCAL'");
|
||||||
|
expect(episode).toContain('const isCurrentEpisodeContext = () =>');
|
||||||
|
expect(episode.match(/if \(!isCurrentEpisodeContext\(\)/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const ready = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||||
|
expect(ready).toContain('settings.roomId !== lobbyRoomId');
|
||||||
|
|
||||||
|
const privacy = sourceBetween("message.type === 'TITLE_PRIVACY_CHANGED'", "message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'");
|
||||||
|
expect(privacy).toContain('currentRoom?.roomId !== privacyRoomId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves content-event awaits before mutating canonical or room state', () => {
|
||||||
|
const handler = sourceBetween("message.type === 'CONTENT_EVENT'", "message.type === 'FORCE_SYNC_ACK'");
|
||||||
|
const processEventIndex = handler.indexOf('const processEvent = async () =>');
|
||||||
|
const videoStateIndex = handler.indexOf('await getReadyTabVideoState(tabId)', processEventIndex);
|
||||||
|
const settingsIndex = handler.indexOf('const settings = await getSettings()', videoStateIndex);
|
||||||
|
const contextGuardIndex = handler.indexOf("sendResponse({ status: 'ignored_stale_session' })", settingsIndex);
|
||||||
|
const supersedeIndex = handler.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)', contextGuardIndex);
|
||||||
|
expect(videoStateIndex).toBeGreaterThan(-1);
|
||||||
|
expect(settingsIndex).toBeGreaterThan(videoStateIndex);
|
||||||
|
expect(contextGuardIndex).toBeGreaterThan(settingsIndex);
|
||||||
|
expect(handler).toContain('(eventRoomId && settings.roomId !== eventRoomId)');
|
||||||
|
expect(supersedeIndex).toBeGreaterThan(contextGuardIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes new connection attempts behind terminal room teardown', () => {
|
||||||
|
const teardown = sourceBetween('async function endRoomSession', 'async function leaveRoomAfterIdleGrace');
|
||||||
|
expect(teardown).toContain('if (roomTeardownPromise) return roomTeardownPromise');
|
||||||
|
expect(teardown).toContain('performRoomSessionTeardown(options)');
|
||||||
|
|
||||||
|
for (const [start, end] of [
|
||||||
|
["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"],
|
||||||
|
["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"],
|
||||||
|
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"]
|
||||||
|
]) {
|
||||||
|
expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+298
-84
@@ -346,6 +346,9 @@ function requestCanonicalMediaRecoveryAttempt() {
|
|||||||
|
|
||||||
function clearCanonicalMediaRecovery() {
|
function clearCanonicalMediaRecovery() {
|
||||||
resetCanonicalMediaRecoveryRetries();
|
resetCanonicalMediaRecoveryRetries();
|
||||||
|
// The content command itself may still finish, but it belongs to the room
|
||||||
|
// being cleared and must not block the first recovery attempt of a new room.
|
||||||
|
canonicalRecoveryApplyInProgress = null;
|
||||||
canonicalMediaStateTracker.clear();
|
canonicalMediaStateTracker.clear();
|
||||||
persistCanonicalMediaRecovery();
|
persistCanonicalMediaRecovery();
|
||||||
}
|
}
|
||||||
@@ -466,7 +469,9 @@ function ensureState() {
|
|||||||
// snapshot after the worker already continued with defaults.
|
// snapshot after the worker already continued with defaults.
|
||||||
if (restorationTimedOut) return;
|
if (restorationTimedOut) return;
|
||||||
clearTimeout(storageTimeout);
|
clearTimeout(storageTimeout);
|
||||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
if (Number.isSafeInteger(data.expectedAcksCount) && data.expectedAcksCount >= 0) {
|
||||||
|
expectedAcksCount = data.expectedAcksCount;
|
||||||
|
}
|
||||||
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
|
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
|
||||||
userSelectedTabId = normalizeTabId(data.selectedTabId);
|
userSelectedTabId = normalizeTabId(data.selectedTabId);
|
||||||
userSelectedTabTitle = userSelectedTabId !== null && typeof data.selectedTabTitle === 'string'
|
userSelectedTabTitle = userSelectedTabId !== null && typeof data.selectedTabTitle === 'string'
|
||||||
@@ -495,10 +500,22 @@ function ensureState() {
|
|||||||
}
|
}
|
||||||
// Merge data from storage with any early-arriving state
|
// Merge data from storage with any early-arriving state
|
||||||
// New entries (added during boot) must stay at the top (index 0)
|
// New entries (added during boot) must stay at the top (index 0)
|
||||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
|
if (Array.isArray(data.logs)) logs = [...logs, ...data.logs].slice(0, 200);
|
||||||
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
if (Array.isArray(data.history)) history = [...history, ...data.history].slice(0, 20);
|
||||||
if (data.currentRoom) {
|
if (data.currentRoom
|
||||||
currentRoom = data.currentRoom;
|
&& typeof data.currentRoom === 'object'
|
||||||
|
&& typeof data.currentRoom.roomId === 'string'
|
||||||
|
&& data.currentRoom.roomId) {
|
||||||
|
currentRoom = { ...data.currentRoom };
|
||||||
|
currentRoom.peers = (Array.isArray(data.currentRoom.peers) ? data.currentRoom.peers : [])
|
||||||
|
.map(createPeerData)
|
||||||
|
.filter(candidate => candidate.peerId);
|
||||||
|
const restoredPeerIds = new Set(currentRoom.peers.map(candidate => candidate.peerId));
|
||||||
|
currentRoom.activeLobby = normalizeEpisodeLobby(
|
||||||
|
data.currentRoom.activeLobby,
|
||||||
|
Date.now(),
|
||||||
|
restoredPeerIds
|
||||||
|
);
|
||||||
// Host Control Mode: restore role/mode/capabilities from persisted room.
|
// Host Control Mode: restore role/mode/capabilities from persisted room.
|
||||||
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
||||||
hostPeerId = currentRoom.hostPeerId || null;
|
hostPeerId = currentRoom.hostPeerId || null;
|
||||||
@@ -516,12 +533,14 @@ function ensureState() {
|
|||||||
data.canonicalMediaRecovery,
|
data.canonicalMediaRecovery,
|
||||||
currentRoom?.roomId || null
|
currentRoom?.roomId || null
|
||||||
);
|
);
|
||||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
if (data.lastActionState && typeof data.lastActionState === 'object') {
|
||||||
|
lastActionState = data.lastActionState;
|
||||||
if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) {
|
|
||||||
isForceSyncInitiator = data.isForceSyncInitiator;
|
|
||||||
}
|
}
|
||||||
if (data.forceSyncAcks) {
|
|
||||||
|
if (currentRoom && data.isForceSyncInitiator === true && isForceSyncInitiator === false) {
|
||||||
|
isForceSyncInitiator = true;
|
||||||
|
}
|
||||||
|
if (currentRoom && isForceSyncInitiator && Array.isArray(data.forceSyncAcks)) {
|
||||||
const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]);
|
const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]);
|
||||||
forceSyncAcks = mergedAcks;
|
forceSyncAcks = mergedAcks;
|
||||||
}
|
}
|
||||||
@@ -532,7 +551,7 @@ function ensureState() {
|
|||||||
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
||||||
|
|
||||||
// Recover Force Sync Timeout
|
// Recover Force Sync Timeout
|
||||||
if (data.forceSyncDeadline) {
|
if (currentRoom && Number.isFinite(data.forceSyncDeadline)) {
|
||||||
const remaining = data.forceSyncDeadline - Date.now();
|
const remaining = data.forceSyncDeadline - Date.now();
|
||||||
if (remaining > 0 && isForceSyncInitiator) {
|
if (remaining > 0 && isForceSyncInitiator) {
|
||||||
forceSyncTimeout = setTimeout(() => {
|
forceSyncTimeout = setTimeout(() => {
|
||||||
@@ -547,8 +566,15 @@ function ensureState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recover Episode Lobby
|
// Recover Episode Lobby
|
||||||
if (data.episodeLobby && !episodeLobby) {
|
const restoredEpisodeLobby = currentRoom
|
||||||
episodeLobby = data.episodeLobby;
|
? normalizeEpisodeLobby(
|
||||||
|
data.episodeLobby,
|
||||||
|
Date.now(),
|
||||||
|
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (restoredEpisodeLobby && !episodeLobby) {
|
||||||
|
episodeLobby = restoredEpisodeLobby;
|
||||||
const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now();
|
const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now();
|
||||||
if (lobbyRemaining > 0) {
|
if (lobbyRemaining > 0) {
|
||||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining);
|
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining);
|
||||||
@@ -601,6 +627,7 @@ let currentServerUrl = null;
|
|||||||
let roomIdleSince = null;
|
let roomIdleSince = null;
|
||||||
let lastContentHeartbeatAt = null;
|
let lastContentHeartbeatAt = null;
|
||||||
let connectIntent = false;
|
let connectIntent = false;
|
||||||
|
let roomTeardownPromise = null;
|
||||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||||
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
||||||
// keeping a single client comfortably under the server's per-IP connection
|
// keeping a single client comfortably under the server's per-IP connection
|
||||||
@@ -644,20 +671,50 @@ let episodeLobbyTimeout = null;
|
|||||||
* @returns {object} Normalized peer data object.
|
* @returns {object} Normalized peer data object.
|
||||||
*/
|
*/
|
||||||
function createPeerData(raw) {
|
function createPeerData(raw) {
|
||||||
|
const source = raw && typeof raw === 'object'
|
||||||
|
? raw
|
||||||
|
: (typeof raw === 'string' ? { peerId: raw } : {});
|
||||||
return {
|
return {
|
||||||
peerId: raw.peerId || null,
|
peerId: typeof source.peerId === 'string' && source.peerId ? source.peerId.substring(0, 16) : null,
|
||||||
username: raw.username || null,
|
username: typeof source.username === 'string' ? source.username.substring(0, 30) : null,
|
||||||
tabTitle: raw.tabTitle || null,
|
tabTitle: typeof source.tabTitle === 'string' ? source.tabTitle.substring(0, 100) : null,
|
||||||
mediaTitle: raw.mediaTitle || null,
|
mediaTitle: typeof source.mediaTitle === 'string' ? source.mediaTitle.substring(0, 100) : null,
|
||||||
playbackState: raw.playbackState || null,
|
playbackState: source.playbackState === 'playing' || source.playbackState === 'paused' ? source.playbackState : null,
|
||||||
currentTime: raw.currentTime != null ? raw.currentTime : null,
|
currentTime: Number.isFinite(source.currentTime) ? source.currentTime : null,
|
||||||
volume: raw.volume != null ? raw.volume : null,
|
volume: Number.isFinite(source.volume) ? source.volume : null,
|
||||||
muted: raw.muted != null ? raw.muted : null,
|
muted: typeof source.muted === 'boolean' ? source.muted : null,
|
||||||
desynced: raw.desynced === true, // HCM: peer is watching on their own
|
desynced: source.desynced === true, // HCM: peer is watching on their own
|
||||||
lastHeartbeat: Date.now()
|
lastHeartbeat: Date.now()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
const expectedTitle = typeof value.expectedTitle === 'string'
|
||||||
|
? value.expectedTitle.substring(0, 100)
|
||||||
|
: '';
|
||||||
|
const initiatorPeerId = typeof value.initiatorPeerId === 'string'
|
||||||
|
? value.initiatorPeerId.substring(0, 16)
|
||||||
|
: '';
|
||||||
|
if (!expectedTitle
|
||||||
|
|| !initiatorPeerId
|
||||||
|
|| !Array.isArray(value.readyPeers)
|
||||||
|
|| (allowedPeerIds && !allowedPeerIds.has(initiatorPeerId))) return null;
|
||||||
|
const readyPeers = [...new Set(value.readyPeers
|
||||||
|
.filter(candidate => typeof candidate === 'string' && candidate)
|
||||||
|
.map(candidate => candidate.substring(0, 16))
|
||||||
|
.filter(candidate => !allowedPeerIds || allowedPeerIds.has(candidate)))];
|
||||||
|
if (!readyPeers.includes(initiatorPeerId)) readyPeers.unshift(initiatorPeerId);
|
||||||
|
return {
|
||||||
|
expectedTitle,
|
||||||
|
initiatorPeerId,
|
||||||
|
readyPeers,
|
||||||
|
createdAt: Number.isFinite(value.createdAt) && value.createdAt > 0
|
||||||
|
? value.createdAt
|
||||||
|
: fallbackCreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI.
|
* Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI.
|
||||||
* Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state.
|
* Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state.
|
||||||
@@ -739,9 +796,13 @@ function withTitlePrivacy(payload, settings, keys) {
|
|||||||
|
|
||||||
function emitEpisodeLobbyForCurrentPrivacy() {
|
function emitEpisodeLobbyForCurrentPrivacy() {
|
||||||
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
||||||
|
const lobby = episodeLobby;
|
||||||
|
const roomId = currentRoom?.roomId || null;
|
||||||
getSettings().then(settings => {
|
getSettings().then(settings => {
|
||||||
if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return;
|
if (episodeLobby !== lobby
|
||||||
const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
|| currentRoom?.roomId !== roomId
|
||||||
|
|| settings.roomId !== roomId) return;
|
||||||
|
const expectedTitle = sanitizeSharedTitle(lobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
||||||
if (expectedTitle) {
|
if (expectedTitle) {
|
||||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle });
|
||||||
}
|
}
|
||||||
@@ -1113,7 +1174,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
|
async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left Room' } = {}) {
|
||||||
webJoinCoordinator.invalidate();
|
webJoinCoordinator.invalidate();
|
||||||
connectIntent = false;
|
connectIntent = false;
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
@@ -1182,6 +1243,22 @@ async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {
|
|||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function endRoomSession(options = {}) {
|
||||||
|
if (roomTeardownPromise) return roomTeardownPromise;
|
||||||
|
|
||||||
|
const teardown = performRoomSessionTeardown(options);
|
||||||
|
roomTeardownPromise = teardown;
|
||||||
|
try {
|
||||||
|
return await teardown;
|
||||||
|
} finally {
|
||||||
|
if (roomTeardownPromise === teardown) roomTeardownPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRoomTeardown() {
|
||||||
|
if (roomTeardownPromise) await roomTeardownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function leaveRoomAfterIdleGrace(reason) {
|
async function leaveRoomAfterIdleGrace(reason) {
|
||||||
if (!currentRoom) return;
|
if (!currentRoom) return;
|
||||||
await endRoomSession({ notifyServer: true, reason });
|
await endRoomSession({ notifyServer: true, reason });
|
||||||
@@ -1883,7 +1960,7 @@ function markCanonicalMediaStateHandled(roomId, revision) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function supersedeCanonicalMediaRecovery(reason) {
|
function supersedeCanonicalMediaRecovery(reason, action = null, payload = null) {
|
||||||
const roomId = currentRoom?.roomId;
|
const roomId = currentRoom?.roomId;
|
||||||
const pending = canonicalMediaStateTracker.getPending(roomId);
|
const pending = canonicalMediaStateTracker.getPending(roomId);
|
||||||
if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) {
|
if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) {
|
||||||
@@ -1894,7 +1971,9 @@ function supersedeCanonicalMediaRecovery(reason) {
|
|||||||
// a newer local or remote control is allowed to win.
|
// a newer local or remote control is allowed to win.
|
||||||
sendMessageToCurrentContent({
|
sendMessageToCurrentContent({
|
||||||
type: 'CANCEL_CANONICAL_MEDIA_STATE',
|
type: 'CANCEL_CANONICAL_MEDIA_STATE',
|
||||||
reason
|
reason,
|
||||||
|
action,
|
||||||
|
payload
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info');
|
addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info');
|
||||||
return true;
|
return true;
|
||||||
@@ -2055,6 +2134,10 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
}
|
}
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case EVENTS.ROOM_DATA: {
|
case EVENTS.ROOM_DATA: {
|
||||||
|
if (typeof data.roomId !== 'string' || !data.roomId) {
|
||||||
|
addLog('Ignored malformed ROOM_DATA without a room ID', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) {
|
if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) {
|
||||||
addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn');
|
addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn');
|
||||||
return;
|
return;
|
||||||
@@ -2074,7 +2157,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
broadcastControlMode();
|
broadcastControlMode();
|
||||||
markRoomPotentiallyIdle();
|
markRoomPotentiallyIdle();
|
||||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
currentRoom.peers = currentRoom.peers
|
||||||
|
.map(createPeerData)
|
||||||
|
.filter(candidate => candidate.peerId);
|
||||||
|
|
||||||
// Clear sequence tracking for peers that are no longer in the room
|
// Clear sequence tracking for peers that are no longer in the room
|
||||||
const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p));
|
const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p));
|
||||||
@@ -2095,24 +2180,31 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
entry?.event === EVENTS.EPISODE_LOBBY
|
entry?.event === EVENTS.EPISODE_LOBBY
|
||||||
&& (!entry.roomId || entry.roomId === data.roomId)
|
&& (!entry.roomId || entry.roomId === data.roomId)
|
||||||
);
|
);
|
||||||
if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby) {
|
const authoritativeLobby = normalizeEpisodeLobby(
|
||||||
|
data.activeLobby,
|
||||||
|
Date.now(),
|
||||||
|
new Set(currentRoom.peers.map(candidate => candidate.peerId))
|
||||||
|
);
|
||||||
|
if (data.activeLobby && !authoritativeLobby) {
|
||||||
|
addLog('Ignored malformed active Episode Lobby in ROOM_DATA', 'warn');
|
||||||
|
}
|
||||||
|
currentRoom.activeLobby = authoritativeLobby;
|
||||||
|
if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby) {
|
||||||
clearEpisodeLobbyState();
|
clearEpisodeLobbyState();
|
||||||
addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info');
|
addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info');
|
||||||
} else if (data?.activeLobby) {
|
} else if (authoritativeLobby) {
|
||||||
const sameLobby = episodeLobby
|
const sameLobby = episodeLobby
|
||||||
&& episodeLobby.expectedTitle === data.activeLobby.expectedTitle
|
&& episodeLobby.expectedTitle === authoritativeLobby.expectedTitle
|
||||||
&& episodeLobby.initiatorPeerId === data.activeLobby.initiatorPeerId;
|
&& episodeLobby.initiatorPeerId === authoritativeLobby.initiatorPeerId;
|
||||||
if (!sameLobby && episodeLobbyTimeout) {
|
if (!sameLobby && episodeLobbyTimeout) {
|
||||||
clearTimeout(episodeLobbyTimeout);
|
clearTimeout(episodeLobbyTimeout);
|
||||||
episodeLobbyTimeout = null;
|
episodeLobbyTimeout = null;
|
||||||
}
|
}
|
||||||
episodeLobby = {
|
episodeLobby = {
|
||||||
expectedTitle: data.activeLobby.expectedTitle,
|
...authoritativeLobby,
|
||||||
initiatorPeerId: data.activeLobby.initiatorPeerId,
|
|
||||||
readyPeers: data.activeLobby.readyPeers,
|
|
||||||
createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt)
|
createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt)
|
||||||
? episodeLobby.createdAt
|
? episodeLobby.createdAt
|
||||||
: Date.now()
|
: authoritativeLobby.createdAt
|
||||||
};
|
};
|
||||||
persistEpisodeLobby();
|
persistEpisodeLobby();
|
||||||
broadcastLobbyUpdate();
|
broadcastLobbyUpdate();
|
||||||
@@ -2143,7 +2235,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
// Inform Website Bridge & Popup
|
// Inform Website Bridge & Popup
|
||||||
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
||||||
await broadcastJoinStatus(joinStatusMsg);
|
await broadcastJoinStatus(joinStatusMsg);
|
||||||
if (expectedConnectionGeneration !== connectionGeneration) return;
|
if (expectedConnectionGeneration !== connectionGeneration
|
||||||
|
|| currentRoom?.roomId !== data.roomId) return;
|
||||||
// Resolve replay privacy before declaring ROOM_DATA complete. Local
|
// Resolve replay privacy before declaring ROOM_DATA complete. Local
|
||||||
// commands remain queued during this await, and any intervening
|
// commands remain queued during this await, and any intervening
|
||||||
// role/lobby event is reflected by the policy below before the
|
// role/lobby event is reflected by the policy below before the
|
||||||
@@ -2151,7 +2244,8 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
const replaySettings = eventQueue.some(isQueuedMediaIntent)
|
const replaySettings = eventQueue.some(isQueuedMediaIntent)
|
||||||
? await getSettings()
|
? await getSettings()
|
||||||
: null;
|
: null;
|
||||||
if (expectedConnectionGeneration !== connectionGeneration) return;
|
if (expectedConnectionGeneration !== connectionGeneration
|
||||||
|
|| currentRoom?.roomId !== data.roomId) return;
|
||||||
awaitingRoomData = false;
|
awaitingRoomData = false;
|
||||||
pendingRoomDataRoomId = null;
|
pendingRoomDataRoomId = null;
|
||||||
|
|
||||||
@@ -2162,7 +2256,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
canControl: !lostRoomAuthority,
|
canControl: !lostRoomAuthority,
|
||||||
activeLobby: !!episodeLobby,
|
activeLobby: !!episodeLobby,
|
||||||
desynced: hcmDesynced,
|
desynced: hcmDesynced,
|
||||||
authoritativeLobby: !!data.activeLobby
|
authoritativeLobby: !!authoritativeLobby
|
||||||
}, lostRoomAuthority
|
}, lostRoomAuthority
|
||||||
? 'Host Control role changed while offline'
|
? 'Host Control role changed while offline'
|
||||||
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
|
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
|
||||||
@@ -2296,7 +2390,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
_persistLastSeq();
|
_persistLastSeq();
|
||||||
}
|
}
|
||||||
if (isCanonicalSupersedingControl(event, data)) {
|
if (isCanonicalSupersedingControl(event, data)) {
|
||||||
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
supersedeCanonicalMediaRecovery(`newer ${event}`, event, data);
|
||||||
}
|
}
|
||||||
if (data.senderId) {
|
if (data.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
@@ -2360,7 +2454,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
lastSeqBySender[data.senderId] = data.seq;
|
lastSeqBySender[data.senderId] = data.seq;
|
||||||
_persistLastSeq();
|
_persistLastSeq();
|
||||||
}
|
}
|
||||||
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
supersedeCanonicalMediaRecovery(`newer ${event}`, event, data);
|
||||||
if (data?.senderId) {
|
if (data?.senderId) {
|
||||||
addToHistory(event, data.senderId);
|
addToHistory(event, data.senderId);
|
||||||
showNotification(data.senderId, event);
|
showNotification(data.senderId, event);
|
||||||
@@ -2495,28 +2589,40 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case EVENTS.EPISODE_LOBBY:
|
case EVENTS.EPISODE_LOBBY:
|
||||||
if (data.senderId && data.expectedTitle) {
|
if (typeof data.senderId === 'string'
|
||||||
|
&& typeof data.expectedTitle === 'string'
|
||||||
|
&& data.expectedTitle
|
||||||
|
&& currentRoom?.peers?.some(peer => (typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) {
|
||||||
|
const expectedTitle = data.expectedTitle.substring(0, 100);
|
||||||
|
const incomingLobby = normalizeEpisodeLobby({
|
||||||
|
expectedTitle,
|
||||||
|
initiatorPeerId: data.senderId,
|
||||||
|
readyPeers: data.authoritative === true && Array.isArray(data.readyPeers)
|
||||||
|
? data.readyPeers
|
||||||
|
: [data.senderId],
|
||||||
|
createdAt: Date.now()
|
||||||
|
}, Date.now(), new Set(currentRoom.peers.map(peer => peer.peerId)));
|
||||||
|
if (!incomingLobby) {
|
||||||
|
addLog(`Ignored malformed Episode lobby from ${data.senderId}`, 'warn');
|
||||||
|
break;
|
||||||
|
}
|
||||||
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
currentRoom.activeLobby = {
|
currentRoom.activeLobby = incomingLobby;
|
||||||
expectedTitle: data.expectedTitle,
|
|
||||||
initiatorPeerId: data.senderId,
|
|
||||||
readyPeers: [data.senderId]
|
|
||||||
};
|
|
||||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||||
}
|
}
|
||||||
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
|
addLog(`Episode lobby from ${data.senderId}: "${expectedTitle}"`, 'info');
|
||||||
// If we already have a lobby for this same title, treat as dedup
|
// If we already have a lobby for this same title, treat as dedup
|
||||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) {
|
if (episodeLobby
|
||||||
|
&& episodeLobby.initiatorPeerId === data.senderId
|
||||||
|
&& sameEpisode(episodeLobby.expectedTitle, expectedTitle)) {
|
||||||
break; // Already tracking this lobby
|
break; // Already tracking this lobby
|
||||||
}
|
}
|
||||||
// Cancel any existing lobby before starting a new one
|
// Cancel any existing lobby before starting a new one
|
||||||
if (episodeLobby) clearEpisodeLobbyState();
|
if (episodeLobby) clearEpisodeLobbyState();
|
||||||
|
|
||||||
episodeLobby = {
|
episodeLobby = {
|
||||||
expectedTitle: data.expectedTitle,
|
...incomingLobby,
|
||||||
initiatorPeerId: data.senderId,
|
|
||||||
readyPeers: [data.senderId], // Initiator is already ready
|
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
};
|
};
|
||||||
persistEpisodeLobby();
|
persistEpisodeLobby();
|
||||||
@@ -2531,7 +2637,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
if (!isNaN(tabId)) {
|
if (!isNaN(tabId)) {
|
||||||
sendMessageToCurrentContent({
|
sendMessageToCurrentContent({
|
||||||
type: 'EPISODE_LOBBY',
|
type: 'EPISODE_LOBBY',
|
||||||
expectedTitle: data.expectedTitle
|
expectedTitle
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2541,6 +2647,15 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
{
|
{
|
||||||
const lobby = episodeLobby;
|
const lobby = episodeLobby;
|
||||||
if (!lobby || !data.senderId) break;
|
if (!lobby || !data.senderId) break;
|
||||||
|
const senderPresent = currentRoom?.peers?.some(peer =>
|
||||||
|
(typeof peer === 'object' ? peer.peerId : peer) === data.senderId
|
||||||
|
);
|
||||||
|
if (!senderPresent
|
||||||
|
|| (data.expectedTitle
|
||||||
|
&& !sameEpisode(data.expectedTitle, lobby.expectedTitle))) {
|
||||||
|
addLog(`Ignored stale Episode ready from ${data.senderId}`, 'warn');
|
||||||
|
break;
|
||||||
|
}
|
||||||
let readyAdded = false;
|
let readyAdded = false;
|
||||||
if (!lobby.readyPeers.includes(data.senderId)) {
|
if (!lobby.readyPeers.includes(data.senderId)) {
|
||||||
lobby.readyPeers.push(data.senderId);
|
lobby.readyPeers.push(data.senderId);
|
||||||
@@ -2558,6 +2673,11 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case EVENTS.EPISODE_LOBBY_CANCEL:
|
case EVENTS.EPISODE_LOBBY_CANCEL:
|
||||||
|
if (typeof data.senderId !== 'string' || !currentRoom?.peers?.some(peer =>
|
||||||
|
(typeof peer === 'object' ? peer.peerId : peer) === data.senderId)) {
|
||||||
|
addLog(`Ignored Episode lobby cancellation from unknown peer ${data.senderId || 'unknown'}`, 'warn');
|
||||||
|
break;
|
||||||
|
}
|
||||||
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
supersedeCanonicalMediaRecovery(`newer ${event}`);
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
currentRoom.activeLobby = null;
|
currentRoom.activeLobby = null;
|
||||||
@@ -2591,7 +2711,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
|
|||||||
}
|
}
|
||||||
|
|
||||||
function executeForceSync() {
|
function executeForceSync() {
|
||||||
supersedeCanonicalMediaRecovery('local force_sync_execute');
|
supersedeCanonicalMediaRecovery('local force_sync_execute', EVENTS.FORCE_SYNC_EXECUTE);
|
||||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||||
isForceSyncInitiator = false;
|
isForceSyncInitiator = false;
|
||||||
forceSyncAcks.clear();
|
forceSyncAcks.clear();
|
||||||
@@ -2749,8 +2869,13 @@ function checkEpisodeLobbyCompletion() {
|
|||||||
// M-3: desynced peers (watching on their own) sit out the lobby — their content
|
// 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
|
// script ignores EPISODE_LOBBY and never reports ready. Don't let them block
|
||||||
// completion: count only peers who actually participate.
|
// completion: count only peers who actually participate.
|
||||||
const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length;
|
const participatingPeerIds = new Set(peers
|
||||||
if (episodeLobby.readyPeers.length >= participatingCount) {
|
.filter(candidate => !(typeof candidate === 'object' && candidate.desynced))
|
||||||
|
.map(candidate => typeof candidate === 'object' ? candidate.peerId : candidate)
|
||||||
|
.filter(Boolean));
|
||||||
|
const readyParticipatingCount = episodeLobby.readyPeers
|
||||||
|
.filter(candidate => participatingPeerIds.has(candidate)).length;
|
||||||
|
if (readyParticipatingCount >= participatingPeerIds.size) {
|
||||||
executeEpisodeLobby();
|
executeEpisodeLobby();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4384,6 +4509,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'CONNECT') {
|
if (message.type === 'CONNECT') {
|
||||||
|
await waitForRoomTeardown();
|
||||||
webJoinCoordinator.invalidate();
|
webJoinCoordinator.invalidate();
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
connectIntent = !!settings.roomId;
|
connectIntent = !!settings.roomId;
|
||||||
@@ -4422,6 +4548,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'RETRY_CONNECT') {
|
} else if (message.type === 'RETRY_CONNECT') {
|
||||||
|
await waitForRoomTeardown();
|
||||||
connectIntent = true;
|
connectIntent = true;
|
||||||
reconnectFailed = false;
|
reconnectFailed = false;
|
||||||
reconnectStartTime = null;
|
reconnectStartTime = null;
|
||||||
@@ -4508,9 +4635,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
sendResponse({ supported: false, hasKey: false });
|
sendResponse({ supported: false, hasKey: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const generation = chatSessionGeneration;
|
||||||
|
const roomId = currentRoom.roomId;
|
||||||
|
const tabId = Number(currentTabId);
|
||||||
|
const isCurrentSession = () => generation === chatSessionGeneration
|
||||||
|
&& currentRoom?.roomId === roomId
|
||||||
|
&& Number(currentTabId) === tabId
|
||||||
|
&& isCurrentContentSender(sender);
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']);
|
const localeData = await chrome.storage.local.get(['locale', 'browserNotifications']);
|
||||||
await loadLocale(localeData.locale || getSystemLanguage());
|
await loadLocale(localeData.locale || getSystemLanguage());
|
||||||
|
if (!isCurrentSession() || settings.roomId !== roomId) {
|
||||||
|
sendResponse({ supported: false, hasKey: false, status: 'session_changed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const translated = key => {
|
const translated = key => {
|
||||||
const value = getMessage(key);
|
const value = getMessage(key);
|
||||||
return value === key ? '' : value;
|
return value === key ? '' : value;
|
||||||
@@ -4522,7 +4660,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||||
eventNotifications: localeData.browserNotifications === true,
|
eventNotifications: localeData.browserNotifications === true,
|
||||||
peerId,
|
peerId,
|
||||||
roomId: currentRoom.roomId,
|
roomId,
|
||||||
activity: chatActivityStore.snapshot(),
|
activity: chatActivityStore.snapshot(),
|
||||||
strings: {
|
strings: {
|
||||||
title: translated('CHAT_TITLE'),
|
title: translated('CHAT_TITLE'),
|
||||||
@@ -4697,6 +4835,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
addLog(`Canonical media state r${pending.mediaState.revision} skipped: local guest chose desynced mode`, 'info');
|
addLog(`Canonical media state r${pending.mediaState.revision} skipped: local guest chose desynced mode`, 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (episodeLobby) {
|
||||||
|
if (hcmDesynced && episodeLobby.initiatorPeerId === peerId) {
|
||||||
|
cancelEpisodeLobby('Initiator entered solo mode');
|
||||||
|
} else {
|
||||||
|
checkEpisodeLobbyCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'LEAVE_ROOM') {
|
} else if (message.type === 'LEAVE_ROOM') {
|
||||||
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
await endRoomSession({ notifyServer: true, reason: 'Left Room' });
|
||||||
@@ -4712,6 +4857,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
emit(EVENTS.GET_ROOMS, {});
|
emit(EVENTS.GET_ROOMS, {});
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||||
|
await waitForRoomTeardown();
|
||||||
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
||||||
const roomId = normalizeRoomId(rawRoomId);
|
const roomId = normalizeRoomId(rawRoomId);
|
||||||
const chatKey = validateChatSecret(rawChatKey);
|
const chatKey = validateChatSecret(rawChatKey);
|
||||||
@@ -4843,6 +4989,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const processEvent = async () => {
|
const processEvent = async () => {
|
||||||
|
const eventRoomId = currentRoom?.roomId || pendingRoomDataRoomId || null;
|
||||||
|
const eventTabId = normalizeTabId(currentTabId);
|
||||||
|
const isCurrentEventContext = () => (currentRoom?.roomId || pendingRoomDataRoomId || null) === eventRoomId
|
||||||
|
&& normalizeTabId(currentTabId) === eventTabId
|
||||||
|
&& (!senderIsContent || isCurrentContentSender(sender));
|
||||||
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
||||||
// not drive the room. Don't broadcast; hand the action back to content.js so
|
// not drive the room. Don't broadcast; hand the action back to content.js so
|
||||||
// it can snap the local player back / offer desync.
|
// it can snap the local player back / offer desync.
|
||||||
@@ -4866,7 +5017,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
||||||
// never cached, so the instant a peer joins we resume sending.
|
// never cached, so the instant a peer joins we resume sending.
|
||||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||||
const hasOtherPeers = otherCount > 0;
|
let hasOtherPeers = otherCount > 0;
|
||||||
|
|
||||||
// Force Sync only makes sense with other peers. Solo it is a no-op:
|
// Force Sync only makes sense with other peers. Solo it is a no-op:
|
||||||
// skip the pause/seek + ACK-wait entirely (no freeze, no server traffic).
|
// skip the pause/seek + ACK-wait entirely (no freeze, no server traffic).
|
||||||
@@ -4894,14 +5045,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
payload.targetTime = targetTime;
|
payload.targetTime = targetTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCanonicalSupersedingControl(message.action, payload)) {
|
|
||||||
supersedeCanonicalMediaRecovery(`local ${message.action}`);
|
|
||||||
}
|
|
||||||
const timestamp = Date.now();
|
|
||||||
localSeq++;
|
|
||||||
chrome.storage.session.set({ localSeq });
|
|
||||||
updateLastAction(message.action, 'You', timestamp);
|
|
||||||
|
|
||||||
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
|
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
|
||||||
if (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
|
if (!senderIsContent && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
|
||||||
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
|
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
|
||||||
@@ -4912,6 +5055,36 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!isCurrentEventContext()) {
|
||||||
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNonEssentialEvent = message.action === EVENTS.PLAY
|
||||||
|
|| message.action === EVENTS.PAUSE
|
||||||
|
|| message.action === EVENTS.SEEK;
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!isCurrentEventContext()
|
||||||
|
|| (eventRoomId && settings.roomId !== eventRoomId)) {
|
||||||
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOtherCount = currentRoom && Array.isArray(currentRoom.peers)
|
||||||
|
? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length
|
||||||
|
: 0;
|
||||||
|
hasOtherPeers = currentOtherCount > 0;
|
||||||
|
const shouldEmit = !(isNonEssentialEvent
|
||||||
|
&& !hasOtherPeers
|
||||||
|
&& !serverSupports(CAPABILITIES.MEDIA_STATE_V1));
|
||||||
|
|
||||||
|
if (isCanonicalSupersedingControl(message.action, payload)) {
|
||||||
|
supersedeCanonicalMediaRecovery(`local ${message.action}`);
|
||||||
|
}
|
||||||
|
const timestamp = Date.now();
|
||||||
|
localSeq++;
|
||||||
|
chrome.storage.session.set({ localSeq });
|
||||||
|
updateLastAction(message.action, 'You', timestamp);
|
||||||
|
|
||||||
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
|
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
|
||||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||||
|
|
||||||
@@ -4955,15 +5128,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
addToHistory(message.action, 'You');
|
addToHistory(message.action, 'You');
|
||||||
sendChatActivity(message.action, peerId, timestamp);
|
sendChatActivity(message.action, peerId, timestamp);
|
||||||
|
|
||||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
if (!shouldEmit) {
|
||||||
if (isNonEssentialEvent
|
|
||||||
&& !hasOtherPeers
|
|
||||||
&& !serverSupports(CAPABILITIES.MEDIA_STATE_V1)) {
|
|
||||||
sendResponse({ status: 'ok_solo' });
|
sendResponse({ status: 'ok_solo' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSettings();
|
|
||||||
const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']);
|
const outboundPayload = withTitlePrivacy(message.payload, settings, ['mediaTitle']);
|
||||||
emit(message.action, { ...outboundPayload, peerId });
|
emit(message.action, { ...outboundPayload, peerId });
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
@@ -5049,10 +5218,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
|
|
||||||
requestCanonicalMediaRecoveryAttempt();
|
requestCanonicalMediaRecoveryAttempt();
|
||||||
markRoomUseful();
|
markRoomUseful();
|
||||||
|
const heartbeatRoomId = currentRoom?.roomId || null;
|
||||||
|
const heartbeatPayload = message.payload && typeof message.payload === 'object'
|
||||||
|
? { ...message.payload }
|
||||||
|
: {};
|
||||||
getSettings().then(settings => {
|
getSettings().then(settings => {
|
||||||
const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle);
|
if ((sender.tab && !isCurrentContentSender(sender))
|
||||||
|
|| currentRoom?.roomId !== heartbeatRoomId
|
||||||
|
|| settings.roomId !== heartbeatRoomId) {
|
||||||
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sharedTitles = getSharedTitleFields(settings, heartbeatPayload.mediaTitle);
|
||||||
const statusPayload = {
|
const statusPayload = {
|
||||||
...message.payload,
|
...heartbeatPayload,
|
||||||
peerId,
|
peerId,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
tabTitle: sharedTitles.tabTitle,
|
tabTitle: sharedTitles.tabTitle,
|
||||||
@@ -5068,10 +5247,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
me.tabTitle = sharedTitles.tabTitle;
|
me.tabTitle = sharedTitles.tabTitle;
|
||||||
me.username = settings.username;
|
me.username = settings.username;
|
||||||
me.mediaTitle = sharedTitles.mediaTitle;
|
me.mediaTitle = sharedTitles.mediaTitle;
|
||||||
me.playbackState = message.payload?.playbackState;
|
me.playbackState = heartbeatPayload.playbackState;
|
||||||
me.currentTime = message.payload?.currentTime;
|
me.currentTime = heartbeatPayload.currentTime;
|
||||||
me.volume = message.payload?.volume;
|
me.volume = heartbeatPayload.volume;
|
||||||
me.muted = message.payload?.muted;
|
me.muted = heartbeatPayload.muted;
|
||||||
me.lastHeartbeat = Date.now();
|
me.lastHeartbeat = Date.now();
|
||||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||||
@@ -5167,6 +5346,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const episodeRoomId = currentRoom?.roomId || null;
|
||||||
|
const isCurrentEpisodeContext = () => currentRoom?.roomId === episodeRoomId
|
||||||
|
&& (!sender.tab || isCurrentContentSender(sender));
|
||||||
|
|
||||||
const newTitle = message.payload && message.payload.newTitle;
|
const newTitle = message.payload && message.payload.newTitle;
|
||||||
if (newTitle && extractEpisodeId(newTitle) === null) {
|
if (newTitle && extractEpisodeId(newTitle) === null) {
|
||||||
@@ -5180,6 +5362,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
if (!isCurrentEpisodeContext() || settings.roomId !== episodeRoomId) {
|
||||||
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
const lobbyTitle = sanitizeSharedTitle(newTitle, settings.mediaTitlePrivacyMode);
|
||||||
if (!lobbyTitle) {
|
if (!lobbyTitle) {
|
||||||
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
addLog(`Episode change detected but media title sharing is ${settings.mediaTitlePrivacyMode}; not creating a lobby.`, 'info');
|
||||||
@@ -5189,12 +5375,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
|
|
||||||
// Check setting
|
// Check setting
|
||||||
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
const epSettings = await chrome.storage.local.get(['autoSyncNextEpisode']);
|
||||||
|
if (!isCurrentEpisodeContext()) {
|
||||||
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (epSettings.autoSyncNextEpisode === false) {
|
if (epSettings.autoSyncNextEpisode === false) {
|
||||||
addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info');
|
addLog(`Episode change detected ("${lobbyTitle}") but Auto-Sync is disabled.`, 'info');
|
||||||
sendResponse({ status: 'disabled' });
|
sendResponse({ status: 'disabled' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hcmDesynced) {
|
||||||
|
addLog(`Episode change ("${lobbyTitle}") — intentional solo mode, not creating a lobby.`, 'info');
|
||||||
|
sendResponse({ status: 'desynced_skip' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Host Control Mode: a gated guest must NOT initiate an episode lobby — the
|
// 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
|
// 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
|
// and the guest would self-pause (PAUSE_FOR_LOBBY) into a 60s freeze. In
|
||||||
@@ -5223,7 +5419,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
episodeLobby.readyPeers.push(peerId);
|
episodeLobby.readyPeers.push(peerId);
|
||||||
persistEpisodeLobby();
|
persistEpisodeLobby();
|
||||||
broadcastLobbyUpdate();
|
broadcastLobbyUpdate();
|
||||||
emit(EVENTS.EPISODE_READY, { peerId, title: lobbyTitle });
|
emit(EVENTS.EPISODE_READY, {
|
||||||
|
peerId,
|
||||||
|
title: lobbyTitle,
|
||||||
|
expectedTitle: episodeLobby.expectedTitle
|
||||||
|
});
|
||||||
checkEpisodeLobbyCompletion();
|
checkEpisodeLobbyCompletion();
|
||||||
}
|
}
|
||||||
sendResponse({ status: 'ready_sent' });
|
sendResponse({ status: 'ready_sent' });
|
||||||
@@ -5234,7 +5434,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
if (episodeLobby) clearEpisodeLobbyState();
|
if (episodeLobby) clearEpisodeLobbyState();
|
||||||
|
|
||||||
// Create new lobby
|
// Create new lobby
|
||||||
supersedeCanonicalMediaRecovery('local episode_lobby');
|
supersedeCanonicalMediaRecovery('local episode_lobby', EVENTS.PAUSE);
|
||||||
episodeLobby = {
|
episodeLobby = {
|
||||||
expectedTitle: lobbyTitle,
|
expectedTitle: lobbyTitle,
|
||||||
initiatorPeerId: peerId,
|
initiatorPeerId: peerId,
|
||||||
@@ -5273,10 +5473,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
}
|
}
|
||||||
// Content script confirmed it loaded the lobby episode
|
// Content script confirmed it loaded the lobby episode
|
||||||
const lobby = episodeLobby;
|
const lobby = episodeLobby;
|
||||||
|
const lobbyRoomId = currentRoom?.roomId || null;
|
||||||
|
const isCurrentLobbyContext = () => episodeLobby === lobby
|
||||||
|
&& currentRoom?.roomId === lobbyRoomId
|
||||||
|
&& (!sender.tab || isCurrentContentSender(sender));
|
||||||
if (lobby && message.payload && sameEpisode(message.payload.title, lobby.expectedTitle)) {
|
if (lobby && message.payload && sameEpisode(message.payload.title, lobby.expectedTitle)) {
|
||||||
if (!lobby.readyPeers.includes(peerId)) {
|
if (!lobby.readyPeers.includes(peerId)) {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (episodeLobby !== lobby) {
|
if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId) {
|
||||||
sendResponse({ status: 'ignored_stale_lobby' });
|
sendResponse({ status: 'ignored_stale_lobby' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5288,17 +5492,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
lobby.readyPeers.push(peerId);
|
lobby.readyPeers.push(peerId);
|
||||||
persistEpisodeLobby();
|
persistEpisodeLobby();
|
||||||
broadcastLobbyUpdate();
|
broadcastLobbyUpdate();
|
||||||
emit(EVENTS.EPISODE_READY, { peerId, title: readyTitle });
|
emit(EVENTS.EPISODE_READY, {
|
||||||
|
peerId,
|
||||||
|
title: readyTitle,
|
||||||
|
expectedTitle: lobby.expectedTitle
|
||||||
|
});
|
||||||
addLog(`Local episode ready: "${readyTitle || lobby.expectedTitle}"`, 'success');
|
addLog(`Local episode ready: "${readyTitle || lobby.expectedTitle}"`, 'success');
|
||||||
checkEpisodeLobbyCompletion();
|
checkEpisodeLobbyCompletion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
} else if (message.type === 'TITLE_PRIVACY_CHANGED') {
|
||||||
|
const privacyRoomId = currentRoom?.roomId || null;
|
||||||
|
const privacyLobby = episodeLobby;
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
if (currentRoom?.roomId !== privacyRoomId || settings.roomId !== privacyRoomId) {
|
||||||
const nextLobbyTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
sendResponse({ status: 'ignored_stale_session' });
|
||||||
if (!nextLobbyTitle || nextLobbyTitle !== episodeLobby.expectedTitle) {
|
return;
|
||||||
|
}
|
||||||
|
if (episodeLobby === privacyLobby && privacyLobby?.initiatorPeerId === peerId) {
|
||||||
|
const nextLobbyTitle = sanitizeSharedTitle(privacyLobby.expectedTitle, settings.mediaTitlePrivacyMode);
|
||||||
|
if (!nextLobbyTitle || nextLobbyTitle !== privacyLobby.expectedTitle) {
|
||||||
cancelEpisodeLobby('Title privacy changed');
|
cancelEpisodeLobby('Title privacy changed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
|
|||||||
const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt');
|
const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt');
|
||||||
expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length');
|
expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length');
|
||||||
expect(retry).toContain('latest?.mediaState.revision !== expectedRevision');
|
expect(retry).toContain('latest?.mediaState.revision !== expectedRevision');
|
||||||
|
const clear = functionBody(backgroundSource, 'clearCanonicalMediaRecovery', 'invalidateChatSession');
|
||||||
|
expect(clear).toContain('canonicalRecoveryApplyInProgress = null');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
|
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
|
||||||
@@ -91,6 +93,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
|
|||||||
expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)');
|
expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)');
|
||||||
expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)');
|
expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)');
|
||||||
expect(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'");
|
expect(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'");
|
||||||
|
expect(supersede).toContain('action,');
|
||||||
|
expect(supersede).toContain('payload');
|
||||||
expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)');
|
expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)');
|
||||||
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)');
|
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)');
|
||||||
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)');
|
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)');
|
||||||
@@ -118,6 +122,10 @@ describe('canonical ROOM_DATA recovery contract', () => {
|
|||||||
expect(contentSource.slice(contentHandlerStart, serverCommandStart))
|
expect(contentSource.slice(contentHandlerStart, serverCommandStart))
|
||||||
.toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)');
|
.toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)');
|
||||||
expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'");
|
expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'");
|
||||||
|
expect(contentSource).toContain('cancelCanonicalMediaApply(action, findVideo(), false, payload)');
|
||||||
|
expect(contentSource).toContain('restorationGeneration !== canonicalMediaApplyGeneration');
|
||||||
|
expect(contentSource).toContain('holdCanonicalRestorePlaySuppression()');
|
||||||
|
expect(contentSource).toContain('consumeCanonicalRestorePlaySuppression()');
|
||||||
expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)');
|
expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function createCanonicalMediaStateTracker() {
|
|||||||
const restoredPending = validateCanonicalMediaState(value.pending?.mediaState);
|
const restoredPending = validateCanonicalMediaState(value.pending?.mediaState);
|
||||||
if (value.pending?.roomId === roomId
|
if (value.pending?.roomId === roomId
|
||||||
&& restoredPending
|
&& restoredPending
|
||||||
&& restoredPending.revision >= appliedRevision
|
&& restoredPending.revision > appliedRevision
|
||||||
&& restoredPending.revision >= knownRevision) {
|
&& restoredPending.revision >= knownRevision) {
|
||||||
pending = {
|
pending = {
|
||||||
roomId,
|
roomId,
|
||||||
|
|||||||
@@ -164,4 +164,19 @@ describe('canonical media state tracker', () => {
|
|||||||
expect(otherRoom.restore(stored, 'room-b')).toBe(false);
|
expect(otherRoom.restore(stored, 'room-b')).toBe(false);
|
||||||
expect(otherRoom.getPending('room-b')).toBeNull();
|
expect(otherRoom.getPending('room-b')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not resurrect a pending snapshot that was already applied', () => {
|
||||||
|
const tracker = createCanonicalMediaStateTracker();
|
||||||
|
expect(tracker.restore({
|
||||||
|
roomId: 'room-a',
|
||||||
|
knownRevision: 7,
|
||||||
|
appliedRevision: 7,
|
||||||
|
pending: {
|
||||||
|
roomId: 'room-a',
|
||||||
|
mediaState: state(7),
|
||||||
|
receivedAt: 1_000
|
||||||
|
}
|
||||||
|
}, 'room-a')).toBe(true);
|
||||||
|
expect(tracker.getPending('room-a')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-8
@@ -91,6 +91,7 @@
|
|||||||
// While a timer exists, matching native events are consumed and not relayed.
|
// While a timer exists, matching native events are consumed and not relayed.
|
||||||
// Timers self-clean after 300ms if the native event never fires.
|
// Timers self-clean after 300ms if the native event never fires.
|
||||||
let _suppressTimers = {};
|
let _suppressTimers = {};
|
||||||
|
const canonicalRestorePlaySuppressions = new Set();
|
||||||
let canonicalMediaApplyGeneration = 0;
|
let canonicalMediaApplyGeneration = 0;
|
||||||
let canonicalSupersedingLocalState = null;
|
let canonicalSupersedingLocalState = null;
|
||||||
|
|
||||||
@@ -108,6 +109,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function holdCanonicalRestorePlaySuppression() {
|
||||||
|
const hold = { timeout: null };
|
||||||
|
hold.timeout = setTimeout(() => canonicalRestorePlaySuppressions.delete(hold), 5000);
|
||||||
|
canonicalRestorePlaySuppressions.add(hold);
|
||||||
|
return hold;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseCanonicalRestorePlaySuppression(hold) {
|
||||||
|
if (!canonicalRestorePlaySuppressions.delete(hold)) return;
|
||||||
|
clearTimeout(hold.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCanonicalRestorePlaySuppression() {
|
||||||
|
const hold = canonicalRestorePlaySuppressions.values().next().value;
|
||||||
|
if (!hold) return false;
|
||||||
|
releaseCanonicalRestorePlaySuppression(hold);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Seek Relay Filtering ---
|
// --- Seek Relay Filtering ---
|
||||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||||
// from being relayed to peers as user-initiated seeks.
|
// from being relayed to peers as user-initiated seeks.
|
||||||
@@ -1237,14 +1257,25 @@
|
|||||||
return canonicalMediaApplyGeneration;
|
return canonicalMediaApplyGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) {
|
function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) {
|
||||||
canonicalMediaApplyGeneration++;
|
canonicalMediaApplyGeneration++;
|
||||||
if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) {
|
const storesPlaybackIntent = action === EVENTS.PLAY
|
||||||
|
|| action === EVENTS.PAUSE
|
||||||
|
|| action === EVENTS.SEEK
|
||||||
|
|| action === EVENTS.FORCE_SYNC_PREPARE
|
||||||
|
|| action === EVENTS.FORCE_SYNC_EXECUTE;
|
||||||
|
if (storesPlaybackIntent) {
|
||||||
|
const payloadTime = Number.isFinite(data?.targetTime)
|
||||||
|
? data.targetTime
|
||||||
|
: (Number.isFinite(data?.currentTime) ? data.currentTime : null);
|
||||||
|
const videoTime = video ? getSyncCurrentTime(video) : null;
|
||||||
canonicalSupersedingLocalState = {
|
canonicalSupersedingLocalState = {
|
||||||
playbackState: action === EVENTS.PLAY
|
playbackState: action === EVENTS.PLAY || action === EVENTS.FORCE_SYNC_EXECUTE
|
||||||
? 'playing'
|
? 'playing'
|
||||||
: (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')),
|
: (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE
|
||||||
currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null
|
? 'paused'
|
||||||
|
: (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)),
|
||||||
|
currentTime: payloadTime ?? videoTime
|
||||||
};
|
};
|
||||||
} else if (!preserveLocalState) {
|
} else if (!preserveLocalState) {
|
||||||
canonicalSupersedingLocalState = null;
|
canonicalSupersedingLocalState = null;
|
||||||
@@ -1257,8 +1288,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSupersedingLocalState(video) {
|
async function restoreSupersedingLocalState(video) {
|
||||||
|
const restorationGeneration = canonicalMediaApplyGeneration;
|
||||||
const state = canonicalSupersedingLocalState;
|
const state = canonicalSupersedingLocalState;
|
||||||
if (!state || !video) return;
|
if (!state || !video || destroyed || video.isConnected === false) return;
|
||||||
|
|
||||||
if (state.playbackState === 'paused' && !video.paused) {
|
if (state.playbackState === 'paused' && !video.paused) {
|
||||||
_setSuppress('paused');
|
_setSuppress('paused');
|
||||||
@@ -1273,13 +1305,21 @@
|
|||||||
}
|
}
|
||||||
if (state.playbackState === 'playing' && video.paused) {
|
if (state.playbackState === 'playing' && video.paused) {
|
||||||
_setSuppress('playing');
|
_setSuppress('playing');
|
||||||
|
const playSuppression = holdCanonicalRestorePlaySuppression();
|
||||||
try {
|
try {
|
||||||
await video.play();
|
await video.play();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_clearSuppress('playing');
|
_clearSuppress('playing');
|
||||||
reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn');
|
reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn');
|
||||||
|
} finally {
|
||||||
|
releaseCanonicalRestorePlaySuppression(playSuppression);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A delayed play() can settle after an even newer command already ran.
|
||||||
|
// Re-assert that newest intent so the old promise cannot win last.
|
||||||
|
if (!destroyed && restorationGeneration !== canonicalMediaApplyGeneration) {
|
||||||
|
await restoreSupersedingLocalState(video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) {
|
function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) {
|
||||||
@@ -1500,7 +1540,12 @@
|
|||||||
const preserveLocalState = message.reason === `local ${EVENTS.PLAY}`
|
const preserveLocalState = message.reason === `local ${EVENTS.PLAY}`
|
||||||
|| message.reason === `local ${EVENTS.PAUSE}`
|
|| message.reason === `local ${EVENTS.PAUSE}`
|
||||||
|| message.reason === `local ${EVENTS.SEEK}`;
|
|| message.reason === `local ${EVENTS.SEEK}`;
|
||||||
cancelCanonicalMediaApply(null, null, preserveLocalState);
|
cancelCanonicalMediaApply(
|
||||||
|
message.action,
|
||||||
|
findVideo(),
|
||||||
|
preserveLocalState,
|
||||||
|
message.payload
|
||||||
|
);
|
||||||
sendResponse({ status: 'cancelled' });
|
sendResponse({ status: 'cancelled' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1552,7 +1597,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (syncActions.includes(action)) {
|
if (syncActions.includes(action)) {
|
||||||
cancelCanonicalMediaApply();
|
cancelCanonicalMediaApply(action, findVideo(), false, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === EVENTS.PLAY) {
|
if (action === EVENTS.PLAY) {
|
||||||
@@ -1863,6 +1908,7 @@
|
|||||||
_clearSuppress(eventState);
|
_clearSuppress(eventState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
|
||||||
|
|
||||||
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
||||||
cancelCanonicalMediaApply(action, video);
|
cancelCanonicalMediaApply(action, video);
|
||||||
@@ -2327,6 +2373,8 @@
|
|||||||
|
|
||||||
for (const timer of Object.values(_suppressTimers)) clearTimeout(timer);
|
for (const timer of Object.values(_suppressTimers)) clearTimeout(timer);
|
||||||
_suppressTimers = {};
|
_suppressTimers = {};
|
||||||
|
for (const hold of canonicalRestorePlaySuppressions) clearTimeout(hold.timeout);
|
||||||
|
canonicalRestorePlaySuppressions.clear();
|
||||||
for (const timer of lifecycleTimeouts) clearTimeout(timer);
|
for (const timer of lifecycleTimeouts) clearTimeout(timer);
|
||||||
lifecycleTimeouts.clear();
|
lifecycleTimeouts.clear();
|
||||||
for (const timer of seekPollTimers) clearInterval(timer);
|
for (const timer of seekPollTimers) clearInterval(timer);
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ describe('episode lobby completion races', () => {
|
|||||||
it('revalidates the lobby after awaiting settings for a local ready', () => {
|
it('revalidates the lobby after awaiting settings for a local ready', () => {
|
||||||
const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||||
const awaitIndex = handler.indexOf('const settings = await getSettings()');
|
const awaitIndex = handler.indexOf('const settings = await getSettings()');
|
||||||
const guardIndex = handler.indexOf('if (episodeLobby !== lobby)');
|
const guardIndex = handler.indexOf('if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId)');
|
||||||
const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)');
|
const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)');
|
||||||
|
|
||||||
expect(handler).toContain('const lobby = episodeLobby');
|
expect(handler).toContain('const lobby = episodeLobby');
|
||||||
|
expect(handler).toContain('const isCurrentLobbyContext = () => episodeLobby === lobby');
|
||||||
expect(awaitIndex).toBeGreaterThan(-1);
|
expect(awaitIndex).toBeGreaterThan(-1);
|
||||||
expect(guardIndex).toBeGreaterThan(awaitIndex);
|
expect(guardIndex).toBeGreaterThan(awaitIndex);
|
||||||
expect(mutationIndex).toBeGreaterThan(guardIndex);
|
expect(mutationIndex).toBeGreaterThan(guardIndex);
|
||||||
@@ -48,4 +49,39 @@ describe('episode lobby completion races', () => {
|
|||||||
expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex);
|
expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex);
|
||||||
expect(execute).toContain('chrome.storage.session.set({ currentRoom })');
|
expect(execute).toContain('chrome.storage.session.set({ currentRoom })');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates restored and authoritative lobby state before using readyPeers', () => {
|
||||||
|
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
|
||||||
|
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
|
||||||
|
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||||
|
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale or unknown ready senders and correlates new ready frames to the lobby', () => {
|
||||||
|
const remoteReady = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:');
|
||||||
|
expect(remoteReady).toContain('const senderPresent = currentRoom?.peers?.some');
|
||||||
|
expect(remoteReady).toContain('!sameEpisode(data.expectedTitle, lobby.expectedTitle)');
|
||||||
|
|
||||||
|
const localReady = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||||
|
expect(localReady).toContain('expectedTitle: lobby.expectedTitle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts authoritative correction data after the relay rejects a competing lobby', () => {
|
||||||
|
const remoteLobby = sourceBetween('case EVENTS.EPISODE_LOBBY:', 'case EVENTS.EPISODE_READY:');
|
||||||
|
expect(remoteLobby).toContain('data.authoritative === true && Array.isArray(data.readyPeers)');
|
||||||
|
expect(remoteLobby).toContain('currentRoom.activeLobby = incomingLobby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only ready peers who still participate in lobby completion', () => {
|
||||||
|
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
|
||||||
|
expect(completion).toContain('const participatingPeerIds = new Set(peers');
|
||||||
|
expect(completion).toContain('participatingPeerIds.has(candidate)');
|
||||||
|
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
|
||||||
|
const desync = sourceBetween("message.type === 'HCM_DESYNC_STATE'", "message.type === 'LEAVE_ROOM'");
|
||||||
|
expect(desync).toContain("cancelEpisodeLobby('Initiator entered solo mode')");
|
||||||
|
expect(desync).toContain('checkEpisodeLobbyCompletion()');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('offline media intent background integration', () => {
|
|||||||
expect(canonicalIndex).toBeLessThan(flushIndex);
|
expect(canonicalIndex).toBeLessThan(flushIndex);
|
||||||
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
||||||
expect(roomData).toContain('desynced: hcmDesynced');
|
expect(roomData).toContain('desynced: hcmDesynced');
|
||||||
expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
||||||
expect(roomData).toContain('authoritativeLobby: !!data.activeLobby');
|
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears queued room intent on failed join, leave and room switch paths', () => {
|
it('clears queued room intent on failed join, leave and room switch paths', () => {
|
||||||
@@ -59,7 +59,7 @@ describe('offline media intent background integration', () => {
|
|||||||
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
|
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
|
||||||
);
|
);
|
||||||
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
|
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
|
||||||
expect(functionBody('endRoomSession', 'leaveRoomAfterIdleGrace')).toContain('forceDisconnect()');
|
expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()');
|
||||||
const retryHandler = backgroundSource.slice(
|
const retryHandler = backgroundSource.slice(
|
||||||
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
|
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
|
||||||
backgroundSource.indexOf("message.type === 'GET_STATUS'")
|
backgroundSource.indexOf("message.type === 'GET_STATUS'")
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ describe('target tab lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('routes every terminal room exit through the full target unhook', () => {
|
it('routes every terminal room exit through the full target unhook', () => {
|
||||||
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
|
const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown');
|
||||||
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
|
const teardownEnd = backgroundSource.indexOf('async function endRoomSession', teardownStart);
|
||||||
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
|
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
|
||||||
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
|
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
|
||||||
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
|
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ try {
|
|||||||
|
|
||||||
// --- Pool: 2 peers in 1 room, test everything ---
|
// --- Pool: 2 peers in 1 room, test everything ---
|
||||||
const rid = 't-'+Date.now();
|
const rid = 't-'+Date.now();
|
||||||
const p1 = await c(), p2 = await c();
|
const p1 = await c(), p2 = await c(), p3 = await c();
|
||||||
|
|
||||||
// Room + join
|
// Room + join
|
||||||
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
|
await j(p1, rid, 'a'); await j(p2, rid, 'b'); await j(p3, rid, 'c');
|
||||||
|
p1._m.length = p2._m.length = p3._m.length = 0;
|
||||||
|
|
||||||
// Relay
|
// Relay
|
||||||
s(p1,'play',{currentTime:10}); await w(p2,'play');
|
s(p1,'play',{currentTime:10}); await w(p2,'play');
|
||||||
@@ -68,7 +69,33 @@ try {
|
|||||||
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
|
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
|
||||||
|
|
||||||
// Lobby
|
// Lobby
|
||||||
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
|
s(p1,'episode_lobby',{expectedTitle:'S01E01'});
|
||||||
|
await w(p2,'episode_lobby'); await w(p3,'episode_lobby');
|
||||||
|
s(p3,'episode_lobby',{expectedTitle:'S01E02'});
|
||||||
|
const authoritativeLobby = await w(p3, 'episode_lobby');
|
||||||
|
assert.equal(authoritativeLobby.authoritative, true,
|
||||||
|
'competing initiator receives an authoritative lobby correction');
|
||||||
|
assert.equal(authoritativeLobby.expectedTitle, 'S01E01');
|
||||||
|
assert.deepEqual(authoritativeLobby.readyPeers, ['a']);
|
||||||
|
let competingLobbyDropped = false;
|
||||||
|
try { await w(p2, 'episode_lobby', 500); } catch { competingLobbyDropped = true; }
|
||||||
|
assert.ok(competingLobbyDropped, 'relay drops a competing lobby while one is active');
|
||||||
|
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01');
|
||||||
|
|
||||||
|
s(p3,'episode_ready',{expectedTitle:'S01E02',title:'S01E02'});
|
||||||
|
let staleReadyDropped = false;
|
||||||
|
try { await w(p2, 'episode_ready', 500); } catch { staleReadyDropped = true; }
|
||||||
|
assert.ok(staleReadyDropped, 'relay drops ready frames for an obsolete lobby');
|
||||||
|
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a']);
|
||||||
|
|
||||||
|
// Missing expectedTitle remains accepted for old-extension compatibility.
|
||||||
|
s(p2,'episode_ready',{title:'S01E01'}); await w(p1,'episode_ready');
|
||||||
|
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a', 'b']);
|
||||||
|
|
||||||
|
s(p3,'leave_room',{}); await w(p1,'peer_status');
|
||||||
|
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01',
|
||||||
|
'an unrelated departure does not dissolve a lobby with two peers left');
|
||||||
|
p1._m.length = p2._m.length = 0;
|
||||||
|
|
||||||
// Leave
|
// Leave
|
||||||
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
||||||
|
|||||||
+36
-2
@@ -284,7 +284,7 @@ function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = t
|
|||||||
// 3.5. Clean up active lobby if a peer leaves
|
// 3.5. Clean up active lobby if a peer leaves
|
||||||
if (room.activeLobby) {
|
if (room.activeLobby) {
|
||||||
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||||
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||||
room.activeLobby = null; // Dissolve lobby
|
room.activeLobby = null; // Dissolve lobby
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +415,7 @@ io.on('connection', (socket) => {
|
|||||||
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
||||||
|
|
||||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||||
|
if (!socket.connected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Protocol check
|
// Protocol check
|
||||||
@@ -450,6 +451,7 @@ io.on('connection', (socket) => {
|
|||||||
let lockPromise = roomCreationLocks.get(roomId);
|
let lockPromise = roomCreationLocks.get(roomId);
|
||||||
if (lockPromise) {
|
if (lockPromise) {
|
||||||
await lockPromise;
|
await lockPromise;
|
||||||
|
if (!socket.connected) return;
|
||||||
room = rooms.get(roomId);
|
room = rooms.get(roomId);
|
||||||
}
|
}
|
||||||
if (!room) {
|
if (!room) {
|
||||||
@@ -511,6 +513,7 @@ io.on('connection', (socket) => {
|
|||||||
let peerLockPromise = peerJoinLocks.get(peerId);
|
let peerLockPromise = peerJoinLocks.get(peerId);
|
||||||
if (peerLockPromise) {
|
if (peerLockPromise) {
|
||||||
await peerLockPromise;
|
await peerLockPromise;
|
||||||
|
if (!socket.connected) return;
|
||||||
room = rooms.get(roomId);
|
room = rooms.get(roomId);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
|
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
|
||||||
@@ -521,6 +524,7 @@ io.on('connection', (socket) => {
|
|||||||
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
|
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
|
||||||
peerJoinLocks.set(peerId, peerLockPromise);
|
peerJoinLocks.set(peerId, peerLockPromise);
|
||||||
try {
|
try {
|
||||||
|
if (!socket.connected) return;
|
||||||
if (!createdByMe) {
|
if (!createdByMe) {
|
||||||
if (room.passwordHash) {
|
if (room.passwordHash) {
|
||||||
if (!password || hashPassword(password) !== room.passwordHash) {
|
if (!password || hashPassword(password) !== room.passwordHash) {
|
||||||
@@ -710,6 +714,36 @@ io.on('connection', (socket) => {
|
|||||||
// Strip undefined keys for clean wire format
|
// Strip undefined keys for clean wire format
|
||||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||||
|
|
||||||
|
// The first live lobby owns the room until completion or
|
||||||
|
// cancellation. Drop concurrent lobby starts and stale ready
|
||||||
|
// frames instead of letting clients build divergent lobbies.
|
||||||
|
if (eventName === EVENTS.EPISODE_LOBBY && room.activeLobby) {
|
||||||
|
log('ROOM', `Dropped competing episode lobby from ${mapping.peerId}`);
|
||||||
|
socket.emit(EVENTS.EPISODE_LOBBY, {
|
||||||
|
senderId: room.activeLobby.initiatorPeerId,
|
||||||
|
peerId: room.activeLobby.initiatorPeerId,
|
||||||
|
expectedTitle: room.activeLobby.expectedTitle,
|
||||||
|
readyPeers: [...room.activeLobby.readyPeers],
|
||||||
|
authoritative: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventName === EVENTS.EPISODE_LOBBY && !relayPayload.expectedTitle) {
|
||||||
|
log('ROOM', `Dropped malformed episode lobby from ${mapping.peerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventName === EVENTS.EPISODE_READY) {
|
||||||
|
if (!room.activeLobby) {
|
||||||
|
log('ROOM', `Dropped stale episode ready from ${mapping.peerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (relayPayload.expectedTitle
|
||||||
|
&& relayPayload.expectedTitle !== room.activeLobby.expectedTitle) {
|
||||||
|
log('ROOM', `Dropped episode ready for an obsolete lobby from ${mapping.peerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mediaStateNow = Date.now();
|
const mediaStateNow = Date.now();
|
||||||
|
|
||||||
// Canonical Media State v1: mutate only after rate limiting,
|
// Canonical Media State v1: mutate only after rate limiting,
|
||||||
@@ -792,7 +826,7 @@ io.on('connection', (socket) => {
|
|||||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||||
|
|
||||||
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
||||||
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
|
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
|
||||||
room.activeLobby = {
|
room.activeLobby = {
|
||||||
expectedTitle: relayPayload.expectedTitle,
|
expectedTitle: relayPayload.expectedTitle,
|
||||||
initiatorPeerId: mapping.peerId,
|
initiatorPeerId: mapping.peerId,
|
||||||
|
|||||||
@@ -570,6 +570,74 @@ test('newer server command clears local recovery state before a delayed apply re
|
|||||||
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('newer remote pause wins after a stale restoration play settles late', async ({ context, extensionId, baseURL }) => {
|
||||||
|
const url = `${baseURL}/pages/simple-player.html`;
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(url);
|
||||||
|
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||||
|
|
||||||
|
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||||
|
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||||
|
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: selectedTabId },
|
||||||
|
world: 'ISOLATED',
|
||||||
|
func: () => {
|
||||||
|
const video = document.querySelector('#player');
|
||||||
|
if (!video) throw new Error('late restoration fixture video missing');
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
const nativePlay = video.play.bind(video);
|
||||||
|
video.dataset.koalaDelayedPlayAttempts = '0';
|
||||||
|
Object.defineProperty(video, 'play', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => {
|
||||||
|
const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
|
||||||
|
video.dataset.koalaDelayedPlayAttempts = String(attempt);
|
||||||
|
if (attempt === 1) {
|
||||||
|
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200)));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => setTimeout(() => {
|
||||||
|
nativePlay().then(resolve, reject);
|
||||||
|
}, 400));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, tabId));
|
||||||
|
|
||||||
|
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
|
||||||
|
revision: 22,
|
||||||
|
playbackState: 'playing',
|
||||||
|
currentTime: 6,
|
||||||
|
updatedBy: 'peer-a'
|
||||||
|
});
|
||||||
|
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||||
|
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1);
|
||||||
|
|
||||||
|
// Capture a superseding play intent while leaving the fixture paused, so
|
||||||
|
// stale recovery has to enter its delayed restoration play path.
|
||||||
|
await page.locator('#player').evaluate(video => {
|
||||||
|
video.pause();
|
||||||
|
});
|
||||||
|
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
|
||||||
|
selectedTabId => chrome.tabs.sendMessage(selectedTabId, {
|
||||||
|
type: 'CANCEL_CANONICAL_MEDIA_STATE',
|
||||||
|
reason: 'local play',
|
||||||
|
action: 'play',
|
||||||
|
payload: { currentTime: 0 }
|
||||||
|
}),
|
||||||
|
tabId
|
||||||
|
));
|
||||||
|
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||||
|
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
|
||||||
|
|
||||||
|
await sendContentServerCommand(context, extensionId, tabId, 'pause');
|
||||||
|
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => {
|
test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => {
|
||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
const relay = await import('../../server/index.js');
|
const relay = await import('../../server/index.js');
|
||||||
|
|||||||
Reference in New Issue
Block a user