fix(sync): let newer commands own recovery state

This commit is contained in:
Timo
2026-08-27 00:10:33 +02:00
parent 981776d374
commit 59788e0749
2 changed files with 85 additions and 5 deletions
+11 -5
View File
@@ -1237,7 +1237,7 @@
return canonicalMediaApplyGeneration;
}
function cancelCanonicalMediaApply(action = null, video = null) {
function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false) {
canonicalMediaApplyGeneration++;
if ((action === EVENTS.PLAY || action === EVENTS.PAUSE || action === EVENTS.SEEK) && video) {
canonicalSupersedingLocalState = {
@@ -1246,6 +1246,8 @@
: (action === EVENTS.PAUSE ? 'paused' : (video.paused ? 'paused' : 'playing')),
currentTime: action === EVENTS.SEEK ? getSyncCurrentTime(video) : null
};
} else if (!preserveLocalState) {
canonicalSupersedingLocalState = null;
}
return canonicalMediaApplyGeneration;
}
@@ -1495,7 +1497,10 @@
}
if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') {
cancelCanonicalMediaApply();
const preserveLocalState = message.reason === `local ${EVENTS.PLAY}`
|| message.reason === `local ${EVENTS.PAUSE}`
|| message.reason === `local ${EVENTS.SEEK}`;
cancelCanonicalMediaApply(null, null, preserveLocalState);
sendResponse({ status: 'cancelled' });
return true;
}
@@ -1512,9 +1517,6 @@
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
if ([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE].includes(action)) {
cancelCanonicalMediaApply();
}
// Host Control Mode: while watching on our own (desynced), don't apply
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
@@ -1548,6 +1550,10 @@
return;
}
}
if (syncActions.includes(action)) {
cancelCanonicalMediaApply();
}
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
+74
View File
@@ -87,6 +87,18 @@ async function applyCanonicalMediaState(context, extensionId, tabId, mediaState)
}, { tabId, mediaState }));
}
async function sendContentServerCommand(context, extensionId, tabId, action, payload = {}) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => {
return chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
payload,
actionTimestamp: Date.now(),
commandSenderId: 'e2e-newer-command'
});
}, { tabId, action, payload }));
}
async function connectLegacyRelayClient(port) {
const socket = new NodeWebSocket(
`ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=3.1.3&token=${OFFICIAL_SERVER_TOKEN}`
@@ -496,6 +508,68 @@ test('local media input cancels an in-flight canonical recovery', async ({ conte
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
});
test('newer server command clears local recovery state before a delayed apply resolves', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
const video = document.querySelector('#player');
if (!video) throw new Error('canonical server-supersession fixture video missing');
video.pause();
video.currentTime = 0;
const nativePlay = video.play.bind(video);
video.dataset.koalaDelayedPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
video.dataset.koalaDelayedPlayAttempts = String(attempts);
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400)));
}
});
}
});
}, tabId));
let applyResponse = null;
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
revision: 21,
playbackState: 'playing',
currentTime: 6,
updatedBy: 'peer-a'
}).then(response => {
applyResponse = response;
return response;
});
await expect.poll(async () => ({
attempts: await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0')),
response: applyResponse
})).toMatchObject({ attempts: 1, response: null });
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
await page.locator('#player').evaluate(video => {
video.pause();
video.currentTime = 10;
});
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await sendContentServerCommand(context, extensionId, tabId, 'play');
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
await page.waitForTimeout(700);
expect(await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false);
});
test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => {
test.setTimeout(45_000);
const relay = await import('../../server/index.js');