fix(sync): harden async session and lobby races

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