mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-03 22:45:21 +00:00
fix(sync): harden terminal state races
This commit is contained in:
+5
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)');
|
||||
|
||||
Reference in New Issue
Block a user