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
+6 -4
View File
@@ -45,11 +45,13 @@ the room/revision, respects Host Control solo mode and Episode Lobby, then sends
internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path.
That path reuses frame election, Netflix/Disney page-API seeks, native play/pause,
the 2-second drift tolerance, and programmatic-event suppression. The apply is
one-shot recovery: it creates no action history, notification, command ACK, or
relay media event.
one-shot recovery: a pending playing snapshot advances from its local receipt
time while waiting for a target, and the apply creates no action history,
notification, command ACK, or relay media event.
Force Sync remains a two-phase ACK protocol. `PREPARE` is temporary choreography;
the matching `EXECUTE` commits its validated target to canonical state. Per-sender
Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary
room-wide choreography; the next authorized `EXECUTE` commits the latest target
visible to peers to canonical state. Per-sender
`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms.
## 3.2 Offline Media Intent
+10 -6
View File
@@ -119,8 +119,10 @@ Only accepted, sanitized room controls update canonical state:
- `pause` uses its valid `currentTime`, or freezes an existing effective position.
- `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the
established playback state.
- `force_sync_prepare` records only temporary coordination state. Its matching
`force_sync_execute` commits the prepared target as playing.
- a valid `force_sync_prepare` records only temporary coordination state. The
next authorized `force_sync_execute` commits the latest room-wide prepared
target as playing. The latest valid prepare is also the only post-demotion
execute exemption in Host Control mode.
`peer_status` heartbeats are observations and never rewrite canonical intent.
Per-sender `seq` still orders commands from one sender; canonical `revision`
@@ -130,7 +132,8 @@ On join/reconnect, a capable extension applies a valid snapshot once through an
extension-internal recovery message. Existing seek/page-API and native-event
suppression prevent `play`, `pause`, or `seek` echoes. Pending recovery is scoped
to the room/revision in `chrome.storage.session`, waits for the selected media
target lifecycle, and is cleared on leave/switch. Intentional host-only guest
target lifecycle, and projects a still-playing snapshot from its local receipt
time before a delayed apply. It is cleared on leave/switch. Intentional host-only guest
desync and an active Episode Lobby take precedence over snapshot recovery.
Compatibility is additive: new clients use old behavior with a relay that omits
@@ -329,10 +332,11 @@ them with the same sanitized relay envelope as other room events, including
### `force_sync_execute`
The current extension sends sequence/action metadata but no target; the relay uses
the validated target retained from the matching `force_sync_prepare`. In
the latest validated room target retained from `force_sync_prepare`. In
`host-only` mode, only controllers may send it.
The relay also allows a matching initiator's execute event after that initiator
started the prepare step, even if their controller state changed before execute.
The relay also allows that latest valid initiator's execute event after their
controller state changed before execute. Invalid prepares are dropped and grant
no exemption.
## Episode Lobby
+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', () => {
+88 -2
View File
@@ -371,6 +371,35 @@ try {
assert.equal(forceSyncState.playbackState, 'playing');
assert.equal(forceSyncState.currentTime, 700);
// The legacy wire exposes one room-wide prepared target. A later PREPARE
// replaces what every peer has just sought to; any currently authorized
// EXECUTE must commit that visible target instead of clearing it unmatched.
s(msa, 'force_sync_prepare', { targetTime: 800 });
await w(msb, 'force_sync_prepare');
s(msb, 'force_sync_prepare', { targetTime: 900 });
await w(msa, 'force_sync_prepare');
const beforeCompetingExecute = { ...mod.rooms.get(msrid).mediaState };
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
const competingForceState = mod.rooms.get(msrid).mediaState;
assert.equal(competingForceState.revision, beforeCompetingExecute.revision + 1);
assert.equal(competingForceState.currentTime, 900,
'authorized EXECUTE commits the latest target visible to legacy peers');
assert.equal(competingForceState.updatedBy, 'msa');
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_prepare', { targetTime: 1_000 });
await w(msb, 'force_sync_prepare');
s(msb, 'pause', { currentTime: 1_100 });
await w(msa, 'pause');
const supersedingMediaState = { ...mod.rooms.get(msrid).mediaState };
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
let orphanExecuteDropped = false;
try { await w(msb, 'force_sync_execute', 500); } catch { orphanExecuteDropped = true; }
assert.ok(orphanExecuteDropped, 'an action that supersedes PREPARE makes delayed EXECUTE invalid');
assert.deepEqual(mod.rooms.get(msrid).mediaState, supersedingMediaState);
// Active Episode Lobby is additive ROOM_DATA state and does not rewrite mediaState.
s(msa, 'episode_lobby', { expectedTitle: 'S02E03' });
await delay(40);
@@ -675,6 +704,63 @@ try {
close();
resetConnectionRate();
// A valid PREPARE remains executable if the room changes from everyone to
// host-only before EXECUTE. An invalid PREPARE grants no such exemption.
const transitionRid = 'force-transition-'+Date.now();
const transitionHost = await c(), transitionGuest = await c();
await j(transitionHost, transitionRid, 'transition-host');
await j(transitionGuest, transitionRid, 'transition-guest');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_prepare', { targetTime: 321 });
await w(transitionHost, 'force_sync_prepare');
s(transitionHost, 'set_control_mode', { controlMode: 'host-only' });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_execute', {});
await w(transitionHost, 'force_sync_execute');
assert.equal(mod.rooms.get(transitionRid).mediaState.currentTime, 321);
await delay(550);
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: true });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_prepare', { targetTime: 'invalid' });
let invalidPrepareRelayed = false;
try { await w(transitionHost, 'force_sync_prepare', 500); } catch { invalidPrepareRelayed = true; }
assert.ok(invalidPrepareRelayed, 'invalid PREPARE is not relayed');
await delay(550);
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: false });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_execute', {});
let invalidExecuteGated = false;
try { await w(transitionHost, 'force_sync_execute', 500); } catch { invalidExecuteGated = true; }
assert.ok(invalidExecuteGated, 'invalid PREPARE grants no post-demotion EXECUTE exemption');
close();
resetConnectionRate();
// A transient disconnect clears the Host Control exemption but not the
// validated room target. If the peer rejoins with current authority, its
// recovered EXECUTE must keep live playback and canonical state aligned.
const forceReconnectRid = 'force-reconnect-'+Date.now();
const forceReconnectHost = await c(), forceReconnectPeer = await c();
await j(forceReconnectHost, forceReconnectRid, 'force-host');
await j(forceReconnectPeer, forceReconnectRid, 'force-peer');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 });
await w(forceReconnectHost, 'force_sync_prepare');
forceReconnectPeer.close();
await delay(100);
const forceReconnectReplacement = await c();
await j(forceReconnectReplacement, forceReconnectRid, 'force-peer');
forceReconnectHost._m.length = forceReconnectReplacement._m.length = 0;
s(forceReconnectReplacement, 'force_sync_execute', {});
await w(forceReconnectHost, 'force_sync_execute');
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.currentTime, 444);
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.playbackState, 'playing');
close();
resetConnectionRate();
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
const grid = 'h1b-'+Date.now();
const go = await c(), gg = await c();
@@ -755,12 +841,12 @@ try {
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
// New → old
mxo._m.length = mxn._m.length = 0;
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
close();
resetConnectionRate();
+42 -21
View File
@@ -286,10 +286,10 @@ function removePeerFromRoom(socketId, roomId, reason) {
if (peerGone && room.controllers && room.peers.size > 0) {
const wasController = room.controllers.has(peerId);
room.controllers.delete(peerId);
// H-1: a leaving initiator strands the room's force-sync — release the
// slot so a future controller's PREPARE can take over cleanly.
// Release the post-demotion exemption, but retain the validated target:
// an authorized initiator may reconnect and finish the already-visible
// choreography. A newer PREPARE still replaces it normally.
if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null;
if (room.forceSyncTarget?.initiatorPeerId === peerId) room.forceSyncTarget = null;
if (room.hostPeerId === peerId) {
// Owner left → reassign owner + fall back to 'everyone' so the room is
// never stuck locked, and reset the controller set to just the new owner.
@@ -596,15 +596,10 @@ io.on('connection', (socket) => {
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
//
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
// land — otherwise their already-relayed PREPARE would leave the whole
// room stuck paused. Track the in-flight initiator on PREPARE and let
// their matching EXECUTE through regardless of current controllers set.
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
room.controlMode === CONTROL_MODES.HOST_ONLY &&
room.controllers && room.controllers.has(mapping.peerId)) {
room.forceSyncInitiator = mapping.peerId;
}
// H-1 exception: the latest valid PREPARE initiator's
// FORCE_SYNC_EXECUTE still has to land after demotion —
// otherwise the already-relayed room-wide choreography
// would leave peers paused.
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
if (!isOwnForceSyncExecute &&
@@ -614,11 +609,6 @@ io.on('connection', (socket) => {
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
return;
}
// Clear initiator tracking once the EXECUTE has been relayed.
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
room.forceSyncInitiator = null;
}
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
@@ -661,6 +651,23 @@ io.on('connection', (socket) => {
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && !room.forceSyncTarget) {
log('ROOM', `Dropped force_sync_execute without a prepared target from ${mapping.peerId}`);
room.forceSyncInitiator = null;
return;
}
if (eventName === EVENTS.PLAY
|| eventName === EVENTS.PAUSE
|| eventName === EVENTS.SEEK
|| eventName === EVENTS.EPISODE_LOBBY
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL) {
// A later room-driving action supersedes unfinished Force
// Sync choreography. Do not let a delayed EXECUTE commit
// an obsolete target after peers have moved elsewhere.
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
}
// Canonical Media State v1: mutate only after rate limiting,
// room mapping, Host Control authorization and sanitization.
// Heartbeats remain observational and never enter this path.
@@ -670,12 +677,25 @@ io.on('connection', (socket) => {
senderPlaybackState: existing.playbackState
});
if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
room.forceSyncTarget = Number.isFinite(relayPayload.targetTime)
? { initiatorPeerId: mapping.peerId, targetTime: relayPayload.targetTime }
: null;
// A malformed PREPARE must neither pause peers nor grant
// the initiator a later Host Control EXECUTE exemption.
if (!Number.isFinite(relayPayload.targetTime)) {
log('ROOM', `Dropped invalid force_sync_prepare from ${mapping.peerId}`);
return;
}
// One room-wide choreography is visible on the legacy
// wire. A newer PREPARE replaces the target every peer
// most recently received. Track its initiator in every
// control mode so an everyone -> host-only transition
// cannot strand that already-authorized transaction.
room.forceSyncInitiator = mapping.peerId;
room.forceSyncTarget = {
initiatorPeerId: mapping.peerId,
targetTime: relayPayload.targetTime
};
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
if (forceSyncTarget?.initiatorPeerId === mapping.peerId) {
if (forceSyncTarget) {
commitForceSyncMediaState(
room,
forceSyncTarget.targetTime,
@@ -683,6 +703,7 @@ io.on('connection', (socket) => {
mediaStateNow
);
}
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
}
+3 -2
View File
@@ -1182,10 +1182,11 @@ test('coalesces persisted offline media intent before canonical reconnect recove
expect(restoredQueue.queuedLogicalEvents).toBeGreaterThanOrEqual(1);
expect(restoredQueue.queuedWireEvents).toBeGreaterThanOrEqual(2);
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => {
const retryResult = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => {
await chrome.storage.local.set({ serverUrl });
chrome.alarms.create('keepAlive', { when: Date.now() + 50 });
return chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}, `ws://127.0.0.1:${port}`));
expect(retryResult).toMatchObject({ status: 'ok' });
await page.locator('#player').evaluate(video => {
window.__koalaReconnectSeeks = [];