fix(sync): harden terminal state races

This commit is contained in:
Timo
2026-09-01 03:34:01 +02:00
parent 77e7d0c103
commit bc5d1a0ea6
5 changed files with 55 additions and 10 deletions
+5 -3
View File
@@ -558,9 +558,11 @@ non-seeking state for `EPISODE_SYNC_V2_STABILITY_MS`.
Any timeout, failed player action, participant departure, manual media command,
room/target change, or explicit cancellation produces `cancel`; v2 never
executes after a timeout. Peers resume only when that transaction paused a
previously playing player and no newer local user action superseded it. Peers
joining after `start` are excluded from the frozen barrier and receive no v2
frames for that transaction.
previously playing player and no newer local or room action superseded it. A
superseding room command defines the next state without racing a restoration.
Peers joining after `start` are excluded from the frozen barrier and receive no
v2 frames for that transaction. Clients revalidate the complete prepared state
again immediately before applying `execute`.
Legacy episode events remain accepted. A new relay binds their PREPARE, EXECUTE,
and CANCEL to the accepted lobby initiator, limiting duplicate old-client wire
+9 -5
View File
@@ -1185,11 +1185,9 @@ async function clearTargetSelectionForLifecycle({
}
resetUserSelectionState();
const cleanupTasks = [clearPendingTarget()];
if (previousTabId !== null) {
cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget));
}
await Promise.all(cleanupTasks);
// Persist the terminal selection state before any frame messaging or host
// permission cleanup can yield. A worker stop or a concurrent new target
// must never resurrect the selection this lifecycle transition removed.
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -1203,6 +1201,12 @@ async function clearTargetSelectionForLifecycle({
selectionErrorTabId: null,
selectionErrorMessage: null
});
const cleanupTasks = [clearPendingTarget()];
if (previousTabId !== null) {
cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget));
}
await Promise.all(cleanupTasks);
updateBadgeStatus();
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId: clearedTabId }).catch(() => {});
return true;
+15 -2
View File
@@ -1983,20 +1983,33 @@
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
const state = episodeSyncV2State;
const video = state.video || findVideo();
const current = video ? getSyncCurrentTime(video) : null;
const canExecute = video
&& state.phase === 'prepare'
&& video === findVideo()
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle);
&& video.isConnected !== false
&& sameEpisodeStrict(getMediaTitle(), state.expectedTitle)
&& video.paused
&& !video.seeking
&& video.readyState >= 3
&& current !== null
&& Math.abs(current) < 1;
clearEpisodeSyncV2Content({ resume: false }).catch(() => {});
if (canExecute) {
Promise.resolve(tryMediaAction(EVENTS.PLAY)).then(applied => {
if (applied) scheduleProactiveHeartbeat();
else reportLog('Episode Sync v2 execute could not start playback', 'warn');
}).catch(() => {});
} else {
reportLog('Episode Sync v2 execute ignored: prepared player state changed', 'warn');
}
}
} else if (transaction.phase === 'cancel') {
if (episodeSyncV2State?.transactionId === transaction.transactionId) {
clearEpisodeSyncV2Content({ resume: true }).catch(() => {});
// A superseding room command follows this cancellation on
// the same ordered socket. Do not race it with restoration
// of the pre-transaction play state.
clearEpisodeSyncV2Content({ resume: transaction.reason !== 'superseded' }).catch(() => {});
}
}
sendResponse({ status: 'ok' });
+24
View File
@@ -98,6 +98,30 @@ describe('Episode Sync v2 extension contract', () => {
expect(contentSource).toContain('failEpisodeSyncV2ForManualAction(action)');
});
it('does not restore playback ahead of a superseding room command', () => {
const handler = between(
contentSource,
"message.type === 'EPISODE_SYNC_V2'",
'// Episode Auto-Sync: Legacy lobby notification from background'
);
expect(handler).toContain("resume: transaction.reason !== 'superseded'");
});
it('revalidates the complete prepared state immediately before execute', () => {
const handler = between(
contentSource,
"transaction.phase === 'execute'",
"transaction.phase === 'cancel'"
);
expect(handler).toContain("state.phase === 'prepare'");
expect(handler).toContain('video === findVideo()');
expect(handler).toContain('video.isConnected !== false');
expect(handler).toContain('video.paused');
expect(handler).toContain('!video.seeking');
expect(handler).toContain('video.readyState >= 3');
expect(handler).toContain('Math.abs(current) < 1');
});
it('injects the shared stability window into packaged content scripts', () => {
expect(buildSource).toContain('EPISODE_SYNC_V2_STABILITY_MS');
expect(buildSource).toContain('episodeSyncStabilityVal');
+2
View File
@@ -118,6 +118,8 @@ describe('target tab lifecycle', () => {
expect(clearSource).toContain('resetUserSelectionState()');
expect(clearSource).toContain('deactivateTargetTab(previousTabId, previousContentTarget)');
expect(clearSource).toContain('selectedTabId: null');
expect(clearSource.indexOf('selectedTabId: null'))
.toBeLessThan(clearSource.indexOf('await Promise.all(cleanupTasks)'));
expect(clearSource).toContain("type: 'TARGET_TAB_CLEARED'");
expect(popupSource).toContain("refreshTargetAccessState({ autoSelectMatch: false })");
expect(popupSource).toContain('if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1)');