mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 20:49:22 +00:00
fix(sync): close canonical recovery merge blockers
This commit is contained in:
+61
-5
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
|
||||
@@ -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' };
|
||||
|
||||
Reference in New Issue
Block a user