fix(sync): close canonical recovery merge blockers

This commit is contained in:
Timo
2026-08-25 22:24:37 +02:00
parent 213a639314
commit 0d23859a9f
14 changed files with 484 additions and 87 deletions
+14 -4
View File
@@ -41,8 +41,10 @@ not. Heartbeats remain observational and do not mutate canonical state.
`ROOM_DATA` materializes the playing position at snapshot creation and advertises
the optional `media-state-v1` capability. A joining/reconnecting client validates
the room/revision, respects Host Control solo mode and Episode Lobby, then sends an
internal `APPLY_CANONICAL_MEDIA_STATE` message to the existing content/video path.
the room/revision and optional privacy-sanitized media title, respects Host Control
solo mode and Episode Lobby, then queues an internal
`APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer
live commands.
That path reuses frame election, Netflix/Disney page-API seeks, native play/pause,
the 2-second drift tolerance, and programmatic-event suppression. Recovery only
completes after playback state and position verification. Transient failures
@@ -52,10 +54,18 @@ snapshot advances from its local receipt time while waiting for a target, and
the apply creates no action history, notification, command ACK, or relay media
event.
Current clients announce `media-state-v1` as an optional client capability and
continue sending accepted media controls while alone on a capable relay. If a
room instead falls back to one legacy client that suppresses solo controls, the
relay clears canonical state so a future joiner receives no snapshot rather than
known-unreliable playback truth.
Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary
room-wide choreography; the next authorized `EXECUTE` commits the latest target
visible to peers to canonical state before the relay target TTL. That TTL is
longer than the client ACK timeout so its scheduled fallback can still land. The
visible to peers to canonical state. Delayed execution is logged but remains
valid until newer accepted playback or lobby state explicitly supersedes it; an
untracked post-restart execute retains legacy relay liveness without inventing a
canonical target. The
offline queue replays an adjacent `PREPARE`/`EXECUTE` pair in one paced batch and
retains both if delivery fails. Per-sender
`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. The
+20 -12
View File
@@ -54,7 +54,7 @@ Payload:
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"clientCapabilities": ["chat-v1", "media-state-v1"],
"protocolVersion": "string, max 16"
}
```
@@ -110,8 +110,9 @@ The internal `currentTime` is the media position at server-owned `updatedAt`.
Playing state advances lazily when a snapshot is requested; paused state stays
fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot
spoof them. The wire snapshot contains the already-projected `currentTime`,
`revision`, `playbackState`, and `updatedBy`, so clients never compare client and
server wall clocks.
`revision`, `playbackState`, `updatedBy`, and an optional privacy-sanitized
`mediaTitle`, so clients never compare client and server wall clocks and retain
the existing cross-episode guard during recovery.
Only accepted, sanitized room controls update canonical state:
@@ -121,11 +122,12 @@ Only accepted, sanitized room controls update canonical state:
established playback state.
- a valid `force_sync_prepare` records only temporary coordination state. The
next authorized `force_sync_execute` commits the latest room-wide prepared
target as playing before `FORCE_SYNC_TARGET_TTL` expires. This relay TTL is
intentionally longer than the client's `FORCE_SYNC_TIMEOUT` ACK wait so the
normal timeout fallback remains deliverable. Expired targets are cleared and
cannot alter canonical state. The latest valid prepare is also the only
post-demotion execute exemption in Host Control mode.
target as playing. A target older than `FORCE_SYNC_TARGET_DELAY_WARNING` is
logged but remains executable until newer room playback supersedes it, because
receivers are already paused. An execute without retained target still uses
the legacy wire fallback after relay restart but cannot invent canonical state.
The latest valid prepare is also the only post-demotion or reconnect execute
exemption in Host Control mode.
`peer_status` heartbeats are observations and never rewrite canonical intent.
For current clients, the relay drops invalid, duplicate, or regressing `seq`
@@ -152,7 +154,12 @@ relaying the established event names, payloads, and order unchanged. This makes
server-first rollout safe: old clients populate recovery state without needing to
understand or acknowledge it, and new clients consume it only when the relay
advertises the capability. No protocol-version or minimum-version bump is
required. Offline `play`/`pause`/`seek` compaction remains the separate
required. New clients also announce `"media-state-v1"` in optional
`join_room.clientCapabilities`; this only tells a capable relay that the client
keeps canonical state current while alone. When a room falls back to one legacy
client, the relay clears potentially stale canonical state instead of recovering
future joiners to unverified solo playback. Offline `play`/`pause`/`seek`
compaction remains the separate
client-owned layer described below rather than part of the relay capability.
### Offline media intent
@@ -347,9 +354,10 @@ The current extension sends sequence/action metadata but no target; the relay us
the latest validated room target retained from `force_sync_prepare`. In
`host-only` mode, only controllers may send it.
The relay also allows that latest valid initiator's execute event after their
controller state changed before execute. Invalid prepares are dropped and grant
no exemption. The retained target expires after `FORCE_SYNC_TARGET_TTL`, which
includes a relay grace period beyond the client's ACK timeout.
controller state changed or their socket reconnected before execute. Invalid
prepares are dropped and grant no exemption. Newer accepted playback/lobby state
explicitly supersedes the prepared target, so its delayed execute is dropped.
Otherwise, even a delayed execute is relayed to release paused receivers.
## Episode Lobby
+61 -5
View File
@@ -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))
+8
View File
@@ -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;
}
+8
View File
@@ -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,
[],
+9 -1
View File
@@ -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' };
+73 -17
View File
@@ -10,7 +10,7 @@ import {
materializeMediaIntent,
reserveLatestMediaIntentSequence
} from '../extension/offline-media-intent.js';
import { FORCE_SYNC_TARGET_TTL, FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -83,7 +83,7 @@ try {
const coalescedRid = 'coalesced-media-'+Date.now();
const legacyReceiver = await c(), coalescingSender = await c();
await j(legacyReceiver, coalescedRid, 'legacy-receiver');
await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1']);
await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1', 'media-state-v1']);
legacyReceiver._m.length = coalescingSender._m.length = 0;
s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 });
@@ -175,12 +175,13 @@ try {
// --- Mixed-version rollout: pre-media-state extension + current extension ---
// Legacy intentionally omits clientCapabilities entirely and uses only the
// pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current
// client advertises only its existing chat capability; media-state support
// is a relay capability and is never required for server acquisition.
// Current clients additionally advertise that they maintain canonical state
// while solo. Recovery itself remains relay-gated and legacy clients still
// omit clientCapabilities entirely.
const mixedMediaRid = 'mixed-media-'+Date.now();
const legacyMedia = await c(), currentMedia = await c();
const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media');
const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1']);
const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning');
assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array');
assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type');
@@ -294,7 +295,7 @@ try {
await w(currentMedia, 'pause');
const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision;
const currentMediaRejoin = await c();
const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1']);
const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision);
assert.equal(mixedRejoinData.mediaState.playbackState, 'paused');
assert.equal(mixedRejoinData.mediaState.currentTime, 1700);
@@ -302,6 +303,27 @@ try {
'current reconnect snapshot reflects the legacy client latest accepted intent');
assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false,
'legacy client receives no new canonical event or ACK requirement');
currentMediaRejoin.close();
await delay(100);
assert.equal(mod.rooms.get(mixedMediaRid).mediaState, null,
'canonical state is cleared when only a legacy solo-suppressing client remains');
close();
resetConnectionRate();
// The inverse must remain true: a capable solo client keeps publishing
// PLAY/PAUSE/SEEK, so removing a legacy peer must not discard valid state.
const capableSoloRid = 'capable-solo-'+Date.now();
const capableSolo = await c(), transientLegacy = await c();
await j(capableSolo, capableSoloRid, 'capable-solo', null, ['chat-v1', 'media-state-v1']);
await j(transientLegacy, capableSoloRid, 'transient-legacy');
capableSolo._m.length = transientLegacy._m.length = 0;
s(capableSolo, 'play', { currentTime: 42, mediaTitle: 'Series S03E04' });
await w(transientLegacy, 'play');
const capableSoloState = { ...mod.rooms.get(capableSoloRid).mediaState };
transientLegacy.close();
await delay(100);
assert.deepEqual(mod.rooms.get(capableSoloRid).mediaState, capableSoloState,
'canonical state remains valid when the sole remaining client advertises media-state-v1');
close();
resetConnectionRate();
@@ -311,12 +333,18 @@ try {
const initialMediaRoom = await j(msa, msrid, 'msa');
assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null');
s(msa, 'play', { currentTime: 100, revision: 999, updatedBy: 'spoofed' });
s(msa, 'play', {
currentTime: 100,
mediaTitle: 'Series S01E02',
revision: 999,
updatedBy: 'spoofed'
});
await delay(120);
const msb = await c();
const playingJoin = await j(msb, msrid, 'msb');
assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1');
assert.equal(playingJoin.mediaState.playbackState, 'playing');
assert.equal(playingJoin.mediaState.mediaTitle, 'Series S01E02');
assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101,
`playing late join receives projected position (${playingJoin.mediaState.currentTime})`);
assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity');
@@ -404,8 +432,8 @@ try {
'authorized EXECUTE commits the latest target visible to legacy peers');
assert.equal(competingForceState.updatedBy, 'msa');
// The initiator's normal ACK timeout must still fit inside relay target
// retention. This is the exact fallback boundary used by background.js.
// The initiator's normal ACK timeout stays below the relay's delayed-target
// warning boundary and commits normally.
s(msa, 'force_sync_prepare', { targetTime: 925 });
await w(msb, 'force_sync_prepare');
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT;
@@ -414,18 +442,39 @@ try {
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 925,
'relay grace accepts EXECUTE at the client ACK-timeout boundary');
// Even beyond the warning boundary, a target that no newer room action
// superseded remains the only safe way to release already-paused peers.
s(msa, 'force_sync_prepare', { targetTime: 950 });
await w(msb, 'force_sync_prepare');
const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState };
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_TTL - 1;
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_DELAY_WARNING - 1;
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
let expiredExecuteDropped = false;
try { await w(msb, 'force_sync_execute', 500); } catch { expiredExecuteDropped = true; }
assert.ok(expiredExecuteDropped, 'an expired Force Sync target rejects delayed EXECUTE');
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeExpiredExecute);
await w(msb, 'force_sync_execute');
assert.equal(mod.rooms.get(msrid).mediaState.revision, beforeExpiredExecute.revision + 1);
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 950,
'a delayed EXECUTE still releases peers and commits its unsuperseded prepared target');
assert.equal(mod.rooms.get(msrid).forceSyncTarget, null);
// A relay restart cannot recover transient PREPARE state. Preserve the old
// wire liveness fallback without inventing a canonical target.
const beforeUntrackedExecute = { ...mod.rooms.get(msrid).mediaState };
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeUntrackedExecute,
'an untracked compatibility EXECUTE relays without canonical mutation');
s(msa, 'force_sync_prepare', { targetTime: 975 });
await w(msb, 'force_sync_prepare');
s(msb, 'seek', { targetTime: 'invalid' });
await w(msa, 'seek');
assert.equal(mod.rooms.get(msrid).forceSyncTarget.targetTime, 975,
'a sanitized no-op SEEK does not supersede an in-flight prepared target');
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 975);
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_prepare', { targetTime: 1_000 });
await w(msb, 'force_sync_prepare');
@@ -830,14 +879,21 @@ try {
close();
resetConnectionRate();
// A transient disconnect clears the Host Control exemption but not the
// validated room target. If the peer rejoins with current authority, its
// recovered EXECUTE must keep live playback and canonical state aligned.
// A transient disconnect removes the co-host role but not the validated
// room target. The same PREPARE initiator may still finish that already
// visible transaction after reconnecting as a host-only guest.
const forceReconnectRid = 'force-reconnect-'+Date.now();
const forceReconnectHost = await c(), forceReconnectPeer = await c();
await j(forceReconnectHost, forceReconnectRid, 'force-host');
await j(forceReconnectPeer, forceReconnectRid, 'force-peer');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectHost, 'set_control_mode', { controlMode: 'host-only' });
await w(forceReconnectHost, 'control_mode');
await w(forceReconnectPeer, 'control_mode');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectHost, 'set_peer_role', { peerId: 'force-peer', controller: true });
await w(forceReconnectPeer, 'control_mode');
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 });
await w(forceReconnectHost, 'force_sync_prepare');
forceReconnectPeer.close();
+71 -34
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_TTL, MAX_MEDIA_TIME } from '../shared/constants.js';
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
commitForceSyncMediaState,
@@ -204,7 +204,8 @@ function normalizeClientCapabilities(value) {
return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1)
.filter(capability => capability === CAPABILITIES.CHAT_V1
|| capability === CAPABILITIES.MEDIA_STATE_V1)
)];
}
@@ -213,6 +214,11 @@ function clientSupportsChat(socket) {
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
}
function clientSupportsMediaState(socket) {
return Array.isArray(socket?.data?.clientCapabilities)
&& socket.data.clientCapabilities.includes(CAPABILITIES.MEDIA_STATE_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
// from generating one broadcast per toggle across all peers.
@@ -295,6 +301,16 @@ function removePeerFromRoom(socketId, roomId, reason) {
// limitation (no host grace period); see KNOWN_LIMITATIONS.md.
const peerRejoining = peerJoinLocks.has(peerId);
const peerGone = !isPeerStillConnected && !peerRejoining;
if (room.peers.size === 1 && !peerRejoining) {
const remainingSocketId = room.peers.values().next().value;
const remainingSocket = io.sockets.sockets.get(remainingSocketId);
if (!clientSupportsMediaState(remainingSocket)) {
// Pre-feature extensions suppress PLAY/PAUSE/SEEK while solo. Their
// last canonical snapshot can therefore become stale before the next
// join; absence is safer than applying known-unreliable room truth.
room.mediaState = null;
}
}
if (peerGone && room.controllers && room.peers.size > 0) {
const wasController = room.controllers.has(peerId);
room.controllers.delete(peerId);
@@ -470,7 +486,10 @@ io.on('connection', (socket) => {
mediaState: null,
// PREPARE is choreography, not stable room intent. Retain its
// validated target only so the matching EXECUTE can commit it.
forceSyncTarget: null
forceSyncTarget: null,
// Distinguishes an unknown target after relay restart from a
// transaction explicitly replaced by newer room playback.
forceSyncSuperseded: false
};
rooms.set(roomId, room);
createdByMe = true;
@@ -612,8 +631,11 @@ io.on('connection', (socket) => {
// FORCE_SYNC_EXECUTE still has to land after demotion —
// otherwise the already-relayed room-wide choreography
// would leave peers paused.
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
const forceSyncInitiator = room.forceSyncTarget?.initiatorPeerId
|| room.forceSyncInitiator;
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE
&& forceSyncInitiator
&& mapping.peerId === forceSyncInitiator;
if (!isOwnForceSyncExecute &&
room.controlMode === CONTROL_MODES.HOST_ONLY &&
!(room.controllers && room.controllers.has(mapping.peerId)) &&
@@ -687,37 +709,33 @@ io.on('connection', (socket) => {
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
const mediaStateNow = Date.now();
if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
const targetExpired = forceSyncTarget
&& (!Number.isFinite(forceSyncTarget.preparedAt)
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_TTL);
if (!forceSyncTarget || targetExpired) {
log('ROOM', `Dropped force_sync_execute ${targetExpired ? 'with an expired target' : 'without a prepared target'} from ${mapping.peerId}`);
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
return;
}
}
if (eventName === EVENTS.PLAY
|| eventName === EVENTS.PAUSE
|| eventName === EVENTS.SEEK
|| eventName === EVENTS.EPISODE_LOBBY
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL) {
// A later room-driving action supersedes unfinished Force
// Sync choreography. Do not let a delayed EXECUTE commit
// an obsolete target after peers have moved elsewhere.
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
}
// Canonical Media State v1: mutate only after rate limiting,
// room mapping, Host Control authorization and sanitization.
// Heartbeats remain observational and never enter this path.
updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, {
now: mediaStateNow,
senderPlaybackState: existing.playbackState
});
const canonicalStateUpdated = updateMediaStateFromControl(
room,
eventName,
relayPayload,
mapping.peerId,
{
now: mediaStateNow,
senderPlaybackState: existing.playbackState,
senderMediaTitle: room.peerData.get(socket.id)?.mediaTitle
}
);
const validLobbyTransition = (eventName === EVENTS.EPISODE_LOBBY
&& typeof relayPayload.expectedTitle === 'string'
&& relayPayload.expectedTitle.length > 0)
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL;
if (canonicalStateUpdated || validLobbyTransition) {
// A later room-driving action supersedes unfinished Force
// Sync choreography. Do not let a delayed EXECUTE commit
// an obsolete target after peers have moved elsewhere.
room.forceSyncSuperseded = true;
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
}
if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
// A malformed PREPARE must neither pause peers nor grant
// the initiator a later Host Control EXECUTE exemption.
@@ -731,23 +749,42 @@ io.on('connection', (socket) => {
// control mode so an everyone -> host-only transition
// cannot strand that already-authorized transaction.
room.forceSyncInitiator = mapping.peerId;
room.forceSyncSuperseded = false;
room.forceSyncTarget = {
initiatorPeerId: mapping.peerId,
targetTime: relayPayload.targetTime,
preparedAt: mediaStateNow
preparedAt: mediaStateNow,
mediaTitle: room.peerData.get(socket.id)?.mediaTitle || null
};
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
const forceSyncTarget = room.forceSyncTarget;
if (!forceSyncTarget && room.forceSyncSuperseded) {
log('ROOM', `Dropped obsolete force_sync_execute after newer room playback from ${mapping.peerId}`);
room.forceSyncInitiator = null;
return;
}
if (forceSyncTarget) {
const targetDelayed = !Number.isFinite(forceSyncTarget.preparedAt)
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_DELAY_WARNING;
if (targetDelayed) {
log('ROOM', `Relaying delayed force_sync_execute from ${mapping.peerId} to release prepared peers`);
}
commitForceSyncMediaState(
room,
forceSyncTarget.targetTime,
mapping.peerId,
mediaStateNow
mediaStateNow,
forceSyncTarget.mediaTitle
);
} else {
// A relay restart loses transient PREPARE state while legacy
// receivers can remain paused in their existing pages. Preserve
// the old wire behavior, but do not invent a canonical target.
log('ROOM', `Relaying force_sync_execute without server target from ${mapping.peerId}`);
}
room.forceSyncInitiator = null;
room.forceSyncTarget = null;
room.forceSyncSuperseded = false;
}
socket.to(mapping.roomId).emit(eventName, relayPayload);
+24 -8
View File
@@ -5,6 +5,11 @@ function clampMediaTime(value) {
return Math.max(0, Math.min(MAX_MEDIA_TIME, value));
}
function normalizeMediaTitle(value) {
if (typeof value !== 'string' || !value) return null;
return value.substring(0, 100);
}
export function effectiveMediaPosition(mediaState, now = Date.now()) {
if (!mediaState) return null;
const currentTime = clampMediaTime(mediaState.currentTime);
@@ -25,15 +30,18 @@ export function snapshotMediaState(mediaState, now = Date.now()) {
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) {
return null;
}
return {
const snapshot = {
revision: mediaState.revision,
playbackState: mediaState.playbackState,
currentTime,
updatedBy: mediaState.updatedBy
};
const mediaTitle = normalizeMediaTitle(mediaState.mediaTitle);
if (mediaTitle) snapshot.mediaTitle = mediaTitle;
return snapshot;
}
function commitMediaState(room, playbackState, currentTime, updatedBy, now) {
function commitMediaState(room, playbackState, currentTime, updatedBy, now, mediaTitle = null) {
const normalizedTime = clampMediaTime(currentTime);
if (normalizedTime === null
|| (playbackState !== 'playing' && playbackState !== 'paused')
@@ -41,21 +49,28 @@ function commitMediaState(room, playbackState, currentTime, updatedBy, now) {
|| !updatedBy) {
return false;
}
room.mediaState = {
const nextState = {
revision: (room.mediaState?.revision || 0) + 1,
playbackState,
currentTime: normalizedTime,
updatedAt: now,
updatedBy
};
const normalizedTitle = normalizeMediaTitle(mediaTitle);
if (normalizedTitle) nextState.mediaTitle = normalizedTitle;
room.mediaState = nextState;
return true;
}
export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, {
now = Date.now(),
senderPlaybackState = null
senderPlaybackState = null,
senderMediaTitle = null
} = {}) {
if (!room || !payload || typeof payload !== 'object') return false;
const mediaTitle = payload.mediaTitle === null
? null
: (normalizeMediaTitle(payload.mediaTitle) || normalizeMediaTitle(senderMediaTitle));
if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) {
const eventPosition = clampMediaTime(payload.currentTime);
@@ -66,7 +81,8 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer
eventName === EVENTS.PLAY ? 'playing' : 'paused',
currentTime,
senderPeerId,
now
now,
mediaTitle
);
}
@@ -74,12 +90,12 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer
const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime);
const playbackState = room.mediaState?.playbackState || senderPlaybackState;
if (targetTime === null) return false;
return commitMediaState(room, playbackState, targetTime, senderPeerId, now);
return commitMediaState(room, playbackState, targetTime, senderPeerId, now, mediaTitle);
}
return false;
}
export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now()) {
return commitMediaState(room, 'playing', targetTime, senderPeerId, now);
export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now(), mediaTitle = null) {
return commitMediaState(room, 'playing', targetTime, senderPeerId, now, mediaTitle);
}
+39 -2
View File
@@ -67,6 +67,36 @@ describe('canonical media state', () => {
expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' });
});
it('tracks only the current sender shared media title and honors an explicit privacy null', () => {
const target = room();
expect(updateMediaStateFromControl(
target,
EVENTS.PLAY,
{ currentTime: 10, mediaTitle: 'Series S01E01' },
'a',
{ now: 1000 }
)).toBe(true);
expect(snapshotMediaState(target.mediaState, 1000).mediaTitle).toBe('Series S01E01');
expect(updateMediaStateFromControl(
target,
EVENTS.SEEK,
{ targetTime: 20 },
'b',
{ now: 2000, senderMediaTitle: 'Series S01E02' }
)).toBe(true);
expect(target.mediaState.mediaTitle).toBe('Series S01E02');
expect(updateMediaStateFromControl(
target,
EVENTS.PAUSE,
{ currentTime: 20, mediaTitle: null },
'b',
{ now: 3000, senderMediaTitle: 'stale S01E01' }
)).toBe(true);
expect(target.mediaState).not.toHaveProperty('mediaTitle');
});
it('ignores client-supplied playback state while seeking', () => {
const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
expect(updateMediaStateFromControl(
@@ -103,7 +133,14 @@ describe('canonical media state', () => {
it('commits Force Sync only at execute time', () => {
const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' });
expect(commitForceSyncMediaState(target, 500, 'b', 2000)).toBe(true);
expect(target.mediaState).toEqual({ revision: 5, playbackState: 'playing', currentTime: 500, updatedAt: 2000, updatedBy: 'b' });
expect(commitForceSyncMediaState(target, 500, 'b', 2000, 'Series S02E03')).toBe(true);
expect(target.mediaState).toEqual({
revision: 5,
playbackState: 'playing',
currentTime: 500,
updatedAt: 2000,
updatedBy: 'b',
mediaTitle: 'Series S02E03'
});
});
});
+1 -1
View File
@@ -64,7 +64,7 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
- `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds.
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
- `FORCE_SYNC_TARGET_TTL`: relay retention for a prepared Force Sync target; includes post-timeout delivery grace.
- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness.
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
+3 -3
View File
@@ -99,7 +99,7 @@ export const MAX_MEDIA_TIME = 86400;
export const HEARTBEAT_INTERVAL = 15000; // 15s
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
// Relay retention must outlive the client's ACK wait. Otherwise the timeout
// fallback EXECUTE arrives exactly when the relay expires its prepared target.
export const FORCE_SYNC_TARGET_TTL = FORCE_SYNC_TIMEOUT + 2000;
// Log unexpectedly delayed EXECUTE delivery after the normal ACK wait plus
// transport grace. The target remains valid until newer room playback replaces it.
export const FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
+136
View File
@@ -381,6 +381,29 @@ test('applies canonical recovery without echoing media commands or activity', as
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
navigator.mediaSession.metadata = new globalThis.MediaMetadata({ title: 'Series S01E02' });
}
});
}, tabId));
const mismatchedApply = await applyCanonicalMediaState(context, extensionId, tabId, {
revision: 12,
playbackState: 'playing',
currentTime: 2,
updatedBy: 'peer-a',
mediaTitle: 'Series S01E01'
});
expect(mismatchedApply).toMatchObject({ status: 'ignored_episode_mismatch' });
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: true });
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
// Wait past seek debounce and play/pause coalescing windows. A leaked native
// echo would have reached background.js and appeared as user activity by now.
await page.waitForTimeout(700);
@@ -470,6 +493,119 @@ test('recovers relay ROOM_DATA through background retries into the packed player
}
});
test('newer mixed-version playback supersedes an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => {
test.setTimeout(45_000);
const relay = await import('../../server/index.js');
let legacy = null;
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-canonical-supersede-${Date.now()}`;
legacy = await connectLegacyRelayClient(port);
await joinLegacyRelayRoom(legacy, roomId, 'legacy-newer-control');
sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 });
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 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 supersession fixture video missing');
video.dataset.koalaCanonicalPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1;
video.dataset.koalaCanonicalPlayAttempts = String(attempts);
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('delayed audit autoplay rejection')), 400);
});
}
});
}
});
}, tabId));
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
await chrome.storage.local.set(settings);
return chrome.runtime.sendMessage({ type: 'CONNECT' });
}, {
serverUrl: `ws://127.0.0.1:${port}`,
useCustomServer: true,
roomId,
password: '',
username: 'current-superseded'
}));
await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaCanonicalPlayAttempts || '0')))
.toBe(1);
sendLegacyRelayEvent(legacy, 'seek', { currentTime: 10, targetTime: 10, seq: 2, actionTimestamp: 2 });
sendLegacyRelayEvent(legacy, 'pause', { currentTime: 10, seq: 3, actionTimestamp: 3 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 3, playbackState: 'paused', currentTime: 10 });
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: true });
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
.toBeGreaterThan(9);
await page.waitForTimeout(1_200);
expect(await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaCanonicalPlayAttempts || '0'))).toBe(1);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
} finally {
try { legacy?.close(); } catch { /* already closed */ }
await relay.stopServerForTests();
}
});
test('keeps canonical media state current while the capable extension is solo', async ({ context, extensionId, baseURL }) => {
test.setTimeout(35_000);
const relay = await import('../../server/index.js');
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-canonical-solo-${Date.now()}`;
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 withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
await chrome.storage.local.set(settings);
return chrome.runtime.sendMessage({ type: 'CONNECT' });
}, {
serverUrl: `ws://127.0.0.1:${port}`,
useCustomServer: true,
roomId,
password: '',
username: 'current-solo'
}));
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ status: 'connected', roomId });
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 8 }))
.toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 8 }))
.toMatchObject({ status: 'ok' });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 8 });
} finally {
await relay.stopServerForTests();
}
});
test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
const first = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage();