fix(sync): harden media state reconnect races

This commit is contained in:
KoalaDev
2026-08-20 22:39:38 +02:00
parent 90050bd9ec
commit a477356d25
12 changed files with 697 additions and 193 deletions
+250 -130
View File
@@ -80,6 +80,7 @@ chrome.runtime.onStartup.addListener(() => {
// --- State Management ---
let socket = null;
let connectionGeneration = 0;
let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
@@ -249,7 +250,7 @@ let pendingHistory = [];
let eventQueue = [];
let eventQueueVersion = 0;
let flushTimer = null; // paces draining of eventQueue after (re)connect
let flushInProgress = false;
let flushInProgress = null;
let isNamespaceJoined = false;
let awaitingRoomData = false;
let pendingRoomDataRoomId = null;
@@ -382,9 +383,11 @@ function ensureState() {
if (!restorationTask) {
restorationTask = new Promise(resolve => {
let resolved = false;
let restorationTimedOut = false;
const done = () => { if (!resolved) { resolved = true; resolve(); } };
const storageTimeout = setTimeout(() => {
restorationTimedOut = true;
addLog('Storage restoration timed out, continuing with defaults', 'warn');
storageInitialized = true;
done();
@@ -399,6 +402,9 @@ function ensureState() {
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced', 'chatActivityTimeline', 'canonicalMediaRecovery'
], (data) => {
// A late callback must not resurrect a room, queue or canonical
// snapshot after the worker already continued with defaults.
if (restorationTimedOut) return;
clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId);
@@ -720,7 +726,8 @@ function resolveServerUrl(settings) {
return (settings.serverUrl && settings.useCustomServer) ? settings.serverUrl : OFFICIAL_SERVER_URL;
}
function forceDisconnect() {
function forceDisconnect({ preserveEventQueue = false } = {}) {
connectionGeneration++;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -755,15 +762,17 @@ function forceDisconnect() {
lastContentHeartbeatAt = null;
forceSyncAcks.clear();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushInProgress = false;
eventQueue = [];
eventQueueVersion++;
flushInProgress = null;
if (!preserveEventQueue) {
eventQueue = [];
eventQueueVersion++;
}
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
expectedAcksCount: 0,
eventQueue: [],
eventQueue,
episodeLobby: null,
roomIdleSince: null,
lastContentHeartbeatAt: null
@@ -1096,6 +1105,8 @@ async function leaveRoomAfterIdleGrace(reason) {
async function connect() {
if (isConnecting) return;
isConnecting = true;
const startingGeneration = connectionGeneration;
let attemptGeneration = startingGeneration;
let finalUrl = '';
try {
@@ -1104,6 +1115,7 @@ async function connect() {
try {
if (!peerId) peerId = await getPeerId();
settings = await getSettings();
if (startingGeneration !== connectionGeneration) return;
pendingRoomDataRoomId = settings.roomId || currentRoom?.roomId || null;
} catch (e) {
throw new Error(`[Storage Error] ${e.message}`);
@@ -1170,114 +1182,125 @@ async function connect() {
url.searchParams.set('version', chrome.runtime.getManifest().version);
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
socket = new WebSocket(url.toString());
const generation = ++connectionGeneration;
attemptGeneration = generation;
const connectionSocket = new WebSocket(url.toString());
socket = connectionSocket;
// --- Phase 5: Event Listeners ---
connectionSocket.onopen = () => {
if (generation !== connectionGeneration || socket !== connectionSocket) return;
reconnectAttempts = 0;
reconnectStartTime = null;
reconnectFailed = false;
addLog('WebSocket Connection Opened', 'success');
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {});
isNamespaceJoined = false;
connectionSocket.send('40');
};
connectionSocket.onmessage = async (event) => {
if (generation !== connectionGeneration || socket !== connectionSocket) return;
await ensureState();
if (generation !== connectionGeneration || socket !== connectionSocket) return;
const msg = event.data;
if (msg === '2') {
connectionSocket.send('3');
return;
}
if (msg.startsWith('0')) {
addLog(`Socket.IO Handshake: ${msg}`, 'info');
} else if (msg.startsWith('40')) {
isConnecting = false;
isNamespaceJoined = true;
broadcastConnectionStatus('connected');
startPing();
addLog('Joined Namespace /', 'success');
const joinedSettings = await getSettings();
if (generation !== connectionGeneration || socket !== connectionSocket) return;
if (joinedSettings.roomId) {
awaitingRoomData = true;
pendingRoomDataRoomId = joinedSettings.roomId;
const sharedTitles = getSharedTitleFields(joinedSettings);
emit(EVENTS.JOIN_ROOM, {
roomId: joinedSettings.roomId,
password: joinedSettings.password,
peerId,
username: joinedSettings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
} else {
awaitingRoomData = false;
pendingRoomDataRoomId = null;
flushEventQueue();
}
} else if (msg.startsWith('42')) {
try {
const payload = JSON.parse(msg.substring(2));
try {
await handleServerEvent(payload[0], payload[1], generation);
} catch (handlerErr) {
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
}
} catch (_e) {
addLog(`Failed to parse message: ${msg}`, 'error');
}
}
};
connectionSocket.onclose = () => {
if (generation !== connectionGeneration || socket !== connectionSocket) return;
// Invalidate any async message handler that began before the
// close event and is still suspended at an await boundary.
connectionGeneration++;
isConnecting = false;
isNamespaceJoined = false;
awaitingRoomData = false;
pendingRoomDataRoomId = null;
invalidateChatSession();
stopPing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (!connectIntent && !currentRoom) {
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
}).catch(() => {});
}
if (currentRoom && !connectIntent) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
socket = null;
if (currentRoom || connectIntent) {
addLog('Disconnected. Scheduling reconnect...', 'warn');
scheduleReconnect();
} else {
addLog('Disconnected. No active session — staying disconnected.', 'info');
}
};
connectionSocket.onerror = () => {
if (generation !== connectionGeneration || socket !== connectionSocket) return;
broadcastConnectionStatus('disconnected');
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
addLog('WebSocket Error: Connection failed', logType);
};
} catch (e) {
throw new Error(`[Connection Error] ${e.message}`);
}
// --- Phase 5: Event Listeners ---
socket.onopen = () => {
reconnectAttempts = 0;
reconnectStartTime = null;
reconnectFailed = false;
addLog('WebSocket Connection Opened', 'success');
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {});
isNamespaceJoined = false;
socket.send('40');
};
socket.onmessage = async (event) => {
await ensureState();
const msg = event.data;
if (msg === '2') {
socket.send('3');
return;
}
if (msg.startsWith('0')) {
addLog(`Socket.IO Handshake: ${msg}`, 'info');
} else if (msg.startsWith('40')) {
isConnecting = false;
isNamespaceJoined = true;
broadcastConnectionStatus('connected');
startPing();
addLog('Joined Namespace /', 'success');
const settings = await getSettings();
if (settings.roomId) {
awaitingRoomData = true;
pendingRoomDataRoomId = settings.roomId;
const sharedTitles = getSharedTitleFields(settings);
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
password: settings.password,
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
} else {
awaitingRoomData = false;
pendingRoomDataRoomId = null;
flushEventQueue();
}
} else if (msg.startsWith('42')) {
try {
const payload = JSON.parse(msg.substring(2));
try {
await handleServerEvent(payload[0], payload[1]);
} catch (handlerErr) {
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
}
} catch (_e) {
addLog(`Failed to parse message: ${msg}`, 'error');
}
}
};
socket.onclose = () => {
isConnecting = false;
isNamespaceJoined = false;
awaitingRoomData = false;
pendingRoomDataRoomId = null;
invalidateChatSession();
stopPing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (!connectIntent && !currentRoom) {
isForceSyncInitiator = false;
forceSyncAcks.clear();
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null
}).catch(() => {});
}
if (currentRoom && !connectIntent) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
if (currentRoom || connectIntent) {
addLog('Disconnected. Scheduling reconnect...', 'warn');
socket = null;
scheduleReconnect();
} else {
addLog('Disconnected. No active session — staying disconnected.', 'info');
socket = null;
}
};
socket.onerror = () => {
broadcastConnectionStatus('disconnected');
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
addLog('WebSocket Error: Connection failed', logType);
};
} catch (e) {
if (attemptGeneration !== connectionGeneration) return;
isConnecting = false;
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
@@ -1517,7 +1540,10 @@ function emit(event, data) {
const mustWaitForRoomData = awaitingRoomData
&& event !== EVENTS.JOIN_ROOM
&& event !== EVENTS.GET_ROOMS;
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && !mustWaitForRoomData) {
if (socket && socket.readyState === WebSocket.OPEN
&& isNamespaceJoined
&& !mustWaitForRoomData
&& !flushInProgress) {
try {
const msg = encodeSocketEvent(event, data, chatSecretGuard);
socket.send(msg);
@@ -1606,24 +1632,49 @@ function applyQueuedRoomPolicy(roomId, policy, reason) {
* Drain logical queue entries only after ROOM_DATA confirms the current room,
* role and lobby. Pacing counts materialized wire frames, not logical entries.
* A two-frame media intent is kept whole at a batch boundary and retained in
* full if either send fails; replaying its first frame again is idempotent and
* preserves the final desired state on the next reconnect.
* full if either send fails. Replaying its first legacy frame can repeat a
* canonical revision/activity side effect, but the ordered full retry remains
* final-state recoverable without a new delivery-ACK protocol.
*/
async function flushEventQueue() {
async function flushEventQueue(replaySettingsOverride = undefined) {
if (flushTimer || flushInProgress || awaitingRoomData) return;
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return;
flushInProgress = true;
const flushToken = {};
const flushConnectionGeneration = connectionGeneration;
const flushSocket = socket;
const flushRoomId = currentRoom?.roomId || null;
flushInProgress = flushToken;
try {
// Resolve privacy settings before taking queue ownership. If new work
// arrives during the drain, keep the original logical entries too; a
// later idempotent replay is safer than losing the concurrent intent.
const replaySettings = eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null;
// arrives during the drain, keep it behind the original logical entries;
// final-state retry is safer than losing concurrent intent.
const replaySettings = replaySettingsOverride !== undefined
? replaySettingsOverride
: (eventQueue.some(isQueuedMediaIntent) ? await getSettings() : null);
if (flushConnectionGeneration !== connectionGeneration
|| socket !== flushSocket
|| currentRoom?.roomId !== flushRoomId
|| awaitingRoomData) {
return;
}
applyQueuedRoomPolicy(flushRoomId, {
canControl: !(controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController()),
activeLobby: !!episodeLobby,
desynced: hcmDesynced,
authoritativeLobby: !!currentRoom?.activeLobby
}, 'Queue replay authority changed');
const drainSource = eventQueue;
const drainVersion = eventQueueVersion;
const result = await drainQueuedBatch(drainSource, {
roomId: currentRoom?.roomId || null,
maxWireEvents: FLUSH_BATCH_SIZE,
sendFrame: async (frame, entry) => {
if (flushConnectionGeneration !== connectionGeneration
|| socket !== flushSocket
|| currentRoom?.roomId !== flushRoomId
|| awaitingRoomData) {
return false;
}
let payload = frame.data && typeof frame.data === 'object' ? { ...frame.data } : {};
if (isQueuedMediaIntent(entry)) {
payload = withTitlePrivacy(payload, replaySettings, ['mediaTitle']);
@@ -1654,14 +1705,19 @@ async function flushEventQueue() {
if (result.sentWireEvents > 0) {
addLog(`Replayed ${result.sentWireEvents} queued wire event${result.sentWireEvents === 1 ? '' : 's'}`, 'info');
}
if (eventQueue.length > 0 && socket?.readyState === WebSocket.OPEN && isNamespaceJoined) {
if (eventQueue.length > 0
&& flushConnectionGeneration === connectionGeneration
&& socket === flushSocket
&& socket?.readyState === WebSocket.OPEN
&& isNamespaceJoined
&& !awaitingRoomData) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushEventQueue().catch(error => addLog(`Queue replay failed: ${error.message}`, 'warn'));
}, FLUSH_BATCH_INTERVAL_MS);
}
} finally {
flushInProgress = false;
if (flushInProgress === flushToken) flushInProgress = null;
}
}
@@ -1739,7 +1795,7 @@ function markCanonicalMediaStateHandled(roomId, revision) {
async function tryApplyPendingCanonicalMediaState() {
const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPending(roomId);
const pending = canonicalMediaStateTracker.getPendingProjected(roomId);
if (!pending || !roomId) return { status: 'none' };
const { mediaState } = pending;
@@ -1756,6 +1812,9 @@ async function tryApplyPendingCanonicalMediaState() {
const tabId = normalizeTabId(currentTabId);
if (tabId === null || currentTargetHasVideo !== true) return { status: 'pending_no_target' };
const targetGeneration = targetActivationGeneration;
const targetFrameId = normalizeFrameId(currentTargetFrameId);
const targetDocumentId = currentTargetDocumentId;
try {
const response = await sendMessageToContentTab(tabId, {
@@ -1763,6 +1822,11 @@ async function tryApplyPendingCanonicalMediaState() {
mediaState
});
if (currentRoom?.roomId !== roomId) return { status: 'stale_room' };
if (!isCurrentTargetIdentity(tabId, targetGeneration)
|| normalizeFrameId(currentTargetFrameId) !== targetFrameId
|| currentTargetDocumentId !== targetDocumentId) {
return { status: 'stale_target' };
}
const latestPending = canonicalMediaStateTracker.getPending(roomId);
if (latestPending?.mediaState.revision !== mediaState.revision) return { status: 'superseded' };
@@ -1821,7 +1885,8 @@ async function handleCanonicalRoomData(data, hasPendingLocalIntent) {
}
// --- Event Handlers ---
async function handleServerEvent(event, data) {
async function handleServerEvent(event, data, expectedConnectionGeneration = connectionGeneration) {
if (expectedConnectionGeneration !== connectionGeneration) return;
if (!data) {
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
return;
@@ -1841,6 +1906,10 @@ async function handleServerEvent(event, data) {
}
switch (event) {
case EVENTS.ROOM_DATA: {
if (pendingRoomDataRoomId && data.roomId !== pendingRoomDataRoomId) {
addLog(`Ignored stale ROOM_DATA for ${data.roomId}`, 'warn');
return;
}
if (currentRoom?.roomId !== data.roomId) {
invalidateChatSession();
clearChatActivity();
@@ -1870,23 +1939,40 @@ async function handleServerEvent(event, data) {
currentRoom.peers = [];
}
// Recover server-tracked active Episode Lobby if present
if (!data?.activeLobby && episodeLobby) {
// ROOM_DATA is authoritative for an already-active server lobby,
// but a locally-created offline lobby has not reached the relay
// yet and must remain owned by its initiator until queued replay.
const hasQueuedLocalLobby = eventQueue.some(entry =>
entry?.event === EVENTS.EPISODE_LOBBY
&& (!entry.roomId || entry.roomId === data.roomId)
);
if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby) {
clearEpisodeLobbyState();
addLog('Discarded stale local Episode Lobby after ROOM_DATA confirmed it ended', 'info');
} else if (data && data.activeLobby && !episodeLobby) {
} else if (data?.activeLobby) {
const sameLobby = episodeLobby
&& episodeLobby.expectedTitle === data.activeLobby.expectedTitle
&& episodeLobby.initiatorPeerId === data.activeLobby.initiatorPeerId;
if (!sameLobby && episodeLobbyTimeout) {
clearTimeout(episodeLobbyTimeout);
episodeLobbyTimeout = null;
}
episodeLobby = {
expectedTitle: data.activeLobby.expectedTitle,
initiatorPeerId: data.activeLobby.initiatorPeerId,
readyPeers: data.activeLobby.readyPeers,
createdAt: Date.now()
createdAt: sameLobby && Number.isFinite(episodeLobby.createdAt)
? episodeLobby.createdAt
: Date.now()
};
persistEpisodeLobby();
broadcastLobbyUpdate();
addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info');
if (!sameLobby) {
addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info');
}
// Notify content script to start polling
if (currentTabId) {
if (!sameLobby && currentTabId) {
const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) {
sendMessageToCurrentContent({
@@ -1908,6 +1994,15 @@ async function handleServerEvent(event, data) {
// Inform Website Bridge & Popup
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
await broadcastJoinStatus(joinStatusMsg);
if (expectedConnectionGeneration !== connectionGeneration) 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
// canonical precedence decision is made.
const replaySettings = eventQueue.some(isQueuedMediaIntent)
? await getSettings()
: null;
if (expectedConnectionGeneration !== connectionGeneration) return;
awaitingRoomData = false;
pendingRoomDataRoomId = null;
@@ -1917,13 +2012,14 @@ async function handleServerEvent(event, data) {
const queuePolicy = applyQueuedRoomPolicy(data.roomId, {
canControl: !lostRoomAuthority,
activeLobby: !!episodeLobby,
desynced: hcmDesynced
desynced: hcmDesynced,
authoritativeLobby: !!data.activeLobby
}, lostRoomAuthority
? 'Host Control role changed while offline'
: (episodeLobby ? 'Active Episode Lobby takes precedence' : 'Reconnect queue policy'));
await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent);
await flushEventQueue();
await flushEventQueue(replaySettings);
break;
}
case EVENTS.CONTROL_MODE:
@@ -2026,6 +2122,10 @@ async function handleServerEvent(event, data) {
case EVENTS.PAUSE:
case EVENTS.SEEK:
case EVENTS.FORCE_SYNC_PREPARE:
if (event === EVENTS.FORCE_SYNC_PREPARE && episodeLobby) {
if (currentRoom) currentRoom.activeLobby = null;
clearEpisodeLobbyState();
}
if (data.senderId && typeof data.seq === 'number') {
const lastSeq = lastSeqBySender[data.senderId];
if (lastSeq !== undefined && data.seq <= lastSeq) {
@@ -2087,6 +2187,10 @@ async function handleServerEvent(event, data) {
}
break;
case EVENTS.FORCE_SYNC_EXECUTE:
if (episodeLobby) {
if (currentRoom) currentRoom.activeLobby = null;
clearEpisodeLobbyState();
}
if (data?.senderId && typeof data.seq === 'number') {
const lastSeq = lastSeqBySender[data.senderId];
if (lastSeq !== undefined && data.seq <= lastSeq) break;
@@ -2228,6 +2332,14 @@ async function handleServerEvent(event, data) {
break;
case EVENTS.EPISODE_LOBBY:
if (data.senderId && data.expectedTitle) {
if (currentRoom) {
currentRoom.activeLobby = {
expectedTitle: data.expectedTitle,
initiatorPeerId: data.senderId,
readyPeers: [data.senderId]
};
if (storageInitialized) chrome.storage.session.set({ currentRoom });
}
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
// If we already have a lobby for this same title, treat as dedup
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) {
@@ -2269,9 +2381,17 @@ async function handleServerEvent(event, data) {
addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info');
checkEpisodeLobbyCompletion();
}
if (currentRoom?.activeLobby) {
currentRoom.activeLobby.readyPeers = [...episodeLobby.readyPeers];
if (storageInitialized) chrome.storage.session.set({ currentRoom });
}
}
break;
case EVENTS.EPISODE_LOBBY_CANCEL:
if (currentRoom) {
currentRoom.activeLobby = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom });
}
if (episodeLobby) {
const title = episodeLobby.expectedTitle;
clearEpisodeLobbyState();
@@ -4078,7 +4198,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
forceDisconnect();
forceDisconnect({ preserveEventQueue: true });
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
@@ -57,6 +57,10 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(backgroundSource).toContain('tryApplyPendingCanonicalMediaState().catch(() => {})');
expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/);
expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4);
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
expect(apply).toContain('getPendingProjected(roomId)');
expect(apply).toContain('targetActivationGeneration');
expect(apply).toContain("return { status: 'stale_target' }");
});
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
@@ -67,7 +71,7 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(roomData).toContain('if (hasPendingLocalIntent)');
expect(backgroundSource).toContain('awaitingRoomData = true');
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
expect(backgroundSource).toContain('await flushEventQueue()');
expect(backgroundSource).toContain('await flushEventQueue(replaySettings)');
});
it('reuses existing seek abstractions, suppression and drift tolerance', () => {
+52 -4
View File
@@ -35,6 +35,22 @@ export function canonicalMediaStateFromRoomData(roomData) {
: { status: 'invalid', mediaState: null };
}
export function projectCanonicalMediaState(mediaState, receivedAt, now = Date.now()) {
const validated = validateCanonicalMediaState(mediaState);
if (!validated) return null;
if (validated.playbackState !== 'playing') return validated;
const received = typeof receivedAt === 'number' && Number.isFinite(receivedAt)
? receivedAt
: now;
return {
...validated,
currentTime: Math.min(
MAX_MEDIA_TIME,
validated.currentTime + Math.max(0, now - received) / 1000
)
};
}
function normalizeRoomId(roomId) {
return typeof roomId === 'string' && roomId ? roomId : null;
}
@@ -60,11 +76,16 @@ export function createCanonicalMediaStateTracker() {
beginRecovery(nextRoomId) {
adoptRoom(nextRoomId);
// A relay restart or an empty-room recreation starts a new in-memory
// revision epoch. ROOM_DATA belongs to this fresh connection, so a
// lower revision is current truth rather than a stale packet from
// the previous epoch.
knownRevision = 0;
appliedRevision = 0;
pending = null;
},
receive(nextRoomId, value) {
receive(nextRoomId, value, receivedAt = Date.now()) {
adoptRoom(nextRoomId);
const mediaState = validateCanonicalMediaState(value);
if (!roomId || !mediaState) return { status: 'invalid' };
@@ -74,7 +95,13 @@ export function createCanonicalMediaStateTracker() {
return { status: 'duplicate' };
}
knownRevision = Math.max(knownRevision, mediaState.revision);
pending = { roomId, mediaState };
pending = {
roomId,
mediaState,
receivedAt: typeof receivedAt === 'number' && Number.isFinite(receivedAt)
? receivedAt
: Date.now()
};
return { status: 'pending', mediaState };
},
@@ -84,6 +111,16 @@ export function createCanonicalMediaStateTracker() {
: null;
},
getPendingProjected(nextRoomId = roomId, now = Date.now()) {
if (!pending || pending.roomId !== normalizeRoomId(nextRoomId)) return null;
const mediaState = projectCanonicalMediaState(
pending.mediaState,
pending.receivedAt,
now
);
return mediaState ? { roomId: pending.roomId, mediaState } : null;
},
markHandled(nextRoomId, revision) {
if (normalizeRoomId(nextRoomId) !== roomId
|| !Number.isSafeInteger(revision)
@@ -114,7 +151,14 @@ export function createCanonicalMediaStateTracker() {
&& restoredPending
&& restoredPending.revision >= appliedRevision
&& restoredPending.revision >= knownRevision) {
pending = { roomId, mediaState: restoredPending };
pending = {
roomId,
mediaState: restoredPending,
receivedAt: typeof value.pending.receivedAt === 'number'
&& Number.isFinite(value.pending.receivedAt)
? value.pending.receivedAt
: Date.now()
};
knownRevision = restoredPending.revision;
}
return true;
@@ -125,7 +169,11 @@ export function createCanonicalMediaStateTracker() {
roomId,
knownRevision,
appliedRevision,
pending: pending ? { roomId: pending.roomId, mediaState: { ...pending.mediaState } } : null
pending: pending ? {
roomId: pending.roomId,
mediaState: { ...pending.mediaState },
receivedAt: pending.receivedAt
} : null
};
}
};
+27
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
canonicalMediaStateFromRoomData,
createCanonicalMediaStateTracker,
projectCanonicalMediaState,
validateCanonicalMediaState
} from './canonical-media-state.js';
@@ -81,6 +82,15 @@ describe('ROOM_DATA capability compatibility', () => {
});
describe('canonical media state tracker', () => {
it('projects a deferred playing snapshot from local receipt time and clamps it', () => {
expect(projectCanonicalMediaState(state(1, 100, 'playing'), 1_000, 31_000))
.toMatchObject({ currentTime: 130, playbackState: 'playing' });
expect(projectCanonicalMediaState(state(1, 100, 'paused'), 1_000, 31_000))
.toMatchObject({ currentTime: 100, playbackState: 'paused' });
expect(projectCanonicalMediaState(state(1, 86_390, 'playing'), 1_000, 31_000).currentTime)
.toBe(86_400);
});
it('accepts a valid snapshot and applies each revision once', () => {
const tracker = createCanonicalMediaStateTracker();
expect(tracker.receive('room-a', state(1)).status).toBe('pending');
@@ -116,6 +126,23 @@ describe('canonical media state tracker', () => {
expect(tracker.getPending().mediaState.currentTime).toBe(100);
});
it('accepts a lower revision from a new relay or room epoch after reconnect', () => {
const tracker = createCanonicalMediaStateTracker();
tracker.receive('room-a', state(12), 1_000);
tracker.markHandled('room-a', 12);
tracker.beginRecovery('room-a');
expect(tracker.receive('room-a', state(1), 2_000).status).toBe('pending');
expect(tracker.getPending('room-a').mediaState.revision).toBe(1);
});
it('persists receipt time so MV3 recovery projects only playing snapshots', () => {
const first = createCanonicalMediaStateTracker();
first.receive('room-a', state(4, 50, 'playing'), 10_000);
const restored = createCanonicalMediaStateTracker();
expect(restored.restore(first.snapshot(), 'room-a')).toBe(true);
expect(restored.getPendingProjected('room-a', 15_000).mediaState.currentTime).toBe(55);
});
it('restores only room-scoped session state', () => {
const first = createCanonicalMediaStateTracker();
first.receive('room-a', state(9));
@@ -16,6 +16,7 @@ describe('offline media intent background integration', () => {
it('keeps online sends immediate and defers reconnect work only while ROOM_DATA is pending', () => {
const emit = functionBody('emit', 'emitLive');
expect(emit).toContain('mustWaitForRoomData');
expect(emit).toContain('&& !flushInProgress');
expect(emit).toContain('socket.send(msg)');
expect(emit).toContain('queueEvent(event, data)');
expect(emit).not.toContain('setTimeout');
@@ -27,6 +28,7 @@ describe('offline media intent background integration', () => {
expect(backgroundSource).toContain('localSeq = Math.max(localSeq, maxQueuedSequence(eventQueue))');
expect(backgroundSource).toContain('chrome.storage.session.set({ eventQueue, localSeq })');
expect(backgroundSource).not.toContain('storage.sync.set({ eventQueue');
expect(backgroundSource).toContain('if (restorationTimedOut) return');
});
it('reconciles Host Control, Episode Lobby and solo mode before canonical recovery and replay', () => {
@@ -36,10 +38,11 @@ describe('offline media intent background integration', () => {
expect(roomData.indexOf('applyQueuedRoomPolicy(data.roomId'))
.toBeLessThan(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'));
expect(roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)'))
.toBeLessThan(roomData.indexOf('flushEventQueue()'));
.toBeLessThan(roomData.indexOf('flushEventQueue(replaySettings)'));
expect(roomData).toContain('activeLobby: !!episodeLobby');
expect(roomData).toContain('desynced: hcmDesynced');
expect(roomData).toContain('if (!data?.activeLobby && episodeLobby)');
expect(roomData).toContain('if (!data?.activeLobby && episodeLobby && !hasQueuedLocalLobby)');
expect(roomData).toContain('authoritativeLobby: !!data.activeLobby');
});
it('clears queued room intent on failed join, leave and room switch paths', () => {
@@ -51,6 +54,11 @@ describe('offline media intent background integration', () => {
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
);
expect(leaveHandler).toContain('forceDisconnect()');
const retryHandler = backgroundSource.slice(
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
backgroundSource.indexOf("message.type === 'GET_STATUS'")
);
expect(retryHandler).toContain('forceDisconnect({ preserveEventQueue: true })');
});
it('paces actual frames through a failure-retaining logical drain', () => {
@@ -61,9 +69,18 @@ describe('offline media intent background integration', () => {
expect(flush).toContain('if (eventQueueVersion === drainVersion)');
expect(flush).toContain('const consumedEntries = new Set(drainSource.slice(0, consumedCount))');
expect(flush).toContain('eventQueue = eventQueue.filter(entry => !consumedEntries.has(entry))');
expect(flush).toContain('flushConnectionGeneration !== connectionGeneration');
expect(flush).not.toMatch(/eventQueue\.shift\(\)[\s\S]*emit\(/);
});
it('generation-scopes socket callbacks and stale ROOM_DATA work', () => {
expect(backgroundSource).toContain('let connectionGeneration = 0');
expect(backgroundSource).toContain('socket !== connectionSocket');
expect(backgroundSource).toContain('handleServerEvent(payload[0], payload[1], generation)');
expect(backgroundSource).toContain('expectedConnectionGeneration !== connectionGeneration');
expect(backgroundSource).toContain('data.roomId !== pendingRoomDataRoomId');
});
it('exposes bounded queue diagnostics without media-title content', () => {
expect(backgroundSource).toContain('queuedLogicalEvents: eventQueue.length');
expect(backgroundSource).toContain('queuedMediaIntents: queuedMediaIntentCount');
+109 -16
View File
@@ -4,6 +4,7 @@ export const MEDIA_INTENT_KIND = 'media-intent';
export const MAX_LOGICAL_QUEUE_SIZE = 50;
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
const HOST_GATED_EVENTS = new Set([
@@ -49,6 +50,22 @@ function trimQueue(queue, maxEntries) {
const trimmed = queue.slice();
let dropped = 0;
while (trimmed.length > maxEntries) {
if (trimmed[0]?.event === EVENTS.FORCE_SYNC_PREPARE) {
const executeIndex = trimmed.findIndex(entry => entry?.event === EVENTS.FORCE_SYNC_EXECUTE);
if (executeIndex >= 0) {
trimmed.splice(0, executeIndex + 1);
dropped += executeIndex + 1;
continue;
}
// Preserve an incomplete oldest Force Sync transaction. Evict the
// next-oldest work until EXECUTE arrives, at which point the whole
// transaction can be evicted atomically if pressure continues.
if (trimmed.length > 1) {
trimmed.splice(1, 1);
dropped++;
continue;
}
}
trimmed.shift();
dropped++;
}
@@ -127,8 +144,16 @@ export function enqueueQueuedEvent(queue, event, data, {
if (STALE_OFFLINE_EVENTS.has(event)) {
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
}
if (!KNOWN_EVENTS.has(event)) {
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
}
if (!isMediaQueueEvent(event)) {
next.push({ event, data });
next.push({
kind: 'event',
roomId: typeof roomId === 'string' && roomId ? roomId : null,
event,
data
});
} else if (typeof roomId === 'string' && roomId) {
const last = next.at(-1);
const hasMergeTarget = isQueuedMediaIntent(last) && last.roomId === roomId;
@@ -177,23 +202,71 @@ function normalizeIntentEntry(entry, roomId) {
};
}
function repairIntentSequences(entry, minimumSequence) {
const repaired = {
...entry,
intent: { ...entry.intent }
};
const hasState = repaired.intent.playbackState !== null;
const hasPosition = repaired.intent.currentTime !== null;
const previousSeq = validSequence(repaired.intent.previousSeq);
const latestSeq = validSequence(repaired.intent.latestSeq);
if (hasState && hasPosition) {
if (latestSeq === null) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
} else if (latestSeq <= minimumSequence) {
if (previousSeq === null) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
} else {
repaired.intent.previousSeq = minimumSequence + 1;
repaired.intent.latestSeq = minimumSequence + 2;
}
} else if (previousSeq !== null
&& (previousSeq >= latestSeq || previousSeq <= minimumSequence)) {
repaired.intent.previousSeq = minimumSequence + 1;
repaired.intent.latestSeq = Math.max(latestSeq, minimumSequence + 2);
}
} else if (latestSeq === null || latestSeq <= minimumSequence) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
}
return repaired;
}
export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOGICAL_QUEUE_SIZE) {
if (!Array.isArray(value) || typeof roomId !== 'string' || !roomId) return [];
let normalized = [];
let maximumSequence = 0;
for (const entry of value) {
if (isQueuedMediaIntent(entry)) {
const intentEntry = normalizeIntentEntry(entry, roomId);
if (intentEntry) normalized.push(intentEntry);
let intentEntry = normalizeIntentEntry(entry, roomId);
if (intentEntry) {
intentEntry = repairIntentSequences(intentEntry, maximumSequence);
normalized.push(intentEntry);
maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry]));
}
continue;
}
if (!entry || typeof entry !== 'object' || typeof entry.event !== 'string') continue;
if (!KNOWN_EVENTS.has(entry.event)) continue;
if (typeof entry.roomId === 'string' && entry.roomId && entry.roomId !== roomId) continue;
if (STALE_OFFLINE_EVENTS.has(entry.event)) continue;
const data = entry.data && typeof entry.data === 'object' ? { ...entry.data } : entry.data;
const queuedSequence = validSequence(data?.seq);
if (data && typeof data === 'object'
&& ((isMediaQueueEvent(entry.event) && queuedSequence === null)
|| (queuedSequence !== null && queuedSequence <= maximumSequence))) {
data.seq = maximumSequence + 1;
}
if (isMediaQueueEvent(entry.event)) {
normalized = enqueueQueuedEvent(normalized, entry.event, entry.data, { roomId, maxEntries }).queue;
normalized = enqueueQueuedEvent(normalized, entry.event, data, { roomId, maxEntries }).queue;
} else {
normalized.push({ event: entry.event, data: entry.data });
normalized.push({ kind: 'event', roomId, event: entry.event, data });
}
normalized = trimQueue(normalized, maxEntries).queue;
maximumSequence = Math.max(maximumSequence, maxQueuedSequence(normalized));
}
return normalized;
}
@@ -229,10 +302,10 @@ export function reserveLatestMediaIntentSequence(queue, roomId, nextSequence) {
return { queue: next, reserved: true };
}
function frameData(intent, seq) {
function frameData(intent, seq, includeActionTimestamp = true) {
const data = {};
if (seq !== null) data.seq = seq;
if (intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp;
if (includeActionTimestamp && intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp;
if (intent.mediaTitle !== null) data.mediaTitle = intent.mediaTitle;
return data;
}
@@ -259,11 +332,11 @@ export function materializeMediaIntent(entry) {
}
if (playbackState === null || currentTime === null) return [];
// Both materialized frames carry the latest genuine action timestamp: it is
// correlation metadata for existing ACK/activity paths, not scheduled wall
// time. A previous-format single PLAY/PAUSE has only one reserved sequence. Keep
// A previous-format single PLAY/PAUSE has only one reserved sequence. Keep
// its original one-frame behavior during migration rather than inventing a
// sequence that could overtake a later transactional barrier.
// sequence that could overtake a later transactional barrier. In a current
// two-frame intent, only the logical final frame carries actionTimestamp so
// its helper frame cannot falsely acknowledge the final user action.
if (previousSeq === null || latestSeq === null || previousSeq >= latestSeq) {
if (intent.latestEvent === EVENTS.SEEK) {
return [{
@@ -279,11 +352,26 @@ export function materializeMediaIntent(entry) {
const seekFrame = {
event: EVENTS.SEEK,
data: { ...frameData(intent, intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq), currentTime, targetTime: currentTime }
data: {
...frameData(
intent,
intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq,
intent.latestEvent === EVENTS.SEEK
),
currentTime,
targetTime: currentTime
}
};
const stateFrame = {
event: stateEvent,
data: { ...frameData(intent, intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq), currentTime }
data: {
...frameData(
intent,
intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq,
intent.latestEvent !== EVENTS.SEEK
),
currentTime
}
};
return intent.latestEvent === EVENTS.SEEK
? [stateFrame, seekFrame]
@@ -320,13 +408,18 @@ export function reconcileQueuedRoomIntent(queue, {
roomId,
canControl = true,
activeLobby = false,
desynced = false
desynced = false,
authoritativeLobby = false
} = {}) {
const source = Array.isArray(queue) ? queue : [];
const blockedEvents = !canControl
? HOST_GATED_EVENTS
: (activeLobby || desynced ? FORCE_SYNC_EVENTS : null);
: new Set([
...(activeLobby || desynced ? FORCE_SYNC_EVENTS : []),
...(authoritativeLobby ? [EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY, EVENTS.EPISODE_LOBBY_CANCEL] : [])
]);
const reconciled = source.filter(entry => {
if (entry?.roomId && entry.roomId !== roomId) return false;
if (isQueuedMediaIntent(entry) && entry.roomId === roomId) {
return canControl && !activeLobby && !desynced;
}
@@ -366,7 +459,7 @@ export async function drainQueuedBatch(queue, {
while (remaining.length > 0) {
const entry = remaining[0];
if (isQueuedMediaIntent(entry) && entry.roomId !== roomId) {
if (entry?.roomId && entry.roomId !== roomId) {
remaining.shift();
droppedStaleIntents++;
continue;
+86 -5
View File
@@ -34,7 +34,7 @@ describe('offline media intent coalescing', () => {
queue = reserve(queue, 6);
expect(queue).toHaveLength(1);
expect(materializeMediaIntent(queue[0])).toEqual([
{ event: EVENTS.SEEK, data: { seq: 5, actionTimestamp: 100, currentTime: 10, targetTime: 10 } },
{ event: EVENTS.SEEK, data: { seq: 5, currentTime: 10, targetTime: 10 } },
{ event: EVENTS.PLAY, data: { seq: 6, actionTimestamp: 100, currentTime: 10 } }
]);
});
@@ -159,6 +159,17 @@ describe('offline media intent coalescing', () => {
expect(maxQueuedSequence(restored)).toBe(4);
});
it('drops room-scoped barriers from another room and unknown persisted events', () => {
const restored = normalizePersistedEventQueue([
{ kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } },
{ kind: 'event', roomId, event: 'unexpected_event', data: { secret: 'nope' } },
{ kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 } }
], roomId);
expect(restored).toEqual([{
kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 }
}]);
});
it('discards stale-room intent without affecting the new room', () => {
const queue = reserve(media(EVENTS.PAUSE, { currentTime: 500, seq: 1 }), 2);
expect(hasQueuedMediaIntent(queue, roomId)).toBe(true);
@@ -180,10 +191,28 @@ describe('offline media intent coalescing', () => {
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 500, seq: 3 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S01E02' }, { roomId }).queue;
const result = reconcileQueuedRoomIntent(queue, { roomId, activeLobby: true });
expect(result.queue).toEqual([{ event: EVENTS.EPISODE_READY, data: { title: 'S01E02' } }]);
expect(result.queue).toEqual([{
kind: 'event',
roomId,
event: EVENTS.EPISODE_READY,
data: { title: 'S01E02' }
}]);
expect(result.hasPendingLocalIntent).toBe(false);
});
it('drops stale queued Episode Lobby coordination when ROOM_DATA has an authoritative lobby', () => {
let queue = enqueueQueuedEvent([], EVENTS.EPISODE_LOBBY, { expectedTitle: 'S02E01' }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S02E01' }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_LOBBY_CANCEL, {}, { roomId }).queue;
const result = reconcileQueuedRoomIntent(queue, {
roomId,
activeLobby: true,
authoritativeLobby: true
});
expect(result.queue).toEqual([]);
expect(result.discarded).toBe(3);
});
it('does not let intentional solo mode retain future room-driving intent', () => {
const queue = reserve(media(EVENTS.SEEK, { targetTime: 600, playbackState: 'paused', seq: 1 }), 2);
const result = reconcileQueuedRoomIntent(queue, { roomId, desynced: true });
@@ -229,7 +258,7 @@ describe('offline media intent drain', () => {
});
it('retains the whole logical intent after a partial send failure', async () => {
const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5 }), 6);
const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5, actionTimestamp: 500 }), 6);
let calls = 0;
const result = await drainQueuedBatch(queue, {
roomId,
@@ -240,6 +269,8 @@ describe('offline media intent drain', () => {
expect(result.sentWireEvents).toBe(1);
expect(result.queue).toEqual(queue);
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.seq)).toEqual([5, 6]);
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.actionTimestamp))
.toEqual([undefined, 500]);
});
it('drops stale-room intent during drain and preserves unrelated events', async () => {
@@ -251,8 +282,58 @@ describe('offline media intent drain', () => {
maxWireEvents: 10,
sendFrame: async frame => { sent.push(frame); return true; }
});
expect(result.droppedStaleIntents).toBe(1);
expect(sent).toEqual([{ event: EVENTS.EPISODE_READY, data: { seq: 3 } }]);
expect(result.droppedStaleIntents).toBe(2);
expect(sent).toEqual([]);
});
it('repairs regressing sequences across malformed persisted intent entries', () => {
const restored = normalizePersistedEventQueue([
{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'playing', currentTime: 10, latestEvent: EVENTS.PLAY,
previousSeq: 99, latestSeq: 100, actionTimestamp: 1, mediaTitle: null, sourceEventCount: 1
}
},
{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
previousSeq: 49, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
}
}
], roomId);
expect(materializeMediaIntent(restored[0]).map(frame => frame.data.seq)).toEqual([99, 100]);
expect(materializeMediaIntent(restored[1]).map(frame => frame.data.seq)).toEqual([101, 102]);
expect(maxQueuedSequence(restored)).toBe(102);
});
it('preserves a valid legacy single-frame intent during sequence repair', () => {
const restored = normalizePersistedEventQueue([{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
previousSeq: null, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
}
}], roomId);
expect(materializeMediaIntent(restored[0])).toEqual([{
event: EVENTS.PAUSE,
data: { seq: 50, actionTimestamp: 2, currentTime: 20 }
}]);
});
it('evicts a complete Force Sync transaction instead of orphaning EXECUTE at the cap', () => {
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
for (let index = 0; index < 49; index++) {
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_ACK, { seq: index + 3 }, { roomId }).queue;
}
expect(queue).toHaveLength(49);
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_PREPARE)).toBe(false);
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false);
});
it('reports logical and actual-wire queue sizes separately', () => {