mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +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()');
|
||||
}
|
||||
});
|
||||
});
|
||||
+299
-85
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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())'))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user