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
+61 -5
View File
@@ -288,7 +288,10 @@ function serverSupports(cap) { return Array.isArray(serverCapabilities) && serve
function serverSupportsChat() {
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() {
if (!storageInitialized) return;
@@ -1880,6 +1883,26 @@ function markCanonicalMediaStateHandled(roomId, revision) {
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() {
const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPendingProjected(roomId);
@@ -1904,9 +1927,21 @@ async function performPendingCanonicalMediaStateApply() {
const targetDocumentId = currentTargetDocumentId;
try {
const response = await sendMessageToContentTab(tabId, {
type: 'APPLY_CANONICAL_MEDIA_STATE',
mediaState
const response = await enqueueContentCommand(async () => {
if (currentRoom?.roomId !== roomId) return { status: 'stale_room' };
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 (!isCurrentTargetIdentity(tabId, targetGeneration)
@@ -1924,6 +1959,9 @@ async function performPendingCanonicalMediaStateApply() {
} else if (response?.status === 'ignored_desynced') {
markCanonicalMediaStateHandled(roomId, mediaState.revision);
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') {
markCanonicalMediaStateHandled(roomId, mediaState.revision);
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;
_persistLastSeq();
}
if (isCanonicalSupersedingControl(event, data)) {
supersedeCanonicalMediaRecovery(`newer ${event}`);
}
if (data.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
@@ -2308,6 +2349,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
lastSeqBySender[data.senderId] = data.seq;
_persistLastSeq();
}
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (data?.senderId) {
addToHistory(event, data.senderId);
showNotification(data.senderId, event);
@@ -2443,6 +2485,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
break;
case EVENTS.EPISODE_LOBBY:
if (data.senderId && data.expectedTitle) {
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (currentRoom) {
currentRoom.activeLobby = {
expectedTitle: data.expectedTitle,
@@ -2499,6 +2542,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
}
break;
case EVENTS.EPISODE_LOBBY_CANCEL:
supersedeCanonicalMediaRecovery(`newer ${event}`);
if (currentRoom) {
currentRoom.activeLobby = null;
if (storageInitialized) chrome.storage.session.set({ currentRoom });
@@ -2531,6 +2575,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con
}
function executeForceSync() {
supersedeCanonicalMediaRecovery('local force_sync_execute');
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
isForceSyncInitiator = false;
forceSyncAcks.clear();
@@ -2604,6 +2649,7 @@ function clearEpisodeLobbyState() {
function cancelEpisodeLobby(reason) {
if (!episodeLobby) return;
const title = episodeLobby.expectedTitle;
supersedeCanonicalMediaRecovery('local episode_lobby_cancel');
// Broadcast cancellation to room
emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId });
@@ -2733,6 +2779,10 @@ function routeToContent(action, payload) {
commandSenderId,
0
);
return enqueueContentCommand(deliver);
}
function enqueueContentCommand(deliver) {
const queued = contentCommandQueue.catch(() => {}).then(deliver);
contentCommandQueue = queued;
return queued;
@@ -4824,6 +4874,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
payload.targetTime = targetTime;
}
if (isCanonicalSupersedingControl(message.action, payload)) {
supersedeCanonicalMediaRecovery(`local ${message.action}`);
}
const timestamp = Date.now();
localSeq++;
chrome.storage.session.set({ localSeq });
@@ -4883,7 +4936,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendChatActivity(message.action, peerId, timestamp);
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' });
return;
}
@@ -5159,6 +5214,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (episodeLobby) clearEpisodeLobbyState();
// Create new lobby
supersedeCanonicalMediaRecovery('local episode_lobby');
episodeLobby = {
expectedTitle: lobbyTitle,
initiatorPeerId: peerId,
@@ -22,6 +22,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(handler).toContain("canonicalSnapshot.status === 'empty'");
expect(handler.indexOf('canonicalMediaStateFromRoomData(data)'))
.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', () => {
@@ -39,6 +41,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
it('uses a dedicated internal apply message without action/history/ACK machinery', () => {
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
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('emit(');
expect(apply).not.toContain('addToHistory(');
@@ -81,6 +85,17 @@ describe('canonical ROOM_DATA recovery contract', () => {
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', () => {
const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady');
expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA');
@@ -92,6 +107,8 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(apply.indexOf("status: 'applied'"))
.toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)'));
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 serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
expect(contentSource.slice(contentHandlerStart, serverCommandStart))
+8
View File
@@ -10,6 +10,11 @@ export function validateCanonicalMediaState(value) {
|| value.currentTime > MAX_MEDIA_TIME) {
return null;
}
if (value.mediaTitle !== undefined
&& value.mediaTitle !== null
&& typeof value.mediaTitle !== 'string') {
return null;
}
const normalized = {
revision: value.revision,
playbackState: value.playbackState,
@@ -18,6 +23,9 @@ export function validateCanonicalMediaState(value) {
if (typeof value.updatedBy === 'string' && value.updatedBy) {
normalized.updatedBy = value.updatedBy.substring(0, 16);
}
if (typeof value.mediaTitle === 'string' && value.mediaTitle) {
normalized.mediaTitle = value.mediaTitle.substring(0, 100);
}
return normalized;
}
+8
View File
@@ -18,6 +18,14 @@ describe('canonical media state validation', () => {
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([
null,
[],
+9 -1
View File
@@ -1270,10 +1270,18 @@
|| typeof mediaState.currentTime !== 'number'
|| !Number.isFinite(mediaState.currentTime)
|| 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' };
}
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();
if (!video) return { status: 'no_video' };