fix(sync): close canonical recovery merge blockers

This commit is contained in:
Timo
2026-08-25 22:24:37 +02:00
parent 213a639314
commit 0d23859a9f
14 changed files with 484 additions and 87 deletions
+14 -4
View File
@@ -41,8 +41,10 @@ not. Heartbeats remain observational and do not mutate canonical state.
`ROOM_DATA` materializes the playing position at snapshot creation and advertises `ROOM_DATA` materializes the playing position at snapshot creation and advertises
the optional `media-state-v1` capability. A joining/reconnecting client validates the optional `media-state-v1` capability. A joining/reconnecting client validates
the room/revision, respects Host Control solo mode and Episode Lobby, then sends an the room/revision and optional privacy-sanitized media title, respects Host Control
internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path. solo mode and Episode Lobby, then queues an internal
`APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer
live commands.
That path reuses frame election, Netflix/Disney page-API seeks, native play/pause, That path reuses frame election, Netflix/Disney page-API seeks, native play/pause,
the 2-second drift tolerance, and programmatic-event suppression. Recovery only the 2-second drift tolerance, and programmatic-event suppression. Recovery only
completes after playback state and position verification. Transient failures completes after playback state and position verification. Transient failures
@@ -52,10 +54,18 @@ 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 the apply creates no action history, notification, command ACK, or relay media
event. event.
Current clients announce `media-state-v1` as an optional client capability and
continue sending accepted media controls while alone on a capable relay. If a
room instead falls back to one legacy client that suppresses solo controls, the
relay clears canonical state so a future joiner receives no snapshot rather than
known-unreliable playback truth.
Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary
room-wide choreography; the next authorized `EXECUTE` commits the latest target room-wide choreography; the next authorized `EXECUTE` commits the latest target
visible to peers to canonical state before the relay target TTL. That TTL is visible to peers to canonical state. Delayed execution is logged but remains
longer than the client ACK timeout so its scheduled fallback can still land. The valid until newer accepted playback or lobby state explicitly supersedes it; an
untracked post-restart execute retains legacy relay liveness without inventing a
canonical target. The
offline queue replays an adjacent `PREPARE`/`EXECUTE` pair in one paced batch and offline queue replays an adjacent `PREPARE`/`EXECUTE` pair in one paced batch and
retains both if delivery fails. Per-sender retains both if delivery fails. Per-sender
`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. The `seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. The
+20 -12
View File
@@ -54,7 +54,7 @@ Payload:
"password": "string, max 128, optional", "password": "string, max 128, optional",
"tabTitle": "string, max 100, optional", "tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional", "mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"], "clientCapabilities": ["chat-v1", "media-state-v1"],
"protocolVersion": "string, max 16" "protocolVersion": "string, max 16"
} }
``` ```
@@ -110,8 +110,9 @@ The internal `currentTime` is the media position at server-owned `updatedAt`.
Playing state advances lazily when a snapshot is requested; paused state stays Playing state advances lazily when a snapshot is requested; paused state stays
fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot
spoof them. The wire snapshot contains the already-projected `currentTime`, spoof them. The wire snapshot contains the already-projected `currentTime`,
`revision`, `playbackState`, and `updatedBy`, so clients never compare client and `revision`, `playbackState`, `updatedBy`, and an optional privacy-sanitized
server wall clocks. `mediaTitle`, so clients never compare client and server wall clocks and retain
the existing cross-episode guard during recovery.
Only accepted, sanitized room controls update canonical state: Only accepted, sanitized room controls update canonical state:
@@ -121,11 +122,12 @@ Only accepted, sanitized room controls update canonical state:
established playback state. established playback state.
- a valid `force_sync_prepare` records only temporary coordination state. The - a valid `force_sync_prepare` records only temporary coordination state. The
next authorized `force_sync_execute` commits the latest room-wide prepared next authorized `force_sync_execute` commits the latest room-wide prepared
target as playing before `FORCE_SYNC_TARGET_TTL` expires. This relay TTL is target as playing. A target older than `FORCE_SYNC_TARGET_DELAY_WARNING` is
intentionally longer than the client's `FORCE_SYNC_TIMEOUT` ACK wait so the logged but remains executable until newer room playback supersedes it, because
normal timeout fallback remains deliverable. Expired targets are cleared and receivers are already paused. An execute without retained target still uses
cannot alter canonical state. The latest valid prepare is also the only the legacy wire fallback after relay restart but cannot invent canonical state.
post-demotion execute exemption in Host Control mode. The latest valid prepare is also the only post-demotion or reconnect execute
exemption in Host Control mode.
`peer_status` heartbeats are observations and never rewrite canonical intent. `peer_status` heartbeats are observations and never rewrite canonical intent.
For current clients, the relay drops invalid, duplicate, or regressing `seq` For current clients, the relay drops invalid, duplicate, or regressing `seq`
@@ -152,7 +154,12 @@ relaying the established event names, payloads, and order unchanged. This makes
server-first rollout safe: old clients populate recovery state without needing to server-first rollout safe: old clients populate recovery state without needing to
understand or acknowledge it, and new clients consume it only when the relay understand or acknowledge it, and new clients consume it only when the relay
advertises the capability. No protocol-version or minimum-version bump is advertises the capability. No protocol-version or minimum-version bump is
required. Offline `play`/`pause`/`seek` compaction remains the separate required. New clients also announce `"media-state-v1"` in optional
`join_room.clientCapabilities`; this only tells a capable relay that the client
keeps canonical state current while alone. When a room falls back to one legacy
client, the relay clears potentially stale canonical state instead of recovering
future joiners to unverified solo playback. Offline `play`/`pause`/`seek`
compaction remains the separate
client-owned layer described below rather than part of the relay capability. client-owned layer described below rather than part of the relay capability.
### Offline media intent ### Offline media intent
@@ -347,9 +354,10 @@ The current extension sends sequence/action metadata but no target; the relay us
the latest validated room target retained from `force_sync_prepare`. In the latest validated room target retained from `force_sync_prepare`. In
`host-only` mode, only controllers may send it. `host-only` mode, only controllers may send it.
The relay also allows that latest valid initiator's execute event after their The relay also allows that latest valid initiator's execute event after their
controller state changed before execute. Invalid prepares are dropped and grant controller state changed or their socket reconnected before execute. Invalid
no exemption. The retained target expires after `FORCE_SYNC_TARGET_TTL`, which prepares are dropped and grant no exemption. Newer accepted playback/lobby state
includes a relay grace period beyond the client's ACK timeout. explicitly supersedes the prepared target, so its delayed execute is dropped.
Otherwise, even a delayed execute is relayed to release paused receivers.
## Episode Lobby ## Episode Lobby
+61 -5
View File
@@ -288,7 +288,10 @@ function serverSupports(cap) { return Array.isArray(serverCapabilities) && serve
function serverSupportsChat() { function serverSupportsChat() {
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT); return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
} }
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]); const CLIENT_CAPABILITIES = Object.freeze([
CAPABILITIES.CHAT_V1,
CAPABILITIES.MEDIA_STATE_V1
]);
function persistCanonicalMediaRecovery() { function persistCanonicalMediaRecovery() {
if (!storageInitialized) return; if (!storageInitialized) return;
@@ -1880,6 +1883,26 @@ function markCanonicalMediaStateHandled(roomId, revision) {
return true; return true;
} }
function supersedeCanonicalMediaRecovery(reason) {
const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPending(roomId);
if (!pending || !markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)) {
return false;
}
addLog(`Canonical media state r${pending.mediaState.revision} superseded by ${reason}`, 'info');
return true;
}
function isCanonicalSupersedingControl(event, data) {
if (event === EVENTS.SEEK) {
return Number.isFinite(data?.targetTime) || Number.isFinite(data?.currentTime);
}
if (event === EVENTS.FORCE_SYNC_PREPARE) {
return Number.isFinite(data?.targetTime);
}
return event === EVENTS.PLAY || event === EVENTS.PAUSE || event === EVENTS.FORCE_SYNC_EXECUTE;
}
async function performPendingCanonicalMediaStateApply() { async function performPendingCanonicalMediaStateApply() {
const roomId = currentRoom?.roomId; const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPendingProjected(roomId); const pending = canonicalMediaStateTracker.getPendingProjected(roomId);
@@ -1904,9 +1927,21 @@ async function performPendingCanonicalMediaStateApply() {
const targetDocumentId = currentTargetDocumentId; const targetDocumentId = currentTargetDocumentId;
try { try {
const response = await sendMessageToContentTab(tabId, { const response = await enqueueContentCommand(async () => {
type: 'APPLY_CANONICAL_MEDIA_STATE', if (currentRoom?.roomId !== roomId) return { status: 'stale_room' };
mediaState if (!isCurrentTargetIdentity(tabId, targetGeneration)
|| normalizeFrameId(currentTargetFrameId) !== targetFrameId
|| currentTargetDocumentId !== targetDocumentId) {
return { status: 'stale_target' };
}
const latest = canonicalMediaStateTracker.getPending(roomId);
if (latest?.mediaState.revision !== mediaState.revision) {
return { status: 'superseded' };
}
return sendMessageToContentTab(tabId, {
type: 'APPLY_CANONICAL_MEDIA_STATE',
mediaState
});
}); });
if (currentRoom?.roomId !== roomId) return { status: 'stale_room' }; if (currentRoom?.roomId !== roomId) return { status: 'stale_room' };
if (!isCurrentTargetIdentity(tabId, targetGeneration) if (!isCurrentTargetIdentity(tabId, targetGeneration)
@@ -1924,6 +1959,9 @@ async function performPendingCanonicalMediaStateApply() {
} else if (response?.status === 'ignored_desynced') { } else if (response?.status === 'ignored_desynced') {
markCanonicalMediaStateHandled(roomId, mediaState.revision); markCanonicalMediaStateHandled(roomId, mediaState.revision);
addLog(`Canonical media state r${mediaState.revision} skipped: content is desynced`, 'info'); addLog(`Canonical media state r${mediaState.revision} skipped: content is desynced`, 'info');
} else if (response?.status === 'ignored_episode_mismatch') {
markCanonicalMediaStateHandled(roomId, mediaState.revision);
addLog(`Canonical media state r${mediaState.revision} skipped: content is on a different episode`, 'info');
} else if (response?.status === 'invalid') { } else if (response?.status === 'invalid') {
markCanonicalMediaStateHandled(roomId, mediaState.revision); markCanonicalMediaStateHandled(roomId, mediaState.revision);
addLog(`Canonical media state r${mediaState.revision} rejected by content validation`, 'warn'); addLog(`Canonical media state r${mediaState.revision} rejected by content validation`, 'warn');
@@ -2246,6 +2284,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
lastSeqBySender[data.senderId] = data.seq; lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq(); _persistLastSeq();
} }
if (isCanonicalSupersedingControl(event, data)) {
supersedeCanonicalMediaRecovery(`newer ${event}`);
}
if (data.senderId) { if (data.senderId) {
addToHistory(event, data.senderId); addToHistory(event, data.senderId);
showNotification(data.senderId, event); showNotification(data.senderId, event);
@@ -2308,6 +2349,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
lastSeqBySender[data.senderId] = data.seq; lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq(); _persistLastSeq();
} }
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (data?.senderId) { if (data?.senderId) {
addToHistory(event, data.senderId); addToHistory(event, data.senderId);
showNotification(data.senderId, event); showNotification(data.senderId, event);
@@ -2443,6 +2485,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
break; break;
case EVENTS.EPISODE_LOBBY: case EVENTS.EPISODE_LOBBY:
if (data.senderId && data.expectedTitle) { if (data.senderId && data.expectedTitle) {
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (currentRoom) { if (currentRoom) {
currentRoom.activeLobby = { currentRoom.activeLobby = {
expectedTitle: data.expectedTitle, expectedTitle: data.expectedTitle,
@@ -2499,6 +2542,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
} }
break; break;
case EVENTS.EPISODE_LOBBY_CANCEL: case EVENTS.EPISODE_LOBBY_CANCEL:
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (currentRoom) { if (currentRoom) {
currentRoom.activeLobby = null; currentRoom.activeLobby = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom }); if (storageInitialized) chrome.storage.session.set({ currentRoom });
@@ -2531,6 +2575,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
} }
function executeForceSync() { function executeForceSync() {
supersedeCanonicalMediaRecovery('local force_sync_execute');
if (forceSyncTimeout) clearTimeout(forceSyncTimeout); if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
isForceSyncInitiator = false; isForceSyncInitiator = false;
forceSyncAcks.clear(); forceSyncAcks.clear();
@@ -2604,6 +2649,7 @@ function clearEpisodeLobbyState() {
function cancelEpisodeLobby(reason) { function cancelEpisodeLobby(reason) {
if (!episodeLobby) return; if (!episodeLobby) return;
const title = episodeLobby.expectedTitle; const title = episodeLobby.expectedTitle;
supersedeCanonicalMediaRecovery('local episode_lobby_cancel');
// Broadcast cancellation to room // Broadcast cancellation to room
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId }); emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
@@ -2733,6 +2779,10 @@ function routeToContent(action, payload) {
commandSenderId, commandSenderId,
0 0
); );
return enqueueContentCommand(deliver);
}
function enqueueContentCommand(deliver) {
const queued = contentCommandQueue.catch(() => {}).then(deliver); const queued = contentCommandQueue.catch(() => {}).then(deliver);
contentCommandQueue = queued; contentCommandQueue = queued;
return queued; return queued;
@@ -4824,6 +4874,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
payload.targetTime = targetTime; payload.targetTime = targetTime;
} }
if (isCanonicalSupersedingControl(message.action, payload)) {
supersedeCanonicalMediaRecovery(`local ${message.action}`);
}
const timestamp = Date.now(); const timestamp = Date.now();
localSeq++; localSeq++;
chrome.storage.session.set({ localSeq }); chrome.storage.session.set({ localSeq });
@@ -4883,7 +4936,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendChatActivity(message.action, peerId, timestamp); sendChatActivity(message.action, peerId, timestamp);
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK; const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
if (isNonEssentialEvent && !hasOtherPeers) { if (isNonEssentialEvent
&& !hasOtherPeers
&& !serverSupports(CAPABILITIES.MEDIA_STATE_V1)) {
sendResponse({ status: 'ok_solo' }); sendResponse({ status: 'ok_solo' });
return; return;
} }
@@ -5159,6 +5214,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (episodeLobby) clearEpisodeLobbyState(); if (episodeLobby) clearEpisodeLobbyState();
// Create new lobby // Create new lobby
supersedeCanonicalMediaRecovery('local episode_lobby');
episodeLobby = { episodeLobby = {
expectedTitle: lobbyTitle, expectedTitle: lobbyTitle,
initiatorPeerId: peerId, initiatorPeerId: peerId,
@@ -22,6 +22,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(handler).toContain("canonicalSnapshot.status === 'empty'"); expect(handler).toContain("canonicalSnapshot.status === 'empty'");
expect(handler.indexOf('canonicalMediaStateFromRoomData(data)')) expect(handler.indexOf('canonicalMediaStateFromRoomData(data)'))
.toBeLessThan(handler.indexOf('canonicalMediaStateTracker.receive')); .toBeLessThan(handler.indexOf('canonicalMediaStateTracker.receive'));
expect(backgroundSource).toMatch(/CLIENT_CAPABILITIES[\s\S]{0,160}CAPABILITIES\.MEDIA_STATE_V1/);
expect(backgroundSource).toContain('!serverSupports(CAPABILITIES.MEDIA_STATE_V1)');
}); });
it('keeps legacy PLAY/PAUSE/SEEK, Force Sync, Episode Lobby, and Host Control handlers independent of canonical recovery', () => { it('keeps legacy PLAY/PAUSE/SEEK, Force Sync, Episode Lobby, and Host Control handlers independent of canonical recovery', () => {
@@ -39,6 +41,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
it('uses a dedicated internal apply message without action/history/ACK machinery', () => { it('uses a dedicated internal apply message without action/history/ACK machinery', () => {
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState'); const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'"); expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'");
expect(apply).toContain('enqueueContentCommand(async () =>');
expect(apply).toContain('canonicalMediaStateTracker.getPending(roomId)');
expect(apply).not.toContain('routeToContent('); expect(apply).not.toContain('routeToContent(');
expect(apply).not.toContain('emit('); expect(apply).not.toContain('emit(');
expect(apply).not.toContain('addToHistory('); expect(apply).not.toContain('addToHistory(');
@@ -81,6 +85,17 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(backgroundSource).toContain('await flushEventQueue(replaySettings)'); expect(backgroundSource).toContain('await flushEventQueue(replaySettings)');
}); });
it('supersedes pending recovery only after newer local or accepted remote room control', () => {
const supersede = functionBody(backgroundSource, 'supersedeCanonicalMediaRecovery', 'performPendingCanonicalMediaStateApply');
expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)');
expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)');
expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)');
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)');
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)');
expect(backgroundSource.indexOf("sendResponse({ status: 'blocked_host_only' })"))
.toBeLessThan(backgroundSource.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)'));
});
it('awaits media actions and verifies playback plus drift before acknowledging recovery', () => { it('awaits media actions and verifies playback plus drift before acknowledging recovery', () => {
const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady'); const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady');
expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA'); expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA');
@@ -92,6 +107,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(apply.indexOf("status: 'applied'")) expect(apply.indexOf("status: 'applied'"))
.toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)')); .toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)'));
expect(apply).toContain('if (hcmDesynced)'); expect(apply).toContain('if (hcmDesynced)');
expect(apply).toContain('isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)');
expect(apply).toContain("status: 'ignored_episode_mismatch'");
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'"); const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart); const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
expect(contentSource.slice(contentHandlerStart, serverCommandStart)) expect(contentSource.slice(contentHandlerStart, serverCommandStart))
+8
View File
@@ -10,6 +10,11 @@ export function validateCanonicalMediaState(value) {
|| value.currentTime > MAX_MEDIA_TIME) { || value.currentTime > MAX_MEDIA_TIME) {
return null; return null;
} }
if (value.mediaTitle !== undefined
&& value.mediaTitle !== null
&& typeof value.mediaTitle !== 'string') {
return null;
}
const normalized = { const normalized = {
revision: value.revision, revision: value.revision,
playbackState: value.playbackState, playbackState: value.playbackState,
@@ -18,6 +23,9 @@ export function validateCanonicalMediaState(value) {
if (typeof value.updatedBy === 'string' && value.updatedBy) { if (typeof value.updatedBy === 'string' && value.updatedBy) {
normalized.updatedBy = value.updatedBy.substring(0, 16); normalized.updatedBy = value.updatedBy.substring(0, 16);
} }
if (typeof value.mediaTitle === 'string' && value.mediaTitle) {
normalized.mediaTitle = value.mediaTitle.substring(0, 100);
}
return normalized; return normalized;
} }
+8
View File
@@ -18,6 +18,14 @@ describe('canonical media state validation', () => {
expect(validateCanonicalMediaState(state(2))).toEqual(state(2)); expect(validateCanonicalMediaState(state(2))).toEqual(state(2));
}); });
it('preserves an optional bounded media title and rejects invalid title types', () => {
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'Series S01E02' }))
.toMatchObject({ mediaTitle: 'Series S01E02' });
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'x'.repeat(120) }).mediaTitle)
.toHaveLength(100);
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 42 })).toBeNull();
});
it.each([ it.each([
null, null,
[], [],
+9 -1
View File
@@ -1270,10 +1270,18 @@
|| typeof mediaState.currentTime !== 'number' || typeof mediaState.currentTime !== 'number'
|| !Number.isFinite(mediaState.currentTime) || !Number.isFinite(mediaState.currentTime)
|| mediaState.currentTime < 0 || mediaState.currentTime < 0
|| mediaState.currentTime > MAX_MEDIA_TIME) { || mediaState.currentTime > MAX_MEDIA_TIME
|| (mediaState.mediaTitle !== undefined
&& mediaState.mediaTitle !== null
&& typeof mediaState.mediaTitle !== 'string')) {
return { status: 'invalid' }; return { status: 'invalid' };
} }
if (hcmDesynced) return { status: 'ignored_desynced' }; if (hcmDesynced) return { status: 'ignored_desynced' };
const localMediaTitle = getMediaTitle();
if (_autoSyncEnabled && isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)) {
reportLog(`Canonical media state ignored: sender="${mediaState.mediaTitle || '?'}" vs mine="${localMediaTitle || '?'}"`, 'warn');
return { status: 'ignored_episode_mismatch' };
}
const video = findVideo(); const video = findVideo();
if (!video) return { status: 'no_video' }; if (!video) return { status: 'no_video' };
+73 -17
View File
@@ -10,7 +10,7 @@ import {
materializeMediaIntent, materializeMediaIntent,
reserveLatestMediaIntentSequence reserveLatestMediaIntentSequence
} from '../extension/offline-media-intent.js'; } from '../extension/offline-media-intent.js';
import { FORCE_SYNC_TARGET_TTL, FORCE_SYNC_TIMEOUT } from '../shared/constants.js'; import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -83,7 +83,7 @@ try {
const coalescedRid = 'coalesced-media-'+Date.now(); const coalescedRid = 'coalesced-media-'+Date.now();
const legacyReceiver = await c(), coalescingSender = await c(); const legacyReceiver = await c(), coalescingSender = await c();
await j(legacyReceiver, coalescedRid, 'legacy-receiver'); await j(legacyReceiver, coalescedRid, 'legacy-receiver');
await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1']); await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1', 'media-state-v1']);
legacyReceiver._m.length = coalescingSender._m.length = 0; legacyReceiver._m.length = coalescingSender._m.length = 0;
s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 }); s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 });
@@ -175,12 +175,13 @@ try {
// --- Mixed-version rollout: pre-media-state extension + current extension --- // --- Mixed-version rollout: pre-media-state extension + current extension ---
// Legacy intentionally omits clientCapabilities entirely and uses only the // Legacy intentionally omits clientCapabilities entirely and uses only the
// pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current // pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current
// client advertises only its existing chat capability; media-state support // Current clients additionally advertise that they maintain canonical state
// is a relay capability and is never required for server acquisition. // while solo. Recovery itself remains relay-gated and legacy clients still
// omit clientCapabilities entirely.
const mixedMediaRid = 'mixed-media-'+Date.now(); const mixedMediaRid = 'mixed-media-'+Date.now();
const legacyMedia = await c(), currentMedia = await c(); const legacyMedia = await c(), currentMedia = await c();
const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media'); const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media');
const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1']); const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning'); assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning');
assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array'); assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array');
assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type'); assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type');
@@ -294,7 +295,7 @@ try {
await w(currentMedia, 'pause'); await w(currentMedia, 'pause');
const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision; const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision;
const currentMediaRejoin = await c(); const currentMediaRejoin = await c();
const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1']); const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision); assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision);
assert.equal(mixedRejoinData.mediaState.playbackState, 'paused'); assert.equal(mixedRejoinData.mediaState.playbackState, 'paused');
assert.equal(mixedRejoinData.mediaState.currentTime, 1700); assert.equal(mixedRejoinData.mediaState.currentTime, 1700);
@@ -302,6 +303,27 @@ try {
'current reconnect snapshot reflects the legacy client latest accepted intent'); 'current reconnect snapshot reflects the legacy client latest accepted intent');
assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false, assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false,
'legacy client receives no new canonical event or ACK requirement'); 'legacy client receives no new canonical event or ACK requirement');
currentMediaRejoin.close();
await delay(100);
assert.equal(mod.rooms.get(mixedMediaRid).mediaState, null,
'canonical state is cleared when only a legacy solo-suppressing client remains');
close();
resetConnectionRate();
// The inverse must remain true: a capable solo client keeps publishing
// PLAY/PAUSE/SEEK, so removing a legacy peer must not discard valid state.
const capableSoloRid = 'capable-solo-'+Date.now();
const capableSolo = await c(), transientLegacy = await c();
await j(capableSolo, capableSoloRid, 'capable-solo', null, ['chat-v1', 'media-state-v1']);
await j(transientLegacy, capableSoloRid, 'transient-legacy');
capableSolo._m.length = transientLegacy._m.length = 0;
s(capableSolo, 'play', { currentTime: 42, mediaTitle: 'Series S03E04' });
await w(transientLegacy, 'play');
const capableSoloState = { ...mod.rooms.get(capableSoloRid).mediaState };
transientLegacy.close();
await delay(100);
assert.deepEqual(mod.rooms.get(capableSoloRid).mediaState, capableSoloState,
'canonical state remains valid when the sole remaining client advertises media-state-v1');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -311,12 +333,18 @@ try {
const initialMediaRoom = await j(msa, msrid, 'msa'); const initialMediaRoom = await j(msa, msrid, 'msa');
assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null'); assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null');
s(msa, 'play', { currentTime: 100, revision: 999, updatedBy: 'spoofed' }); s(msa, 'play', {
currentTime: 100,
mediaTitle: 'Series S01E02',
revision: 999,
updatedBy: 'spoofed'
});
await delay(120); await delay(120);
const msb = await c(); const msb = await c();
const playingJoin = await j(msb, msrid, 'msb'); const playingJoin = await j(msb, msrid, 'msb');
assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1'); assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1');
assert.equal(playingJoin.mediaState.playbackState, 'playing'); assert.equal(playingJoin.mediaState.playbackState, 'playing');
assert.equal(playingJoin.mediaState.mediaTitle, 'Series S01E02');
assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101, assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101,
`playing late join receives projected position (${playingJoin.mediaState.currentTime})`); `playing late join receives projected position (${playingJoin.mediaState.currentTime})`);
assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity'); assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity');
@@ -404,8 +432,8 @@ try {
'authorized EXECUTE commits the latest target visible to legacy peers'); 'authorized EXECUTE commits the latest target visible to legacy peers');
assert.equal(competingForceState.updatedBy, 'msa'); assert.equal(competingForceState.updatedBy, 'msa');
// The initiator's normal ACK timeout must still fit inside relay target // The initiator's normal ACK timeout stays below the relay's delayed-target
// retention. This is the exact fallback boundary used by background.js. // warning boundary and commits normally.
s(msa, 'force_sync_prepare', { targetTime: 925 }); s(msa, 'force_sync_prepare', { targetTime: 925 });
await w(msb, 'force_sync_prepare'); await w(msb, 'force_sync_prepare');
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT; mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT;
@@ -414,18 +442,39 @@ try {
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 925, assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 925,
'relay grace accepts EXECUTE at the client ACK-timeout boundary'); 'relay grace accepts EXECUTE at the client ACK-timeout boundary');
// Even beyond the warning boundary, a target that no newer room action
// superseded remains the only safe way to release already-paused peers.
s(msa, 'force_sync_prepare', { targetTime: 950 }); s(msa, 'force_sync_prepare', { targetTime: 950 });
await w(msb, 'force_sync_prepare'); await w(msb, 'force_sync_prepare');
const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState }; const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState };
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_TTL - 1; mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_DELAY_WARNING - 1;
msa._m.length = msb._m.length = 0; msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {}); s(msa, 'force_sync_execute', {});
let expiredExecuteDropped = false; await w(msb, 'force_sync_execute');
try { await w(msb, 'force_sync_execute', 500); } catch { expiredExecuteDropped = true; } assert.equal(mod.rooms.get(msrid).mediaState.revision, beforeExpiredExecute.revision + 1);
assert.ok(expiredExecuteDropped, 'an expired Force Sync target rejects delayed EXECUTE'); assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 950,
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeExpiredExecute); 'a delayed EXECUTE still releases peers and commits its unsuperseded prepared target');
assert.equal(mod.rooms.get(msrid).forceSyncTarget, null); assert.equal(mod.rooms.get(msrid).forceSyncTarget, null);
// A relay restart cannot recover transient PREPARE state. Preserve the old
// wire liveness fallback without inventing a canonical target.
const beforeUntrackedExecute = { ...mod.rooms.get(msrid).mediaState };
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeUntrackedExecute,
'an untracked compatibility EXECUTE relays without canonical mutation');
s(msa, 'force_sync_prepare', { targetTime: 975 });
await w(msb, 'force_sync_prepare');
s(msb, 'seek', { targetTime: 'invalid' });
await w(msa, 'seek');
assert.equal(mod.rooms.get(msrid).forceSyncTarget.targetTime, 975,
'a sanitized no-op SEEK does not supersede an in-flight prepared target');
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 975);
msa._m.length = msb._m.length = 0; msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_prepare', { targetTime: 1_000 }); s(msa, 'force_sync_prepare', { targetTime: 1_000 });
await w(msb, 'force_sync_prepare'); await w(msb, 'force_sync_prepare');
@@ -830,14 +879,21 @@ try {
close(); close();
resetConnectionRate(); resetConnectionRate();
// A transient disconnect clears the Host Control exemption but not the // A transient disconnect removes the co-host role but not the validated
// validated room target. If the peer rejoins with current authority, its // room target. The same PREPARE initiator may still finish that already
// recovered EXECUTE must keep live playback and canonical state aligned. // visible transaction after reconnecting as a host-only guest.
const forceReconnectRid = 'force-reconnect-'+Date.now(); const forceReconnectRid = 'force-reconnect-'+Date.now();
const forceReconnectHost = await c(), forceReconnectPeer = await c(); const forceReconnectHost = await c(), forceReconnectPeer = await c();
await j(forceReconnectHost, forceReconnectRid, 'force-host'); await j(forceReconnectHost, forceReconnectRid, 'force-host');
await j(forceReconnectPeer, forceReconnectRid, 'force-peer'); await j(forceReconnectPeer, forceReconnectRid, 'force-peer');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0; forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectHost, 'set_control_mode', { controlMode: 'host-only' });
await w(forceReconnectHost, 'control_mode');
await w(forceReconnectPeer, 'control_mode');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectHost, 'set_peer_role', { peerId: 'force-peer', controller: true });
await w(forceReconnectPeer, 'control_mode');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 }); s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 });
await w(forceReconnectHost, 'force_sync_prepare'); await w(forceReconnectHost, 'force_sync_prepare');
forceReconnectPeer.close(); forceReconnectPeer.close();
+71 -34
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import crypto from 'crypto'; import crypto from 'crypto';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_TTL, MAX_MEDIA_TIME } from '../shared/constants.js'; import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js'; import { createChatEnvelope } from './chat.js';
import { import {
commitForceSyncMediaState, commitForceSyncMediaState,
@@ -204,7 +204,8 @@ function normalizeClientCapabilities(value) {
return [...new Set(value.slice(0, 16) return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string') .filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32)) .map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1) .filter(capability => capability === CAPABILITIES.CHAT_V1
|| capability === CAPABILITIES.MEDIA_STATE_V1)
)]; )];
} }
@@ -213,6 +214,11 @@ function clientSupportsChat(socket) {
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1); socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
} }
function clientSupportsMediaState(socket) {
return Array.isArray(socket?.data?.clientCapabilities)
&& socket.data.clientCapabilities.includes(CAPABILITIES.MEDIA_STATE_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly // M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and // toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
// from generating one broadcast per toggle across all peers. // from generating one broadcast per toggle across all peers.
@@ -295,6 +301,16 @@ function removePeerFromRoom(socketId, roomId, reason) {
// limitation (no host grace period); see KNOWN_LIMITATIONS.md. // limitation (no host grace period); see KNOWN_LIMITATIONS.md.
const peerRejoining = peerJoinLocks.has(peerId); const peerRejoining = peerJoinLocks.has(peerId);
const peerGone = !isPeerStillConnected && !peerRejoining; const peerGone = !isPeerStillConnected && !peerRejoining;
if (room.peers.size === 1 && !peerRejoining) {
const remainingSocketId = room.peers.values().next().value;
const remainingSocket = io.sockets.sockets.get(remainingSocketId);
if (!clientSupportsMediaState(remainingSocket)) {
// Pre-feature extensions suppress PLAY/PAUSE/SEEK while solo. Their
// last canonical snapshot can therefore become stale before the next
// join; absence is safer than applying known-unreliable room truth.
room.mediaState = null;
}
}
if (peerGone && room.controllers && room.peers.size > 0) { if (peerGone && room.controllers && room.peers.size > 0) {
const wasController = room.controllers.has(peerId); const wasController = room.controllers.has(peerId);
room.controllers.delete(peerId); room.controllers.delete(peerId);
@@ -470,7 +486,10 @@ io.on('connection', (socket) => {
mediaState: null, mediaState: null,
// PREPARE is choreography, not stable room intent. Retain its // PREPARE is choreography, not stable room intent. Retain its
// validated target only so the matching EXECUTE can commit it. // validated target only so the matching EXECUTE can commit it.
forceSyncTarget: null forceSyncTarget: null,
// Distinguishes an unknown target after relay restart from a
// transaction explicitly replaced by newer room playback.
forceSyncSuperseded: false
}; };
rooms.set(roomId, room); rooms.set(roomId, room);
createdByMe = true; createdByMe = true;
@@ -612,8 +631,11 @@ io.on('connection', (socket) => {
// FORCE_SYNC_EXECUTE still has to land after demotion — // FORCE_SYNC_EXECUTE still has to land after demotion —
// otherwise the already-relayed room-wide choreography // otherwise the already-relayed room-wide choreography
// would leave peers paused. // would leave peers paused.
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE && const forceSyncInitiator = room.forceSyncTarget?.initiatorPeerId
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator; || room.forceSyncInitiator;
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE
&& forceSyncInitiator
&& mapping.peerId === forceSyncInitiator;
if (!isOwnForceSyncExecute && if (!isOwnForceSyncExecute &&
room.controlMode === CONTROL_MODES.HOST_ONLY && room.controlMode === CONTROL_MODES.HOST_ONLY &&
!(room.controllers && room.controllers.has(mapping.peerId)) && !(room.controllers && room.controllers.has(mapping.peerId)) &&
@@ -687,37 +709,33 @@ io.on('connection', (socket) => {
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
const mediaStateNow = Date.now(); const mediaStateNow = Date.now();
if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
const targetExpired = forceSyncTarget
&& (!Number.isFinite(forceSyncTarget.preparedAt)
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_TTL);
if (!forceSyncTarget || targetExpired) {
log('ROOM', `Dropped force_sync_execute ${targetExpired ? 'with an expired target' : 'without a prepared target'} from ${mapping.peerId}`);
room.forceSyncInitiator = null;
room.forceSyncTarget = 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, // Canonical Media State v1: mutate only after rate limiting,
// room mapping, Host Control authorization and sanitization. // room mapping, Host Control authorization and sanitization.
// Heartbeats remain observational and never enter this path. // Heartbeats remain observational and never enter this path.
updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, { const canonicalStateUpdated = updateMediaStateFromControl(
now: mediaStateNow, room,
senderPlaybackState: existing.playbackState eventName,
}); relayPayload,
mapping.peerId,
{
now: mediaStateNow,
senderPlaybackState: existing.playbackState,
senderMediaTitle: room.peerData.get(socket.id)?.mediaTitle
}
);
const validLobbyTransition = (eventName === EVENTS.EPISODE_LOBBY
&& typeof relayPayload.expectedTitle === 'string'
&& relayPayload.expectedTitle.length > 0)
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL;
if (canonicalStateUpdated || validLobbyTransition) {
// 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.forceSyncSuperseded = true;
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
}
if (eventName === EVENTS.FORCE_SYNC_PREPARE) { if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
// A malformed PREPARE must neither pause peers nor grant // A malformed PREPARE must neither pause peers nor grant
// the initiator a later Host Control EXECUTE exemption. // the initiator a later Host Control EXECUTE exemption.
@@ -731,23 +749,42 @@ io.on('connection', (socket) => {
// control mode so an everyone -> host-only transition // control mode so an everyone -> host-only transition
// cannot strand that already-authorized transaction. // cannot strand that already-authorized transaction.
room.forceSyncInitiator = mapping.peerId; room.forceSyncInitiator = mapping.peerId;
room.forceSyncSuperseded = false;
room.forceSyncTarget = { room.forceSyncTarget = {
initiatorPeerId: mapping.peerId, initiatorPeerId: mapping.peerId,
targetTime: relayPayload.targetTime, targetTime: relayPayload.targetTime,
preparedAt: mediaStateNow preparedAt: mediaStateNow,
mediaTitle: room.peerData.get(socket.id)?.mediaTitle || null
}; };
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) { } else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget; const forceSyncTarget = room.forceSyncTarget;
if (!forceSyncTarget && room.forceSyncSuperseded) {
log('ROOM', `Dropped obsolete force_sync_execute after newer room playback from ${mapping.peerId}`);
room.forceSyncInitiator = null;
return;
}
if (forceSyncTarget) { if (forceSyncTarget) {
const targetDelayed = !Number.isFinite(forceSyncTarget.preparedAt)
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_DELAY_WARNING;
if (targetDelayed) {
log('ROOM', `Relaying delayed force_sync_execute from ${mapping.peerId} to release prepared peers`);
}
commitForceSyncMediaState( commitForceSyncMediaState(
room, room,
forceSyncTarget.targetTime, forceSyncTarget.targetTime,
mapping.peerId, mapping.peerId,
mediaStateNow mediaStateNow,
forceSyncTarget.mediaTitle
); );
} else {
// A relay restart loses transient PREPARE state while legacy
// receivers can remain paused in their existing pages. Preserve
// the old wire behavior, but do not invent a canonical target.
log('ROOM', `Relaying force_sync_execute without server target from ${mapping.peerId}`);
} }
room.forceSyncInitiator = null; room.forceSyncInitiator = null;
room.forceSyncTarget = null; room.forceSyncTarget = null;
room.forceSyncSuperseded = false;
} }
socket.to(mapping.roomId).emit(eventName, relayPayload); socket.to(mapping.roomId).emit(eventName, relayPayload);
+24 -8
View File
@@ -5,6 +5,11 @@ function clampMediaTime(value) {
return Math.max(0, Math.min(MAX_MEDIA_TIME, value)); return Math.max(0, Math.min(MAX_MEDIA_TIME, value));
} }
function normalizeMediaTitle(value) {
if (typeof value !== 'string' || !value) return null;
return value.substring(0, 100);
}
export function effectiveMediaPosition(mediaState, now = Date.now()) { export function effectiveMediaPosition(mediaState, now = Date.now()) {
if (!mediaState) return null; if (!mediaState) return null;
const currentTime = clampMediaTime(mediaState.currentTime); const currentTime = clampMediaTime(mediaState.currentTime);
@@ -25,15 +30,18 @@ export function snapshotMediaState(mediaState, now = Date.now()) {
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) { || (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) {
return null; return null;
} }
return { const snapshot = {
revision: mediaState.revision, revision: mediaState.revision,
playbackState: mediaState.playbackState, playbackState: mediaState.playbackState,
currentTime, currentTime,
updatedBy: mediaState.updatedBy updatedBy: mediaState.updatedBy
}; };
const mediaTitle = normalizeMediaTitle(mediaState.mediaTitle);
if (mediaTitle) snapshot.mediaTitle = mediaTitle;
return snapshot;
} }
function commitMediaState(room, playbackState, currentTime, updatedBy, now) { function commitMediaState(room, playbackState, currentTime, updatedBy, now, mediaTitle = null) {
const normalizedTime = clampMediaTime(currentTime); const normalizedTime = clampMediaTime(currentTime);
if (normalizedTime === null if (normalizedTime === null
|| (playbackState !== 'playing' && playbackState !== 'paused') || (playbackState !== 'playing' && playbackState !== 'paused')
@@ -41,21 +49,28 @@ function commitMediaState(room, playbackState, currentTime, updatedBy, now) {
|| !updatedBy) { || !updatedBy) {
return false; return false;
} }
room.mediaState = { const nextState = {
revision: (room.mediaState?.revision || 0) + 1, revision: (room.mediaState?.revision || 0) + 1,
playbackState, playbackState,
currentTime: normalizedTime, currentTime: normalizedTime,
updatedAt: now, updatedAt: now,
updatedBy updatedBy
}; };
const normalizedTitle = normalizeMediaTitle(mediaTitle);
if (normalizedTitle) nextState.mediaTitle = normalizedTitle;
room.mediaState = nextState;
return true; return true;
} }
export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, { export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, {
now = Date.now(), now = Date.now(),
senderPlaybackState = null senderPlaybackState = null,
senderMediaTitle = null
} = {}) { } = {}) {
if (!room || !payload || typeof payload !== 'object') return false; if (!room || !payload || typeof payload !== 'object') return false;
const mediaTitle = payload.mediaTitle === null
? null
: (normalizeMediaTitle(payload.mediaTitle) || normalizeMediaTitle(senderMediaTitle));
if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) { if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) {
const eventPosition = clampMediaTime(payload.currentTime); const eventPosition = clampMediaTime(payload.currentTime);
@@ -66,7 +81,8 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer
eventName === EVENTS.PLAY ? 'playing' : 'paused', eventName === EVENTS.PLAY ? 'playing' : 'paused',
currentTime, currentTime,
senderPeerId, senderPeerId,
now now,
mediaTitle
); );
} }
@@ -74,12 +90,12 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer
const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime); const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime);
const playbackState = room.mediaState?.playbackState || senderPlaybackState; const playbackState = room.mediaState?.playbackState || senderPlaybackState;
if (targetTime === null) return false; if (targetTime === null) return false;
return commitMediaState(room, playbackState, targetTime, senderPeerId, now); return commitMediaState(room, playbackState, targetTime, senderPeerId, now, mediaTitle);
} }
return false; return false;
} }
export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now()) { export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now(), mediaTitle = null) {
return commitMediaState(room, 'playing', targetTime, senderPeerId, now); return commitMediaState(room, 'playing', targetTime, senderPeerId, now, mediaTitle);
} }
+39 -2
View File
@@ -67,6 +67,36 @@ describe('canonical media state', () => {
expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' }); expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' });
}); });
it('tracks only the current sender shared media title and honors an explicit privacy null', () => {
const target = room();
expect(updateMediaStateFromControl(
target,
EVENTS.PLAY,
{ currentTime: 10, mediaTitle: 'Series S01E01' },
'a',
{ now: 1000 }
)).toBe(true);
expect(snapshotMediaState(target.mediaState, 1000).mediaTitle).toBe('Series S01E01');
expect(updateMediaStateFromControl(
target,
EVENTS.SEEK,
{ targetTime: 20 },
'b',
{ now: 2000, senderMediaTitle: 'Series S01E02' }
)).toBe(true);
expect(target.mediaState.mediaTitle).toBe('Series S01E02');
expect(updateMediaStateFromControl(
target,
EVENTS.PAUSE,
{ currentTime: 20, mediaTitle: null },
'b',
{ now: 3000, senderMediaTitle: 'stale S01E01' }
)).toBe(true);
expect(target.mediaState).not.toHaveProperty('mediaTitle');
});
it('ignores client-supplied playback state while seeking', () => { it('ignores client-supplied playback state while seeking', () => {
const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' }); const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
expect(updateMediaStateFromControl( expect(updateMediaStateFromControl(
@@ -103,7 +133,14 @@ describe('canonical media state', () => {
it('commits Force Sync only at execute time', () => { it('commits Force Sync only at execute time', () => {
const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' }); const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' });
expect(commitForceSyncMediaState(target, 500, 'b', 2000)).toBe(true); expect(commitForceSyncMediaState(target, 500, 'b', 2000, 'Series S02E03')).toBe(true);
expect(target.mediaState).toEqual({ revision: 5, playbackState: 'playing', currentTime: 500, updatedAt: 2000, updatedBy: 'b' }); expect(target.mediaState).toEqual({
revision: 5,
playbackState: 'playing',
currentTime: 500,
updatedAt: 2000,
updatedBy: 'b',
mediaTitle: 'Series S02E03'
});
}); });
}); });
+1 -1
View File
@@ -64,7 +64,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
- `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds. - `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds.
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs. - `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
- `FORCE_SYNC_TARGET_TTL`: relay retention for a prepared Force Sync target; includes post-timeout delivery grace. - `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness.
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness. - `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions. - `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
+3 -3
View File
@@ -99,7 +99,7 @@ export const MAX_MEDIA_TIME = 86400;
export const HEARTBEAT_INTERVAL = 15000; // 15s export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s) export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
// Relay retention must outlive the client's ACK wait. Otherwise the timeout // Log unexpectedly delayed EXECUTE delivery after the normal ACK wait plus
// fallback EXECUTE arrives exactly when the relay expires its prepared target. // transport grace. The target remains valid until newer room playback replaces it.
export const FORCE_SYNC_TARGET_TTL = FORCE_SYNC_TIMEOUT + 2000; export const FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
+136
View File
@@ -381,6 +381,29 @@ test('applies canonical recovery without echoing media commands or activity', as
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true); await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9); await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
navigator.mediaSession.metadata = new globalThis.MediaMetadata({ title: 'Series S01E02' });
}
});
}, tabId));
const mismatchedApply = await applyCanonicalMediaState(context, extensionId, tabId, {
revision: 12,
playbackState: 'playing',
currentTime: 2,
updatedBy: 'peer-a',
mediaTitle: 'Series S01E01'
});
expect(mismatchedApply).toMatchObject({ status: 'ignored_episode_mismatch' });
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: true });
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
// Wait past seek debounce and play/pause coalescing windows. A leaked native // Wait past seek debounce and play/pause coalescing windows. A leaked native
// echo would have reached background.js and appeared as user activity by now. // echo would have reached background.js and appeared as user activity by now.
await page.waitForTimeout(700); await page.waitForTimeout(700);
@@ -470,6 +493,119 @@ test('recovers relay ROOM_DATA through background retries into the packed player
} }
}); });
test('newer mixed-version playback supersedes an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => {
test.setTimeout(45_000);
const relay = await import('../../server/index.js');
let legacy = null;
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-canonical-supersede-${Date.now()}`;
legacy = await connectLegacyRelayClient(port);
await joinLegacyRelayRoom(legacy, roomId, 'legacy-newer-control');
sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 });
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
const video = document.querySelector('#player');
if (!video) throw new Error('canonical supersession fixture video missing');
video.dataset.koalaCanonicalPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1;
video.dataset.koalaCanonicalPlayAttempts = String(attempts);
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('delayed audit autoplay rejection')), 400);
});
}
});
}
});
}, tabId));
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
await chrome.storage.local.set(settings);
return chrome.runtime.sendMessage({ type: 'CONNECT' });
}, {
serverUrl: `ws://127.0.0.1:${port}`,
useCustomServer: true,
roomId,
password: '',
username: 'current-superseded'
}));
await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaCanonicalPlayAttempts || '0')))
.toBe(1);
sendLegacyRelayEvent(legacy, 'seek', { currentTime: 10, targetTime: 10, seq: 2, actionTimestamp: 2 });
sendLegacyRelayEvent(legacy, 'pause', { currentTime: 10, seq: 3, actionTimestamp: 3 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 3, playbackState: 'paused', currentTime: 10 });
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: true });
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
.toBeGreaterThan(9);
await page.waitForTimeout(1_200);
expect(await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaCanonicalPlayAttempts || '0'))).toBe(1);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
} finally {
try { legacy?.close(); } catch { /* already closed */ }
await relay.stopServerForTests();
}
});
test('keeps canonical media state current while the capable extension is solo', async ({ context, extensionId, baseURL }) => {
test.setTimeout(35_000);
const relay = await import('../../server/index.js');
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-canonical-solo-${Date.now()}`;
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
await chrome.storage.local.set(settings);
return chrome.runtime.sendMessage({ type: 'CONNECT' });
}, {
serverUrl: `ws://127.0.0.1:${port}`,
useCustomServer: true,
roomId,
password: '',
username: 'current-solo'
}));
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ status: 'connected', roomId });
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 8 }))
.toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 8 }))
.toMatchObject({ status: 'ok' });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 8 });
} finally {
await relay.stopServerForTests();
}
});
test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => { test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
const first = `${baseURL}/pages/iframe-player.html`; const first = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage(); const page = await context.newPage();