mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 04:19:27 +00:00
fix(sync): close canonical recovery merge blockers
This commit is contained in:
+14
-4
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
[],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user