fix(sync): harden canonical recovery

This commit is contained in:
Timo
2026-08-25 21:21:18 +02:00
parent a65fe2f576
commit def5e1a380
10 changed files with 363 additions and 46 deletions
+81 -3
View File
@@ -258,6 +258,18 @@ let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
let localSeq = 0; // Monotonically increasing command sequence for this peer
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
const canonicalMediaStateTracker = createCanonicalMediaStateTracker();
const CANONICAL_RECOVERY_RETRY_DELAYS = Object.freeze([250, 750, 1500, 3000]);
const CANONICAL_RECOVERY_RETRYABLE = new Set([
'no_video',
'apply_failed',
'no_response',
'pending_unreachable',
'stale_target',
'superseded'
]);
let canonicalRecoveryRetryTimer = null;
let canonicalRecoveryRetryAttempt = 0;
let canonicalRecoveryApplyInProgress = null;
// --- Host Control Mode ---
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
@@ -285,7 +297,52 @@ function persistCanonicalMediaRecovery() {
}).catch(() => {});
}
function resetCanonicalMediaRecoveryRetries() {
if (canonicalRecoveryRetryTimer) {
clearTimeout(canonicalRecoveryRetryTimer);
canonicalRecoveryRetryTimer = null;
}
canonicalRecoveryRetryAttempt = 0;
}
function scheduleCanonicalMediaRecoveryRetry(reason) {
const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPending(roomId);
if (!pending
|| canonicalRecoveryRetryTimer
|| canonicalRecoveryApplyInProgress
|| canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length) {
return false;
}
const expectedRevision = pending.mediaState.revision;
const delay = CANONICAL_RECOVERY_RETRY_DELAYS[canonicalRecoveryRetryAttempt++];
canonicalRecoveryRetryTimer = setTimeout(() => {
canonicalRecoveryRetryTimer = null;
const latest = canonicalMediaStateTracker.getPending(roomId);
if (currentRoom?.roomId !== roomId || latest?.mediaState.revision !== expectedRevision) return;
addLog(`Retrying canonical media state r${expectedRevision} after ${reason}`, 'info');
tryApplyPendingCanonicalMediaState().catch(error => {
addLog(`Canonical media state retry failed: ${error.message}`, 'warn');
});
}, delay);
return true;
}
function requestCanonicalMediaRecoveryAttempt() {
if (canonicalRecoveryRetryTimer
|| canonicalRecoveryApplyInProgress
|| canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length
|| !canonicalMediaStateTracker.getPending(currentRoom?.roomId)) {
return false;
}
tryApplyPendingCanonicalMediaState().catch(error => {
addLog(`Canonical media state retry failed: ${error.message}`, 'warn');
});
return true;
}
function clearCanonicalMediaRecovery() {
resetCanonicalMediaRecoveryRetries();
canonicalMediaStateTracker.clear();
persistCanonicalMediaRecovery();
}
@@ -728,6 +785,7 @@ function resolveServerUrl(settings) {
function forceDisconnect({ preserveEventQueue = false } = {}) {
connectionGeneration++;
resetCanonicalMediaRecoveryRetries();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -945,7 +1003,7 @@ function adoptReportingFrame(sender) {
currentTargetHasVideo = true;
stopMediaDiscoveryPoll();
chrome.storage.session.set({ currentTargetHasVideo }).catch(() => {});
tryApplyPendingCanonicalMediaState().catch(() => {});
requestCanonicalMediaRecoveryAttempt();
return true;
}
@@ -960,7 +1018,7 @@ function adoptReportingFrame(sender) {
currentTargetDocumentId,
currentTargetHasVideo
}).catch(() => {});
tryApplyPendingCanonicalMediaState().catch(() => {});
requestCanonicalMediaRecoveryAttempt();
return true;
}
@@ -1817,11 +1875,12 @@ function stopPing() {
function markCanonicalMediaStateHandled(roomId, revision) {
if (!canonicalMediaStateTracker.markHandled(roomId, revision)) return false;
resetCanonicalMediaRecoveryRetries();
persistCanonicalMediaRecovery();
return true;
}
async function tryApplyPendingCanonicalMediaState() {
async function performPendingCanonicalMediaStateApply() {
const roomId = currentRoom?.roomId;
const pending = canonicalMediaStateTracker.getPendingProjected(roomId);
if (!pending || !roomId) return { status: 'none' };
@@ -1876,6 +1935,22 @@ async function tryApplyPendingCanonicalMediaState() {
}
}
async function tryApplyPendingCanonicalMediaState() {
if (canonicalRecoveryApplyInProgress) return canonicalRecoveryApplyInProgress;
const applyTask = performPendingCanonicalMediaStateApply();
canonicalRecoveryApplyInProgress = applyTask;
let result;
try {
result = await applyTask;
} finally {
if (canonicalRecoveryApplyInProgress === applyTask) canonicalRecoveryApplyInProgress = null;
}
if (CANONICAL_RECOVERY_RETRYABLE.has(result?.status)) {
scheduleCanonicalMediaRecoveryRetry(result.status);
}
return result;
}
async function handleCanonicalRoomData(data, hasPendingLocalIntent) {
canonicalMediaStateTracker.adoptRoom(data?.roomId || null);
const canonicalSnapshot = canonicalMediaStateFromRoomData(data);
@@ -1898,6 +1973,7 @@ async function handleCanonicalRoomData(data, hasPendingLocalIntent) {
}
if (received.status !== 'pending') return;
resetCanonicalMediaRecoveryRetries();
persistCanonicalMediaRecovery();
addLog(`Canonical media state received: r${mediaState.revision} ${mediaState.playbackState} @ ${mediaState.currentTime.toFixed(2)}s`, 'info');
@@ -4896,6 +4972,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
updateBadgeStatus();
}
requestCanonicalMediaRecoveryAttempt();
markRoomUseful();
getSettings().then(settings => {
const sharedTitles = getSharedTitleFields(settings, message.payload?.mediaTitle);
@@ -5222,6 +5299,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return;
}
}
requestCanonicalMediaRecoveryAttempt();
// Content script re-injected, check if there's an active lobby
if (episodeLobby) {
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
@@ -37,7 +37,7 @@ describe('canonical ROOM_DATA recovery contract', () => {
});
it('uses a dedicated internal apply message without action/history/ACK machinery', () => {
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'");
expect(apply).not.toContain('routeToContent(');
expect(apply).not.toContain('emit(');
@@ -55,17 +55,23 @@ describe('canonical ROOM_DATA recovery contract', () => {
it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => {
expect(backgroundSource).toContain("'canonicalMediaRecovery'");
expect(backgroundSource).toContain('canonicalMediaStateTracker.restore(');
expect(backgroundSource).toContain('tryApplyPendingCanonicalMediaState().catch(() => {})');
expect(backgroundSource).toContain('CANONICAL_RECOVERY_RETRY_DELAYS');
expect(backgroundSource).toContain('requestCanonicalMediaRecoveryAttempt()');
expect(backgroundSource).toMatch(/message\.type === 'HEARTBEAT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
expect(backgroundSource).toMatch(/message\.type === 'CONTENT_BOOT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/);
expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4);
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
expect(apply).toContain('getPendingProjected(roomId)');
expect(apply).toContain('targetActivationGeneration');
expect(apply).toContain("return { status: 'stale_target' }");
const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt');
expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length');
expect(retry).toContain('latest?.mediaState.revision !== expectedRevision');
});
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
const apply = functionBody(backgroundSource, 'tryApplyPendingCanonicalMediaState', 'handleCanonicalRoomData');
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
expect(apply).toContain('if (hcmDesynced)');
expect(apply).toContain('if (episodeLobby)');
@@ -75,13 +81,20 @@ describe('canonical ROOM_DATA recovery contract', () => {
expect(backgroundSource).toContain('await flushEventQueue(replaySettings)');
});
it('reuses existing seek abstractions, suppression and drift tolerance', () => {
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');
expect(apply).toContain("_setSuppress('seek')");
expect(apply).toContain('seekVideo(video, mediaState.currentTime)');
expect(apply).toContain('tryMediaAction(EVENTS.PAUSE)');
expect(apply).toContain('tryMediaAction(EVENTS.PLAY)');
expect(apply).toContain('await tryMediaAction(EVENTS.SEEK');
expect(apply).toContain('await tryMediaAction(EVENTS.PAUSE)');
expect(apply).toContain('await tryMediaAction(EVENTS.PLAY)');
expect(apply).toContain('await pollCanonicalMediaState(mediaState, startedAt)');
expect(apply.indexOf("status: 'applied'"))
.toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt)'));
expect(apply).toContain('if (hcmDesynced)');
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
expect(contentSource.slice(contentHandlerStart, serverCommandStart))
.toContain('applyCanonicalMediaState(message.mediaState).then(sendResponse)');
});
});
+77 -14
View File
@@ -1185,13 +1185,13 @@
// --- Helper: site-specific player actions, then native HTML5 fallback ---
function tryMediaAction(action, data) {
const video = findVideo();
if (!video) return;
if (!video) return false;
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
return false;
}
data = { ...data, targetTime: target };
}
@@ -1199,28 +1199,71 @@
try {
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
return true;
}
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
_clearSuppress('playing');
});
const playResult = video.play();
if (playResult && typeof playResult.then === 'function') {
return playResult.then(() => true).catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
_clearSuppress('playing');
return false;
});
}
return true;
} else if (action === EVENTS.PAUSE) {
_setSuppress('paused');
video.pause();
return true;
} else if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
return true;
}
} catch (e) {
return false;
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
return false;
}
}
function applyCanonicalMediaState(mediaState) {
function pollCanonicalMediaState(mediaState, startedAt, timeoutMs = 2500) {
return new Promise((resolve) => {
const interval = 100;
const finishAt = Date.now() + timeoutMs;
const timer = setInterval(() => {
if (destroyed) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(null);
return;
}
const video = findVideo();
const currentTime = video ? getSyncCurrentTime(video) : null;
const projectedTime = mediaState.currentTime
+ (mediaState.playbackState === 'playing'
? Math.max(0, Date.now() - startedAt) / 1000
: 0);
const playbackMatches = video
&& (mediaState.playbackState === 'playing' ? !video.paused : video.paused);
const drift = currentTime === null ? null : projectedTime - currentTime;
if (playbackMatches && drift !== null && Math.abs(drift) < MIN_SEEK_DELTA) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve({ currentTime, drift });
} else if (Date.now() >= finishAt) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(null);
}
}, interval);
seekPollTimers.add(timer);
});
}
async function applyCanonicalMediaState(mediaState) {
if (!mediaState || typeof mediaState !== 'object'
|| !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')
@@ -1238,23 +1281,40 @@
const currentTime = getSyncCurrentTime(video);
const drift = currentTime === null ? null : mediaState.currentTime - currentTime;
const shouldSeek = drift === null || Math.abs(drift) >= MIN_SEEK_DELTA;
const startedAt = Date.now();
try {
// Paused recovery pauses before seeking; playing recovery seeks before
// starting. Both paths reuse the same site/page-API abstractions and
// native-event suppression as ordinary remote commands.
if (mediaState.playbackState === 'paused' && !video.paused) {
tryMediaAction(EVENTS.PAUSE);
if (!await tryMediaAction(EVENTS.PAUSE)) {
return { status: 'apply_failed', reason: 'pause_action_failed' };
}
}
if (shouldSeek) {
_setSuppress('seek');
seekVideo(video, mediaState.currentTime);
if (!await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime })) {
return { status: 'apply_failed', reason: 'seek_action_failed' };
}
}
if (mediaState.playbackState === 'playing' && video.paused) {
tryMediaAction(EVENTS.PLAY);
if (!await tryMediaAction(EVENTS.PLAY)) {
return { status: 'apply_failed', reason: 'play_action_failed' };
}
}
const verified = await pollCanonicalMediaState(mediaState, startedAt);
if (!verified) {
reportLog(`Canonical media state r${mediaState.revision} could not be verified`, 'warn');
return { status: 'apply_failed', reason: 'verification_timeout' };
}
scheduleProactiveHeartbeat();
return { status: 'applied', revision: mediaState.revision, drift, sought: shouldSeek };
return {
status: 'applied',
revision: mediaState.revision,
drift: verified.drift,
sought: shouldSeek
};
} catch (error) {
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
return { status: 'apply_failed' };
@@ -1362,7 +1422,10 @@
}
if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') {
sendResponse(applyCanonicalMediaState(message.mediaState));
applyCanonicalMediaState(message.mediaState).then(sendResponse).catch(error => {
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
sendResponse({ status: 'apply_failed', reason: 'unexpected_error' });
});
return true;
}