Merge pull request #35 from Shik3i/feat/canonical-media-state-v1

feat(sync): add canonical media state and resilient offline recovery
This commit is contained in:
KoalaDev
2026-08-30 00:02:42 +02:00
committed by GitHub
32 changed files with 5052 additions and 351 deletions
+64
View File
@@ -32,6 +32,70 @@ Ensures all peers are buffered and synchronized before resuming:
> **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections.
4. **Resume**: All peers call `play()` simultaneously.
## 3.1 Canonical Media State v1
The relay keeps one optional, in-memory canonical playback state per active room.
Accepted `PLAY`, `PAUSE`, and `SEEK` commands advance a server-owned revision.
Playing positions advance lazily from the server update time; paused positions do
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 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
retry after 250, 750, 1500, and 3000 ms, while target, heartbeat, and content-boot
signals can retrigger a pending attempt within that bound. A pending playing
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. 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
relay rejects duplicate/regressing current-client media sequences before they
can diverge canonical truth from live receivers.
## 3.2 Offline Media Intent
Canonical Media State and Offline Media Intent have different ownership:
- Canonical Media State is the relay's last accepted shared playback truth.
- Offline Media Intent is one room-scoped client representation of local
`PLAY`/`PAUSE`/`SEEK` commands that have not reached the relay yet.
Contiguous offline controls merge into a bounded logical queue entry. Every
retained coordination event, including Force Sync and Episode Lobby events, is
an ordering barrier. Stale offline `PING`, `PONG`, heartbeat `PEER_STATUS`, and
`EVENT_ACK` frames are not persisted because they are no longer meaningful after
reconnect. Force Sync ACK remains transactional and is not dropped or merged.
Media intent waits for the reconnecting room's `ROOM_DATA`. An authorized intent
takes precedence over the older canonical snapshot, materializes into the
minimum ordered legacy `SEEK` plus `PLAY`/`PAUSE` frames needed by old peers, and
thereby advances relay canonical state normally. With no intent, canonical
recovery is unchanged. Role loss discards room-driving intent before recovery;
intentional Host Control solo mode and an active Episode Lobby remain
authoritative. MV3 session restoration migrates the previous raw queue format,
preserves barriers, repairs `localSeq` monotonically, and rejects another room's
intent.
## 4. Episode Auto-Sync
Maintains continuous synchronized viewing when watching series:
1. **Detection**: `content.js` monitors the Media Session API for title changes.
+107 -5
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"
}
```
@@ -83,13 +83,110 @@ Payload:
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
"mediaState": "canonical media state object or null",
"capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1"]
}
```
`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.
## Canonical Media State v1
Relays advertise this optional recovery primitive with `"media-state-v1"` in
`room_data.capabilities`. Each active room stores at most one state:
```json
{
"revision": 42,
"playbackState": "playing",
"currentTime": 1234.5,
"updatedAt": 1787234425123,
"updatedBy": "peer-id"
}
```
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`, `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:
- `play` uses its valid `currentTime`, or an existing effective canonical position.
- `pause` uses its valid `currentTime`, or freezes an existing effective position.
- `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the
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. 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`
values on room-moving media commands before relay/canonical mutation. Legacy
clients without `seq` retain their existing behavior. Canonical `revision`
orders server-accepted room transitions; it does not replace per-sender order.
On join/reconnect, a capable extension attempts to apply a valid snapshot
through an extension-internal recovery message. Recovery is only marked handled
after playback state and position verification. Transient failures use bounded
retries after 250, 750, 1500, and 3000 ms and can also be retriggered by target,
heartbeat, or content-boot signals. Existing seek/page-API and native-event
suppression prevent `play`, `pause`, or `seek` echoes. Pending recovery is scoped
to the room/revision in `chrome.storage.session`, waits for the selected media
target lifecycle, and projects a still-playing snapshot from its local receipt
time before a delayed apply. It is cleared on leave/switch. Intentional host-only
guest desync and an active Episode Lobby take precedence over snapshot recovery.
Compatibility is additive: new clients use old behavior with a relay that omits
the capability; old clients ignore the extra `room_data` field from a new relay.
A new relay canonicalizes every accepted legacy `play`, `pause`, `seek`, and
matching Force Sync command regardless of `join_room.clientCapabilities`, while
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. 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
Offline media intent is client-side queue state, not a relay protocol feature.
An updated extension coalesces contiguous unsent `play`, `pause`, and `seek`
commands for one room. Retained non-media events are ordering barriers. On a
successful rejoin, the extension first reads `room_data` so current Host Control
and Episode Lobby authority can be applied, then replays an authorized intent as
the minimum existing legacy media-event sequence. Actual wire frames, rather
than logical queue entries, consume the paced reconnect budget.
Pending authorized local intent takes precedence over an older canonical
snapshot because it has not yet been accepted by the relay. Its legacy replay
then updates canonical state like any other accepted control. Without pending
intent, canonical recovery proceeds normally. Intent made stale by a room switch,
role loss, intentional solo mode, or an active Episode Lobby is discarded and
cannot suppress server recovery.
This requires no event, capability, ACK, protocol-version, or minimum-version
change. Old relays receive ordinary `play`/`pause`/`seek`; old peers see only the
same existing relayed events.
Queued adjacent `force_sync_prepare` and `force_sync_execute` entries replay in
one paced batch. If either send fails, the full pair remains queued so a later
retry refreshes the prepared target before executing it.
## Ephemeral encrypted chat
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
@@ -253,9 +350,14 @@ them with the same sanitized relay envelope as other room events, including
### `force_sync_execute`
Payload includes `targetTime`. In `host-only` mode, only controllers may send it.
The relay also allows a matching initiator's execute event after that initiator
started the prepare step, even if their controller state changed before execute.
The current extension sends sequence/action metadata but no target; the relay uses
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 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
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
function sourceBetween(startNeedle, endNeedle) {
const start = backgroundSource.indexOf(startNeedle);
const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
return backgroundSource.slice(start, end);
}
describe('async room-session guards', () => {
it('normalizes persisted room, peer, lobby and Force Sync state before restoration', () => {
const restore = sourceBetween('function ensureState()', '// Start restoration immediately');
expect(restore).toContain("typeof data.currentRoom.roomId === 'string'");
expect(restore).toContain('.map(createPeerData)');
expect(restore).toContain('data.currentRoom.activeLobby,');
expect(restore).toContain('Array.isArray(data.forceSyncAcks)');
expect(restore).toContain('currentRoom && Number.isFinite(data.forceSyncDeadline)');
expect(restore).toContain('const restoredEpisodeLobby = currentRoom');
expect(restore).toContain('data.episodeLobby,');
});
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
});
it('does not return chat context after its room or target changed', () => {
const handler = sourceBetween("message.type === 'GET_CHAT_CONTEXT'", "message.type === 'CHAT_SEND'");
expect(handler).toContain('const roomId = currentRoom.roomId');
expect(handler).toContain('const isCurrentSession = () =>');
expect(handler.indexOf('if (!isCurrentSession() || settings.roomId !== roomId)'))
.toBeGreaterThan(handler.indexOf('await loadLocale'));
expect(handler).not.toContain('roomId: currentRoom.roomId');
});
it('drops heartbeats and episode transitions that cross a room or target switch', () => {
const heartbeat = sourceBetween("message.type === 'HEARTBEAT'", "message.type === 'INJECT_CONTENT_SCRIPT'");
expect(heartbeat).toContain('const heartbeatRoomId = currentRoom?.roomId || null');
expect(heartbeat).toContain("status: 'ignored_stale_session'");
expect(heartbeat.indexOf('currentRoom?.roomId !== heartbeatRoomId'))
.toBeLessThan(heartbeat.indexOf('emit(EVENTS.PEER_STATUS'));
const episode = sourceBetween("message.type === 'EPISODE_CHANGED'", "message.type === 'EPISODE_READY_LOCAL'");
expect(episode).toContain('const isCurrentEpisodeContext = () =>');
expect(episode.match(/if \(!isCurrentEpisodeContext\(\)/g)?.length).toBeGreaterThanOrEqual(2);
const ready = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
expect(ready).toContain('settings.roomId !== lobbyRoomId');
const privacy = sourceBetween("message.type === 'TITLE_PRIVACY_CHANGED'", "message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'");
expect(privacy).toContain('currentRoom?.roomId !== privacyRoomId');
});
it('resolves content-event awaits before mutating canonical or room state', () => {
const handler = sourceBetween("message.type === 'CONTENT_EVENT'", "message.type === 'FORCE_SYNC_ACK'");
const processEventIndex = handler.indexOf('const processEvent = async () =>');
const videoStateIndex = handler.indexOf('await getReadyTabVideoState(tabId)', processEventIndex);
const settingsIndex = handler.indexOf('const settings = await getSettings()', videoStateIndex);
const contextGuardIndex = handler.indexOf("sendResponse({ status: 'ignored_stale_session' })", settingsIndex);
const supersedeIndex = handler.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)', contextGuardIndex);
expect(videoStateIndex).toBeGreaterThan(-1);
expect(settingsIndex).toBeGreaterThan(videoStateIndex);
expect(contextGuardIndex).toBeGreaterThan(settingsIndex);
expect(handler).toContain('(eventRoomId && settings.roomId !== eventRoomId)');
expect(supersedeIndex).toBeGreaterThan(contextGuardIndex);
});
it('serializes new connection attempts behind terminal room teardown', () => {
const teardown = sourceBetween('async function endRoomSession', 'async function leaveRoomAfterIdleGrace');
expect(teardown).toContain('if (roomTeardownPromise) return roomTeardownPromise');
expect(teardown).toContain('performRoomSessionTeardown(options)');
for (const [start, end] of [
["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"],
["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"],
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"]
]) {
expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()');
}
});
});
+981 -208
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
function functionBody(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf(`function ${nextName}(`, start + 1);
expect(start).toBeGreaterThan(-1);
return source.slice(start, end === -1 ? source.length : end);
}
describe('canonical ROOM_DATA recovery contract', () => {
it('is capability-gated and treats absent/null media state as the old-relay path', () => {
const handler = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
expect(handler).toContain('canonicalMediaStateFromRoomData(data)');
expect(handler).toContain("canonicalSnapshot.status === 'unsupported'");
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', () => {
const serverHandler = functionBody(backgroundSource, 'handleServerEvent', 'executeForceSync');
expect(serverHandler).toContain('case EVENTS.PLAY:');
expect(serverHandler).toContain('case EVENTS.PAUSE:');
expect(serverHandler).toContain('case EVENTS.SEEK:');
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_PREPARE:');
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_EXECUTE:');
expect(serverHandler).toContain('case EVENTS.EPISODE_LOBBY:');
expect(serverHandler).toContain('case EVENTS.CONTROL_MODE:');
expect(serverHandler).not.toMatch(/case EVENTS\.(?:PLAY|PAUSE|SEEK):[\s\S]{0,500}MEDIA_STATE_V1/);
});
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(');
expect(apply).not.toContain('EVENT_ACK');
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
const internalHandler = contentSource.slice(contentHandlerStart, serverCommandStart);
expect(internalHandler).toContain('const applyGeneration = beginCanonicalMediaApply()');
expect(internalHandler).toContain('applyCanonicalMediaState(message.mediaState, applyGeneration)');
expect(internalHandler).not.toContain('CMD_ACK');
expect(internalHandler).not.toContain('CONTENT_EVENT');
});
it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => {
expect(backgroundSource).toContain("'canonicalMediaRecovery'");
expect(backgroundSource).toContain('canonicalMediaStateTracker.restore(');
expect(backgroundSource).toContain('CANONICAL_RECOVERY_RETRY_DELAYS');
expect(backgroundSource).toContain('requestCanonicalMediaRecoveryAttempt()');
expect(backgroundSource).toMatch(/message\.type === 'HEARTBEAT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
expect(backgroundSource).toMatch(/message\.type === 'CONTENT_BOOT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/);
expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4);
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
expect(apply).toContain('getPendingProjected(roomId)');
expect(apply).toContain('targetActivationGeneration');
expect(apply).toContain("return { status: 'stale_target' }");
const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt');
expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length');
expect(retry).toContain('latest?.mediaState.revision !== expectedRevision');
const clear = functionBody(backgroundSource, 'clearCanonicalMediaRecovery', 'invalidateChatSession');
expect(clear).toContain('canonicalRecoveryApplyInProgress = null');
});
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
expect(apply).toContain('if (hcmDesynced)');
expect(apply).toContain('if (episodeLobby)');
expect(roomData).toContain('if (hasPendingLocalIntent)');
expect(backgroundSource).toContain('awaitingRoomData = true');
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
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(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'");
expect(supersede).toContain('action,');
expect(supersede).toContain('payload');
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');
expect(apply).toContain("_setSuppress('seek')");
expect(apply).toContain('await tryMediaAction(EVENTS.SEEK');
expect(apply).toContain('await tryMediaAction(EVENTS.PAUSE)');
expect(apply).toContain('await tryMediaAction(EVENTS.PLAY)');
expect(apply).toContain('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)');
expect(apply.indexOf("status: 'applied'"))
.toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)'));
expect(apply).toContain('isCanonicalMediaApplyCurrent(applyGeneration)');
expect(apply).toContain('restoreSupersedingLocalState(video)');
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))
.toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)');
expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'");
expect(contentSource).toContain('cancelCanonicalMediaApply(action, findVideo(), false, payload)');
expect(contentSource).toContain('restorationGeneration !== canonicalMediaApplyGeneration');
expect(contentSource).toContain('holdCanonicalRestorePlaySuppression()');
expect(contentSource).toContain('consumeCanonicalRestorePlaySuppression()');
expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)');
});
});
+188
View File
@@ -0,0 +1,188 @@
import { CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
export function validateCanonicalMediaState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null;
if (value.playbackState !== 'playing' && value.playbackState !== 'paused') return null;
if (typeof value.currentTime !== 'number'
|| !Number.isFinite(value.currentTime)
|| value.currentTime < 0
|| 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,
currentTime: value.currentTime
};
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;
}
export function canonicalMediaStateFromRoomData(roomData) {
const capabilities = Array.isArray(roomData?.capabilities) ? roomData.capabilities : [];
if (!capabilities.includes(CAPABILITIES.MEDIA_STATE_V1)) {
return { status: 'unsupported', mediaState: null };
}
if (roomData.mediaState === null || roomData.mediaState === undefined) {
return { status: 'empty', mediaState: null };
}
const mediaState = validateCanonicalMediaState(roomData.mediaState);
return mediaState
? { status: 'available', mediaState }
: { status: 'invalid', mediaState: null };
}
export function projectCanonicalMediaState(mediaState, receivedAt, now = Date.now()) {
const validated = validateCanonicalMediaState(mediaState);
if (!validated) return null;
if (validated.playbackState !== 'playing') return validated;
const received = typeof receivedAt === 'number' && Number.isFinite(receivedAt)
? receivedAt
: now;
return {
...validated,
currentTime: Math.min(
MAX_MEDIA_TIME,
validated.currentTime + Math.max(0, now - received) / 1000
)
};
}
function normalizeRoomId(roomId) {
return typeof roomId === 'string' && roomId ? roomId : null;
}
export function createCanonicalMediaStateTracker() {
let roomId = null;
let knownRevision = 0;
let appliedRevision = 0;
let pending = null;
function adoptRoom(nextRoomId) {
const normalizedRoomId = normalizeRoomId(nextRoomId);
if (normalizedRoomId === roomId) return false;
roomId = normalizedRoomId;
knownRevision = 0;
appliedRevision = 0;
pending = null;
return true;
}
return {
adoptRoom,
beginRecovery(nextRoomId) {
adoptRoom(nextRoomId);
// A relay restart or an empty-room recreation starts a new in-memory
// revision epoch. ROOM_DATA belongs to this fresh connection, so a
// lower revision is current truth rather than a stale packet from
// the previous epoch.
knownRevision = 0;
appliedRevision = 0;
pending = null;
},
receive(nextRoomId, value, receivedAt = Date.now()) {
adoptRoom(nextRoomId);
const mediaState = validateCanonicalMediaState(value);
if (!roomId || !mediaState) return { status: 'invalid' };
if (mediaState.revision < knownRevision) return { status: 'stale' };
if (mediaState.revision === knownRevision
&& (appliedRevision === mediaState.revision || pending?.mediaState.revision === mediaState.revision)) {
return { status: 'duplicate' };
}
knownRevision = Math.max(knownRevision, mediaState.revision);
pending = {
roomId,
mediaState,
receivedAt: typeof receivedAt === 'number' && Number.isFinite(receivedAt)
? receivedAt
: Date.now()
};
return { status: 'pending', mediaState };
},
getPending(nextRoomId = roomId) {
return pending && pending.roomId === normalizeRoomId(nextRoomId)
? { roomId: pending.roomId, mediaState: { ...pending.mediaState } }
: null;
},
getPendingProjected(nextRoomId = roomId, now = Date.now()) {
if (!pending || pending.roomId !== normalizeRoomId(nextRoomId)) return null;
const mediaState = projectCanonicalMediaState(
pending.mediaState,
pending.receivedAt,
now
);
return mediaState ? { roomId: pending.roomId, mediaState } : null;
},
markHandled(nextRoomId, revision) {
if (normalizeRoomId(nextRoomId) !== roomId
|| !Number.isSafeInteger(revision)
|| revision < 1) {
return false;
}
knownRevision = Math.max(knownRevision, revision);
appliedRevision = Math.max(appliedRevision, revision);
if (pending?.mediaState.revision <= revision) pending = null;
return true;
},
clear() {
adoptRoom(null);
},
restore(value, currentRoomId) {
adoptRoom(currentRoomId);
if (!value || typeof value !== 'object' || value.roomId !== roomId || !roomId) return false;
knownRevision = Number.isSafeInteger(value.knownRevision) && value.knownRevision >= 0
? value.knownRevision
: 0;
appliedRevision = Number.isSafeInteger(value.appliedRevision) && value.appliedRevision >= 0
? Math.min(value.appliedRevision, knownRevision)
: 0;
const restoredPending = validateCanonicalMediaState(value.pending?.mediaState);
if (value.pending?.roomId === roomId
&& restoredPending
&& restoredPending.revision > appliedRevision
&& restoredPending.revision >= knownRevision) {
pending = {
roomId,
mediaState: restoredPending,
receivedAt: typeof value.pending.receivedAt === 'number'
&& Number.isFinite(value.pending.receivedAt)
? value.pending.receivedAt
: Date.now()
};
knownRevision = restoredPending.revision;
}
return true;
},
snapshot() {
return {
roomId,
knownRevision,
appliedRevision,
pending: pending ? {
roomId: pending.roomId,
mediaState: { ...pending.mediaState },
receivedAt: pending.receivedAt
} : null
};
}
};
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import {
canonicalMediaStateFromRoomData,
createCanonicalMediaStateTracker,
projectCanonicalMediaState,
validateCanonicalMediaState
} from './canonical-media-state.js';
const state = (revision, currentTime = revision * 10, playbackState = 'playing') => ({
revision,
currentTime,
playbackState,
updatedBy: 'peer-a'
});
describe('canonical media state validation', () => {
it('accepts a bounded canonical snapshot', () => {
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,
[],
{},
{ revision: 0, playbackState: 'playing', currentTime: 1 },
{ revision: 1.5, playbackState: 'playing', currentTime: 1 },
{ revision: 1, playbackState: 'buffering', currentTime: 1 },
{ revision: 1, playbackState: 'playing', currentTime: NaN },
{ revision: 1, playbackState: 'playing', currentTime: Infinity },
{ revision: 1, playbackState: 'playing', currentTime: -1 },
{ revision: 1, playbackState: 'playing', currentTime: 86401 },
{ revision: 1, playbackState: 'playing', currentTime: '1' }
])('rejects malformed snapshot %#', value => {
expect(validateCanonicalMediaState(value)).toBeNull();
});
});
describe('ROOM_DATA capability compatibility', () => {
const legacyRoomData = {
roomId: 'room-a',
peers: [],
activeLobby: null,
hostPeerId: 'peer-a',
controlMode: 'everyone',
controllers: ['peer-a'],
capabilities: ['host-control', 'co-host', 'chat-v1']
};
it('treats an old relay without capability or mediaState as the unchanged fallback', () => {
expect(canonicalMediaStateFromRoomData(legacyRoomData)).toEqual({
status: 'unsupported',
mediaState: null
});
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, capabilities: undefined })).toEqual({
status: 'unsupported',
mediaState: null
});
});
it('does not consume a stray mediaState unless the relay advertises support', () => {
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, mediaState: state(4) })).toEqual({
status: 'unsupported',
mediaState: null
});
});
it('accepts null as a valid capable-relay state without creating pending recovery', () => {
expect(canonicalMediaStateFromRoomData({
...legacyRoomData,
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
mediaState: null
})).toEqual({ status: 'empty', mediaState: null });
});
it('returns a validated snapshot only for a capable relay', () => {
expect(canonicalMediaStateFromRoomData({
...legacyRoomData,
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
mediaState: state(4)
})).toEqual({ status: 'available', mediaState: state(4) });
});
});
describe('canonical media state tracker', () => {
it('projects a deferred playing snapshot from local receipt time and clamps it', () => {
expect(projectCanonicalMediaState(state(1, 100, 'playing'), 1_000, 31_000))
.toMatchObject({ currentTime: 130, playbackState: 'playing' });
expect(projectCanonicalMediaState(state(1, 100, 'paused'), 1_000, 31_000))
.toMatchObject({ currentTime: 100, playbackState: 'paused' });
expect(projectCanonicalMediaState(state(1, 86_390, 'playing'), 1_000, 31_000).currentTime)
.toBe(86_400);
});
it('accepts a valid snapshot and applies each revision once', () => {
const tracker = createCanonicalMediaStateTracker();
expect(tracker.receive('room-a', state(1)).status).toBe('pending');
expect(tracker.markHandled('room-a', 1)).toBe(true);
expect(tracker.receive('room-a', state(1)).status).toBe('duplicate');
});
it('ignores stale revisions and lets a newer snapshot replace pending state', () => {
const tracker = createCanonicalMediaStateTracker();
tracker.receive('room-a', state(3));
expect(tracker.receive('room-a', state(2)).status).toBe('stale');
expect(tracker.receive('room-a', state(4)).status).toBe('pending');
expect(tracker.getPending('room-a').mediaState).toEqual(state(4));
});
it('never exposes room A state after switching to room B or leaving', () => {
const tracker = createCanonicalMediaStateTracker();
tracker.receive('room-a', state(5));
tracker.adoptRoom('room-b');
expect(tracker.getPending('room-a')).toBeNull();
expect(tracker.getPending('room-b')).toBeNull();
tracker.receive('room-b', state(1));
tracker.clear();
expect(tracker.snapshot()).toEqual({ roomId: null, knownRevision: 0, appliedRevision: 0, pending: null });
});
it('allows the same revision once in a new reconnect recovery cycle', () => {
const tracker = createCanonicalMediaStateTracker();
tracker.receive('room-a', state(8, 80));
tracker.markHandled('room-a', 8);
tracker.beginRecovery('room-a');
expect(tracker.receive('room-a', state(8, 100)).status).toBe('pending');
expect(tracker.getPending().mediaState.currentTime).toBe(100);
});
it('accepts a lower revision from a new relay or room epoch after reconnect', () => {
const tracker = createCanonicalMediaStateTracker();
tracker.receive('room-a', state(12), 1_000);
tracker.markHandled('room-a', 12);
tracker.beginRecovery('room-a');
expect(tracker.receive('room-a', state(1), 2_000).status).toBe('pending');
expect(tracker.getPending('room-a').mediaState.revision).toBe(1);
});
it('persists receipt time so MV3 recovery projects only playing snapshots', () => {
const first = createCanonicalMediaStateTracker();
first.receive('room-a', state(4, 50, 'playing'), 10_000);
const restored = createCanonicalMediaStateTracker();
expect(restored.restore(first.snapshot(), 'room-a')).toBe(true);
expect(restored.getPendingProjected('room-a', 15_000).mediaState.currentTime).toBe(55);
});
it('restores only room-scoped session state', () => {
const first = createCanonicalMediaStateTracker();
first.receive('room-a', state(9));
const stored = first.snapshot();
const sameRoom = createCanonicalMediaStateTracker();
expect(sameRoom.restore(stored, 'room-a')).toBe(true);
expect(sameRoom.getPending('room-a').mediaState.revision).toBe(9);
const otherRoom = createCanonicalMediaStateTracker();
expect(otherRoom.restore(stored, 'room-b')).toBe(false);
expect(otherRoom.getPending('room-b')).toBeNull();
});
it('does not resurrect a pending snapshot that was already applied', () => {
const tracker = createCanonicalMediaStateTracker();
expect(tracker.restore({
roomId: 'room-a',
knownRevision: 7,
appliedRevision: 7,
pending: {
roomId: 'room-a',
mediaState: state(7),
receivedAt: 1_000
}
}, 'room-a')).toBe(true);
expect(tracker.getPending('room-a')).toBeNull();
});
});
+288 -9
View File
@@ -83,6 +83,7 @@
EPISODE_LOBBY: "episode_lobby",
EPISODE_READY: "episode_ready"
};
const MAX_MEDIA_TIME = 86400;
// --- SHARED_EVENTS_INJECT_END ---
// Suppresses native event reporting after a programmatic action.
@@ -90,6 +91,9 @@
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
const canonicalRestorePlaySuppressions = new Set();
let canonicalMediaApplyGeneration = 0;
let canonicalSupersedingLocalState = null;
function _setSuppress(state) {
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
@@ -105,6 +109,25 @@
}
}
function holdCanonicalRestorePlaySuppression() {
const hold = { timeout: null };
hold.timeout = setTimeout(() => canonicalRestorePlaySuppressions.delete(hold), 5000);
canonicalRestorePlaySuppressions.add(hold);
return hold;
}
function releaseCanonicalRestorePlaySuppression(hold) {
if (!canonicalRestorePlaySuppressions.delete(hold)) return;
clearTimeout(hold.timeout);
}
function consumeCanonicalRestorePlaySuppression() {
const hold = canonicalRestorePlaySuppressions.values().next().value;
if (!hold) return false;
releaseCanonicalRestorePlaySuppression(hold);
return true;
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
@@ -1184,13 +1207,13 @@
// --- Helper: site-specific player actions, then native HTML5 fallback ---
function tryMediaAction(action, data) {
const video = findVideo();
if (!video) return;
if (!video) return false;
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
return false;
}
data = { ...data, targetTime: target };
}
@@ -1198,24 +1221,218 @@
try {
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
return true;
}
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
_clearSuppress('playing');
});
const playResult = video.play();
if (playResult && typeof playResult.then === 'function') {
return playResult.then(() => true).catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
_clearSuppress('playing');
return false;
});
}
return true;
} else if (action === EVENTS.PAUSE) {
_setSuppress('paused');
video.pause();
return true;
} else if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
return true;
}
} catch (e) {
return false;
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
return false;
}
}
function beginCanonicalMediaApply() {
canonicalMediaApplyGeneration++;
canonicalSupersedingLocalState = null;
return canonicalMediaApplyGeneration;
}
function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) {
canonicalMediaApplyGeneration++;
const storesPlaybackIntent = action === EVENTS.PLAY
|| action === EVENTS.PAUSE
|| action === EVENTS.SEEK
|| action === EVENTS.FORCE_SYNC_PREPARE
|| action === EVENTS.FORCE_SYNC_EXECUTE;
if (storesPlaybackIntent) {
const payloadTime = Number.isFinite(data?.targetTime)
? data.targetTime
: (Number.isFinite(data?.currentTime) ? data.currentTime : null);
const videoTime = video ? getSyncCurrentTime(video) : null;
canonicalSupersedingLocalState = {
playbackState: action === EVENTS.PLAY || action === EVENTS.FORCE_SYNC_EXECUTE
? 'playing'
: (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE
? 'paused'
: (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)),
currentTime: payloadTime ?? videoTime
};
} else if (!preserveLocalState) {
canonicalSupersedingLocalState = null;
}
return canonicalMediaApplyGeneration;
}
function isCanonicalMediaApplyCurrent(generation) {
return !destroyed && generation === canonicalMediaApplyGeneration;
}
async function restoreSupersedingLocalState(video) {
const restorationGeneration = canonicalMediaApplyGeneration;
const state = canonicalSupersedingLocalState;
if (!state || !video || destroyed || video.isConnected === false) return;
if (state.playbackState === 'paused' && !video.paused) {
_setSuppress('paused');
video.pause();
}
if (Number.isFinite(state.currentTime)) {
const currentTime = getSyncCurrentTime(video);
if (currentTime === null || Math.abs(currentTime - state.currentTime) >= MIN_SEEK_DELTA) {
_setSuppress('seek');
seekVideo(video, state.currentTime);
}
}
if (state.playbackState === 'playing' && video.paused) {
_setSuppress('playing');
const playSuppression = holdCanonicalRestorePlaySuppression();
try {
await video.play();
} catch (error) {
_clearSuppress('playing');
reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn');
} finally {
releaseCanonicalRestorePlaySuppression(playSuppression);
}
}
// A delayed play() can settle after an even newer command already ran.
// Re-assert that newest intent so the old promise cannot win last.
if (!destroyed && restorationGeneration !== canonicalMediaApplyGeneration) {
await restoreSupersedingLocalState(video);
}
}
function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) {
return new Promise((resolve) => {
const interval = 100;
const finishAt = Date.now() + timeoutMs;
const timer = setInterval(() => {
if (!isCanonicalMediaApplyCurrent(applyGeneration)) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(null);
return;
}
const video = findVideo();
const currentTime = video ? getSyncCurrentTime(video) : null;
const projectedTime = mediaState.currentTime
+ (mediaState.playbackState === 'playing'
? Math.max(0, Date.now() - startedAt) / 1000
: 0);
const playbackMatches = video
&& (mediaState.playbackState === 'playing' ? !video.paused : video.paused);
const drift = currentTime === null ? null : projectedTime - currentTime;
if (playbackMatches && drift !== null && Math.abs(drift) < MIN_SEEK_DELTA) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve({ currentTime, drift });
} else if (Date.now() >= finishAt) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(null);
}
}, interval);
seekPollTimers.add(timer);
});
}
async function applyCanonicalMediaState(mediaState, applyGeneration) {
if (!mediaState || typeof mediaState !== 'object'
|| !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')
|| typeof mediaState.currentTime !== 'number'
|| !Number.isFinite(mediaState.currentTime)
|| mediaState.currentTime < 0
|| mediaState.currentTime > MAX_MEDIA_TIME
|| (mediaState.mediaTitle !== undefined
&& mediaState.mediaTitle !== null
&& typeof mediaState.mediaTitle !== 'string')) {
return { status: 'invalid' };
}
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return { status: 'superseded' };
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' };
const currentTime = getSyncCurrentTime(video);
const drift = currentTime === null ? null : mediaState.currentTime - currentTime;
const shouldSeek = drift === null || Math.abs(drift) >= MIN_SEEK_DELTA;
const startedAt = Date.now();
const superseded = async () => {
await restoreSupersedingLocalState(video);
return { status: 'superseded', revision: mediaState.revision };
};
try {
// Paused recovery pauses before seeking; playing recovery seeks before
// starting. Both paths reuse the same site/page-API abstractions and
// native-event suppression as ordinary remote commands.
if (mediaState.playbackState === 'paused' && !video.paused) {
const pauseApplied = await tryMediaAction(EVENTS.PAUSE);
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (!pauseApplied) {
return { status: 'apply_failed', reason: 'pause_action_failed' };
}
}
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (shouldSeek) {
_setSuppress('seek');
const seekApplied = await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime });
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (!seekApplied) {
return { status: 'apply_failed', reason: 'seek_action_failed' };
}
}
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (mediaState.playbackState === 'playing' && video.paused) {
const playApplied = await tryMediaAction(EVENTS.PLAY);
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (!playApplied) {
return { status: 'apply_failed', reason: 'play_action_failed' };
}
}
const verified = await pollCanonicalMediaState(mediaState, startedAt, applyGeneration);
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
if (!verified) {
reportLog(`Canonical media state r${mediaState.revision} could not be verified`, 'warn');
return { status: 'apply_failed', reason: 'verification_timeout' };
}
scheduleProactiveHeartbeat();
return {
status: 'applied',
revision: mediaState.revision,
drift: verified.drift,
sought: shouldSeek
};
} catch (error) {
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
return { status: 'apply_failed' };
}
}
@@ -1319,6 +1536,29 @@
return true;
}
if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') {
const preserveLocalState = message.reason === `local ${EVENTS.PLAY}`
|| message.reason === `local ${EVENTS.PAUSE}`
|| message.reason === `local ${EVENTS.SEEK}`;
cancelCanonicalMediaApply(
message.action,
findVideo(),
preserveLocalState,
message.payload
);
sendResponse({ status: 'cancelled' });
return true;
}
if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') {
const applyGeneration = beginCanonicalMediaApply();
applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse).catch(error => {
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
sendResponse({ status: 'apply_failed', reason: 'unexpected_error' });
});
return true;
}
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
let actionCompleted = false;
@@ -1355,6 +1595,10 @@
return;
}
}
if (syncActions.includes(action)) {
cancelCanonicalMediaApply(action, findVideo(), false, payload);
}
if (action === EVENTS.PLAY) {
tryMediaAction(EVENTS.PLAY);
@@ -1664,6 +1908,11 @@
_clearSuppress(eventState);
return;
}
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
cancelCanonicalMediaApply(action, video);
}
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
// Play/Pause pass through — user may want to immediately pause after tabbing back.
@@ -1818,6 +2067,13 @@
return;
}
// `seeking` normally captured the local state synchronously. Keep this
// as a fallback for players that emit only `seeked`, without replacing
// the earlier paused/playing state after an async stale action resolves.
if (!canonicalSupersedingLocalState) {
cancelCanonicalMediaApply(EVENTS.SEEK, video);
}
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
// — wait 300ms for the user to settle before relaying
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
@@ -1835,6 +2091,18 @@
}, 300);
};
const handleSeeking = event => {
if (!isCurrentVideoEvent(event)) return;
const video = event.currentTarget;
const current = getSyncCurrentTime(video);
if (current === null) return;
if (expectedSeekTime !== null && Math.abs(current - expectedSeekTime) < 1.0) return;
if (Date.now() < visibilityGraceUntil) return;
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
if (delta !== null && delta < MIN_SEEK_DELTA) return;
cancelCanonicalMediaApply(EVENTS.SEEK, video);
};
let lastVideoSrc = undefined;
@@ -1849,6 +2117,7 @@
if (handlers) {
video.removeEventListener('play', handlers.play);
video.removeEventListener('pause', handlers.pause);
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
video.removeEventListener('seeked', handlers.seeked);
video.removeEventListener('loadeddata', handlers.loadeddata);
if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting);
@@ -1881,9 +2150,17 @@
const existing = video._koalaHandlers;
if (existing) detachVideoListeners(video);
activeVideo = video;
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting };
video._koalaHandlers = {
play: handlePlay,
pause: handlePause,
seeking: handleSeeking,
seeked: handleSeeked,
loadeddata: handleLoadedData,
waiting: handleWaiting
};
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('seeking', handleSeeking);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('waiting', handleWaiting);
@@ -2096,6 +2373,8 @@
for (const timer of Object.values(_suppressTimers)) clearTimeout(timer);
_suppressTimers = {};
for (const hold of canonicalRestorePlaySuppressions) clearTimeout(hold.timeout);
canonicalRestorePlaySuppressions.clear();
for (const timer of lifecycleTimeouts) clearTimeout(timer);
lifecycleTimeouts.clear();
for (const timer of seekPollTimers) clearInterval(timer);
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
function sourceBetween(startNeedle, endNeedle) {
const start = backgroundSource.indexOf(startNeedle);
const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
return backgroundSource.slice(start, end);
}
describe('episode lobby completion races', () => {
it('does not read the cleared lobby after a remote ready completes it', () => {
const handler = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:');
const snapshotIndex = handler.indexOf('const readyPeers = [...lobby.readyPeers]');
const roomUpdateIndex = handler.indexOf('currentRoom.activeLobby.readyPeers = readyPeers');
const completionIndex = handler.indexOf('checkEpisodeLobbyCompletion()');
expect(snapshotIndex).toBeGreaterThan(-1);
expect(roomUpdateIndex).toBeGreaterThan(snapshotIndex);
expect(completionIndex).toBeGreaterThan(roomUpdateIndex);
expect(handler.slice(completionIndex)).not.toContain('episodeLobby.readyPeers');
});
it('revalidates the lobby after awaiting settings for a local ready', () => {
const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
const awaitIndex = handler.indexOf('const settings = await getSettings()');
const guardIndex = handler.indexOf('if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId)');
const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)');
expect(handler).toContain('const lobby = episodeLobby');
expect(handler).toContain('const isCurrentLobbyContext = () => episodeLobby === lobby');
expect(awaitIndex).toBeGreaterThan(-1);
expect(guardIndex).toBeGreaterThan(awaitIndex);
expect(mutationIndex).toBeGreaterThan(guardIndex);
});
it('clears the persisted room lobby when completion starts Force Sync', () => {
const execute = sourceBetween('function executeEpisodeLobby()', 'function checkEpisodeLobbyCompletion()');
const roomClearIndex = execute.indexOf('currentRoom.activeLobby = null');
const lobbyClearIndex = execute.indexOf('clearEpisodeLobbyState()');
expect(roomClearIndex).toBeGreaterThan(-1);
expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex);
expect(execute).toContain('chrome.storage.session.set({ currentRoom })');
});
it('validates restored and authoritative lobby state before using readyPeers', () => {
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
});
it('rejects stale or unknown ready senders and correlates new ready frames to the lobby', () => {
const remoteReady = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:');
expect(remoteReady).toContain('const senderPresent = currentRoom?.peers?.some');
expect(remoteReady).toContain('!sameEpisode(data.expectedTitle, lobby.expectedTitle)');
const localReady = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
expect(localReady).toContain('expectedTitle: lobby.expectedTitle');
});
it('adopts authoritative correction data after the relay rejects a competing lobby', () => {
const remoteLobby = sourceBetween('case EVENTS.EPISODE_LOBBY:', 'case EVENTS.EPISODE_READY:');
expect(remoteLobby).toContain('data.authoritative === true && Array.isArray(data.readyPeers)');
expect(remoteLobby).toContain('currentRoom.activeLobby = incomingLobby');
});
it('counts only ready peers who still participate in lobby completion', () => {
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
expect(completion).toContain('const participatingPeerIds = new Set(peers');
expect(completion).toContain('participatingPeerIds.has(candidate)');
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
});
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
const desync = sourceBetween("message.type === 'HCM_DESYNC_STATE'", "message.type === 'LEAVE_ROOM'");
expect(desync).toContain("cancelEpisodeLobby('Initiator entered solo mode')");
expect(desync).toContain('checkEpisodeLobbyCompletion()');
});
});
@@ -0,0 +1,96 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
function functionBody(name, nextName) {
const start = backgroundSource.indexOf(`function ${name}(`);
const end = backgroundSource.indexOf(`function ${nextName}(`, start + 1);
expect(start).toBeGreaterThan(-1);
return backgroundSource.slice(start, end === -1 ? backgroundSource.length : end);
}
describe('offline media intent background integration', () => {
it('keeps online sends immediate and defers reconnect work only while ROOM_DATA is pending', () => {
const emit = functionBody('emit', 'emitLive');
expect(emit).toContain('mustWaitForRoomData');
expect(emit).toContain('&& !flushInProgress');
expect(emit).toContain('socket.send(msg)');
expect(emit).toContain('queueEvent(event, data)');
expect(emit).not.toContain('setTimeout');
expect(backgroundSource).toMatch(/awaitingRoomData = true;[\s\S]*emit\(EVENTS\.JOIN_ROOM/);
});
it('restores and migrates the persisted MV3 queue with a monotonic local sequence', () => {
expect(backgroundSource).toContain('normalizePersistedEventQueue(');
expect(backgroundSource).toContain('localSeq = Math.max(localSeq, maxQueuedSequence(eventQueue))');
expect(backgroundSource).toContain('chrome.storage.session.set({ eventQueue, localSeq })');
expect(backgroundSource).not.toContain('storage.sync.set({ eventQueue');
expect(backgroundSource).toContain('if (restorationTimedOut) return');
});
it('reconciles Host Control, Episode Lobby and solo mode before canonical recovery and replay', () => {
const roomDataStart = backgroundSource.indexOf('case EVENTS.ROOM_DATA:');
const roomDataEnd = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:', roomDataStart);
const roomData = backgroundSource.slice(roomDataStart, roomDataEnd);
const policyIndex = roomData.indexOf('applyQueuedRoomPolicy(data.roomId');
const canonicalIndex = roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
const flushIndex = roomData.indexOf('flushEventQueue(replaySettings)');
expect(policyIndex).toBeGreaterThan(-1);
expect(canonicalIndex).toBeGreaterThan(-1);
expect(flushIndex).toBeGreaterThan(-1);
expect(policyIndex).toBeLessThan(canonicalIndex);
expect(canonicalIndex).toBeLessThan(flushIndex);
expect(roomData).toContain('activeLobby: !!episodeLobby');
expect(roomData).toContain('desynced: hcmDesynced');
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
});
it('clears queued room intent on failed join, leave and room switch paths', () => {
expect(functionBody('clearFailedJoinCredentials', 'invalidateChatSession')).toContain('clearEventQueue()');
expect(functionBody('forceDisconnect', 'persistRoomIdleState')).toContain('eventQueue = []');
expect(functionBody('leaveOldRoomIfSwitching', 'resetAudioProcessingInTab')).toContain('forceDisconnect()');
const leaveHandler = backgroundSource.slice(
backgroundSource.indexOf("message.type === 'LEAVE_ROOM'"),
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
);
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()');
const retryHandler = backgroundSource.slice(
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
backgroundSource.indexOf("message.type === 'GET_STATUS'")
);
expect(retryHandler).toContain('forceDisconnect({ preserveEventQueue: true })');
});
it('paces actual frames through a failure-retaining logical drain', () => {
const flush = functionBody('flushEventQueue', 'addToHistory');
expect(flush).toContain('drainQueuedBatch(drainSource');
expect(flush).toContain('maxWireEvents: FLUSH_BATCH_SIZE');
expect(flush).toContain('return emitLive(frame.event, payload)');
expect(flush).toContain('if (eventQueueVersion === drainVersion)');
expect(flush).toContain('const consumedEntries = new Set(drainSource.slice(0, consumedCount))');
expect(flush).toContain('eventQueue = eventQueue.filter(entry => !consumedEntries.has(entry))');
expect(flush).toContain('flushConnectionGeneration !== connectionGeneration');
expect(flush).not.toMatch(/eventQueue\.shift\(\)[\s\S]*emit\(/);
});
it('generation-scopes socket callbacks and stale ROOM_DATA work', () => {
expect(backgroundSource).toContain('let connectionGeneration = 0');
expect(backgroundSource).toContain('socket !== connectionSocket');
expect(backgroundSource).toContain('handleServerEvent(payload[0], payload[1], generation)');
expect(backgroundSource).toContain('expectedConnectionGeneration !== connectionGeneration');
expect(backgroundSource).toContain('data.roomId !== pendingRoomDataRoomId');
});
it('exposes bounded queue diagnostics without media-title content', () => {
expect(backgroundSource).toContain('queuedLogicalEvents: eventQueue.length');
expect(backgroundSource).toContain('queuedMediaIntents: queuedMediaIntentCount');
expect(backgroundSource).toContain('queuedWireEvents: queuedWireCount(eventQueue)');
expect(backgroundSource).not.toMatch(/Offline media intent[^`'\n]*mediaTitle/);
});
});
+508
View File
@@ -0,0 +1,508 @@
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
export const MEDIA_INTENT_KIND = 'media-intent';
export const MAX_LOGICAL_QUEUE_SIZE = 50;
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
const HOST_GATED_EVENTS = new Set([
...MEDIA_EVENTS,
...FORCE_SYNC_EVENTS,
EVENTS.EPISODE_LOBBY,
EVENTS.EPISODE_LOBBY_CANCEL
]);
function validSequence(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : null;
}
function validTimestamp(value) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
}
function mediaTime(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(MAX_MEDIA_TIME, value));
}
function playbackStateFor(event, data) {
if (event === EVENTS.PLAY) return 'playing';
if (event === EVENTS.PAUSE) return 'paused';
return data?.playbackState === 'playing' || data?.playbackState === 'paused'
? data.playbackState
: null;
}
function mediaPositionFor(event, data) {
if (event === EVENTS.SEEK) {
return mediaTime(data?.targetTime) ?? mediaTime(data?.currentTime);
}
return mediaTime(data?.currentTime);
}
function sanitizedMediaTitle(value) {
return typeof value === 'string' && value ? value.substring(0, 100) : null;
}
function trimQueue(queue, maxEntries) {
const trimmed = queue.slice();
let dropped = 0;
while (trimmed.length > maxEntries) {
if (trimmed[0]?.event === EVENTS.FORCE_SYNC_PREPARE) {
const executeIndex = trimmed.findIndex(entry => entry?.event === EVENTS.FORCE_SYNC_EXECUTE);
if (executeIndex >= 0) {
trimmed.splice(0, executeIndex + 1);
dropped += executeIndex + 1;
continue;
}
// Preserve an incomplete oldest Force Sync transaction. Evict the
// next-oldest work until EXECUTE arrives, at which point the whole
// transaction can be evicted atomically if pressure continues.
if (trimmed.length > 1) {
trimmed.splice(1, 1);
dropped++;
continue;
}
}
trimmed.shift();
dropped++;
}
return { queue: trimmed, dropped };
}
function createIntentEntry(event, data, roomId) {
const currentTime = mediaPositionFor(event, data);
const playbackState = playbackStateFor(event, data);
if (event === EVENTS.SEEK && currentTime === null) return null;
if (playbackState === null && currentTime === null) return null;
return {
kind: MEDIA_INTENT_KIND,
roomId,
intent: {
playbackState,
currentTime,
latestEvent: event,
previousSeq: null,
latestSeq: validSequence(data?.seq),
actionTimestamp: validTimestamp(data?.actionTimestamp),
mediaTitle: sanitizedMediaTitle(data?.mediaTitle),
sourceEventCount: 1
}
};
}
function mergeIntentEntry(entry, event, data) {
const incomingSeq = validSequence(data?.seq);
const previousLatestSeq = validSequence(entry.intent.latestSeq);
if (incomingSeq !== null && previousLatestSeq !== null && incomingSeq <= previousLatestSeq) {
return null;
}
const incomingPosition = mediaPositionFor(event, data);
if (event === EVENTS.SEEK && incomingPosition === null) return entry;
const incomingState = playbackStateFor(event, data);
const merged = {
...entry,
intent: {
...entry.intent,
playbackState: incomingState ?? entry.intent.playbackState,
currentTime: incomingPosition ?? entry.intent.currentTime,
latestEvent: event,
actionTimestamp: validTimestamp(data?.actionTimestamp) ?? entry.intent.actionTimestamp,
mediaTitle: sanitizedMediaTitle(data?.mediaTitle) ?? entry.intent.mediaTitle,
sourceEventCount: entry.intent.sourceEventCount + 1
}
};
if (incomingSeq !== null) {
merged.intent.previousSeq = previousLatestSeq ?? validSequence(entry.intent.previousSeq);
merged.intent.latestSeq = incomingSeq;
}
return merged;
}
export function isMediaQueueEvent(event) {
return MEDIA_EVENTS.has(event);
}
export function isQueuedMediaIntent(entry) {
return entry?.kind === MEDIA_INTENT_KIND
&& typeof entry.roomId === 'string'
&& entry.roomId
&& entry.intent
&& typeof entry.intent === 'object';
}
export function enqueueQueuedEvent(queue, event, data, {
roomId,
maxEntries = MAX_LOGICAL_QUEUE_SIZE
} = {}) {
const next = Array.isArray(queue) ? queue.slice() : [];
let collapsed = 0;
if (STALE_OFFLINE_EVENTS.has(event)) {
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
}
if (!KNOWN_EVENTS.has(event)) {
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
}
if (!isMediaQueueEvent(event)) {
next.push({
kind: 'event',
roomId: typeof roomId === 'string' && roomId ? roomId : null,
event,
data
});
} else if (typeof roomId === 'string' && roomId) {
const last = next.at(-1);
const hasMergeTarget = isQueuedMediaIntent(last) && last.roomId === roomId;
const merged = hasMergeTarget
? mergeIntentEntry(last, event, data)
: null;
if (merged) {
next[next.length - 1] = merged;
collapsed = 1;
} else if (!hasMergeTarget) {
const entry = createIntentEntry(event, data, roomId);
if (entry) next.push(entry);
} else {
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
}
}
const trimmed = trimQueue(next, maxEntries);
return { queue: trimmed.queue, collapsed, dropped: trimmed.dropped, droppedStale: 0 };
}
function normalizeIntentEntry(entry, roomId) {
if (!isQueuedMediaIntent(entry) || entry.roomId !== roomId) return null;
const intent = entry.intent;
const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused'
? intent.playbackState
: null;
const currentTime = mediaTime(intent.currentTime);
const latestEvent = isMediaQueueEvent(intent.latestEvent) ? intent.latestEvent : null;
if (!latestEvent || (playbackState === null && currentTime === null)) return null;
return {
kind: MEDIA_INTENT_KIND,
roomId,
intent: {
playbackState,
currentTime,
latestEvent,
previousSeq: validSequence(intent.previousSeq),
latestSeq: validSequence(intent.latestSeq),
actionTimestamp: validTimestamp(intent.actionTimestamp),
mediaTitle: sanitizedMediaTitle(intent.mediaTitle),
sourceEventCount: Number.isSafeInteger(intent.sourceEventCount) && intent.sourceEventCount > 0
? intent.sourceEventCount
: 1
}
};
}
function repairIntentSequences(entry, minimumSequence) {
const repaired = {
...entry,
intent: { ...entry.intent }
};
const hasState = repaired.intent.playbackState !== null;
const hasPosition = repaired.intent.currentTime !== null;
const previousSeq = validSequence(repaired.intent.previousSeq);
const latestSeq = validSequence(repaired.intent.latestSeq);
if (hasState && hasPosition) {
if (latestSeq === null) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
} else if (latestSeq <= minimumSequence) {
if (previousSeq === null) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
} else {
repaired.intent.previousSeq = minimumSequence + 1;
repaired.intent.latestSeq = minimumSequence + 2;
}
} else if (previousSeq !== null
&& (previousSeq >= latestSeq || previousSeq <= minimumSequence)) {
repaired.intent.previousSeq = minimumSequence + 1;
repaired.intent.latestSeq = Math.max(latestSeq, minimumSequence + 2);
}
} else if (latestSeq === null || latestSeq <= minimumSequence) {
repaired.intent.previousSeq = null;
repaired.intent.latestSeq = minimumSequence + 1;
}
return repaired;
}
export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOGICAL_QUEUE_SIZE) {
if (!Array.isArray(value) || typeof roomId !== 'string' || !roomId) return [];
let normalized = [];
let maximumSequence = 0;
for (const entry of value) {
if (isQueuedMediaIntent(entry)) {
let intentEntry = normalizeIntentEntry(entry, roomId);
if (intentEntry) {
intentEntry = repairIntentSequences(intentEntry, maximumSequence);
normalized.push(intentEntry);
normalized = trimQueue(normalized, maxEntries).queue;
maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry]));
}
continue;
}
if (!entry || typeof entry !== 'object' || typeof entry.event !== 'string') continue;
if (!KNOWN_EVENTS.has(entry.event)) continue;
if (typeof entry.roomId === 'string' && entry.roomId && entry.roomId !== roomId) continue;
if (STALE_OFFLINE_EVENTS.has(entry.event)) continue;
const data = entry.data && typeof entry.data === 'object' ? { ...entry.data } : entry.data;
const queuedSequence = validSequence(data?.seq);
if (data && typeof data === 'object'
&& ((isMediaQueueEvent(entry.event) && queuedSequence === null)
|| (queuedSequence !== null && queuedSequence <= maximumSequence))) {
data.seq = maximumSequence + 1;
}
if (isMediaQueueEvent(entry.event)) {
normalized = enqueueQueuedEvent(normalized, entry.event, data, { roomId, maxEntries }).queue;
} else {
normalized.push({ kind: 'event', roomId, event: entry.event, data });
}
normalized = trimQueue(normalized, maxEntries).queue;
maximumSequence = Math.max(maximumSequence, maxQueuedSequence(normalized));
}
return normalized;
}
export function mediaIntentNeedsSequenceReservation(entry) {
if (!isQueuedMediaIntent(entry)) return false;
const { playbackState, currentTime, previousSeq, latestSeq } = entry.intent;
return playbackState !== null
&& currentTime !== null
&& validSequence(latestSeq) !== null
&& validSequence(previousSeq) === null;
}
export function reserveLatestMediaIntentSequence(queue, roomId, nextSequence) {
const next = Array.isArray(queue) ? queue.slice() : [];
const index = next.length - 1;
const entry = next[index];
if (!isQueuedMediaIntent(entry)
|| entry.roomId !== roomId
|| !mediaIntentNeedsSequenceReservation(entry)
|| validSequence(nextSequence) === null
|| nextSequence <= entry.intent.latestSeq) {
return { queue: next, reserved: false };
}
next[index] = {
...entry,
intent: {
...entry.intent,
previousSeq: entry.intent.latestSeq,
latestSeq: nextSequence
}
};
return { queue: next, reserved: true };
}
function frameData(intent, seq, includeActionTimestamp = true) {
const data = {};
if (seq !== null) data.seq = seq;
if (includeActionTimestamp && intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp;
if (intent.mediaTitle !== null) data.mediaTitle = intent.mediaTitle;
return data;
}
export function materializeMediaIntent(entry) {
if (!isQueuedMediaIntent(entry)) return [];
const intent = entry.intent;
const currentTime = mediaTime(intent.currentTime);
const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused'
? intent.playbackState
: null;
const latestSeq = validSequence(intent.latestSeq);
const previousSeq = validSequence(intent.previousSeq);
const stateEvent = playbackState === 'playing' ? EVENTS.PLAY : EVENTS.PAUSE;
if (playbackState === null && currentTime !== null) {
return [{
event: EVENTS.SEEK,
data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime }
}];
}
if (playbackState !== null && currentTime === null) {
return [{ event: stateEvent, data: frameData(intent, latestSeq) }];
}
if (playbackState === null || currentTime === null) return [];
// A previous-format single PLAY/PAUSE has only one reserved sequence. Keep
// its original one-frame behavior during migration rather than inventing a
// sequence that could overtake a later transactional barrier. In a current
// two-frame intent, only the logical final frame carries actionTimestamp so
// its helper frame cannot falsely acknowledge the final user action.
if (previousSeq === null || latestSeq === null || previousSeq >= latestSeq) {
if (intent.latestEvent === EVENTS.SEEK) {
return [{
event: EVENTS.SEEK,
data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime }
}];
}
return [{
event: stateEvent,
data: { ...frameData(intent, latestSeq), currentTime }
}];
}
const seekFrame = {
event: EVENTS.SEEK,
data: {
...frameData(
intent,
intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq,
intent.latestEvent === EVENTS.SEEK
),
currentTime,
targetTime: currentTime
}
};
const stateFrame = {
event: stateEvent,
data: {
...frameData(
intent,
intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq,
intent.latestEvent !== EVENTS.SEEK
),
currentTime
}
};
return intent.latestEvent === EVENTS.SEEK
? [stateFrame, seekFrame]
: [seekFrame, stateFrame];
}
export function queuedEntryWireCount(entry) {
return isQueuedMediaIntent(entry) ? materializeMediaIntent(entry).length : 1;
}
export function queuedWireCount(queue) {
return Array.isArray(queue)
? queue.reduce((total, entry) => total + queuedEntryWireCount(entry), 0)
: 0;
}
export function queuedMediaIntentCount(queue, roomId = null) {
return Array.isArray(queue)
? queue.filter(entry => isQueuedMediaIntent(entry) && (!roomId || entry.roomId === roomId)).length
: 0;
}
export function hasQueuedMediaIntent(queue, roomId) {
return queuedMediaIntentCount(queue, roomId) > 0;
}
export function discardQueuedMediaIntents(queue, roomId) {
return Array.isArray(queue)
? queue.filter(entry => !isQueuedMediaIntent(entry) || entry.roomId !== roomId)
: [];
}
export function reconcileQueuedRoomIntent(queue, {
roomId,
canControl = true,
activeLobby = false,
desynced = false,
authoritativeLobby = false
} = {}) {
const source = Array.isArray(queue) ? queue : [];
const blockedEvents = !canControl
? HOST_GATED_EVENTS
: new Set([
...(activeLobby || desynced ? FORCE_SYNC_EVENTS : []),
...(authoritativeLobby ? [EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY, EVENTS.EPISODE_LOBBY_CANCEL] : [])
]);
const reconciled = source.filter(entry => {
if (entry?.roomId && entry.roomId !== roomId) return false;
if (isQueuedMediaIntent(entry) && entry.roomId === roomId) {
return canControl && !activeLobby && !desynced;
}
return !blockedEvents?.has(entry?.event);
});
const hasPendingLocalIntent = reconciled.some(entry =>
(isQueuedMediaIntent(entry) && entry.roomId === roomId)
|| (!isQueuedMediaIntent(entry) && FORCE_SYNC_EVENTS.has(entry?.event))
);
return {
queue: reconciled,
discarded: source.length - reconciled.length,
hasPendingLocalIntent
};
}
export function maxQueuedSequence(queue) {
let max = 0;
for (const entry of Array.isArray(queue) ? queue : []) {
if (isQueuedMediaIntent(entry)) {
max = Math.max(max, validSequence(entry.intent.previousSeq) ?? 0, validSequence(entry.intent.latestSeq) ?? 0);
} else {
max = Math.max(max, validSequence(entry?.data?.seq) ?? 0);
}
}
return max;
}
export async function drainQueuedBatch(queue, {
roomId,
maxWireEvents,
sendFrame
}) {
const remaining = Array.isArray(queue) ? queue.slice() : [];
let sentWireEvents = 0;
let droppedStaleIntents = 0;
while (remaining.length > 0) {
const entry = remaining[0];
if (entry?.roomId && entry.roomId !== roomId) {
remaining.shift();
droppedStaleIntents++;
continue;
}
const deliveryEntries = entry?.event === EVENTS.FORCE_SYNC_PREPARE
&& remaining[1]?.event === EVENTS.FORCE_SYNC_EXECUTE
? remaining.slice(0, 2)
: [entry];
const frames = deliveryEntries.flatMap(deliveryEntry => {
const deliveryFrames = isQueuedMediaIntent(deliveryEntry)
? materializeMediaIntent(deliveryEntry)
: [{ event: deliveryEntry.event, data: deliveryEntry.data }];
return deliveryFrames.map(frame => ({ frame, entry: deliveryEntry }));
});
if (frames.length === 0) {
remaining.splice(0, deliveryEntries.length);
continue;
}
if (sentWireEvents > 0 && sentWireEvents + frames.length > maxWireEvents) {
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
}
if (frames.length > maxWireEvents) {
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'entry_exceeds_batch' };
}
let sentEntryFrames = 0;
for (const { frame, entry: deliveryEntry } of frames) {
if (!await sendFrame(frame, deliveryEntry)) {
return {
queue: remaining,
sentWireEvents: sentWireEvents + sentEntryFrames,
droppedStaleIntents,
status: 'send_failed'
};
}
sentEntryFrames++;
}
sentWireEvents += sentEntryFrames;
remaining.splice(0, deliveryEntries.length);
if (sentWireEvents >= maxWireEvents) {
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
}
}
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'drained' };
}
+399
View File
@@ -0,0 +1,399 @@
import { describe, expect, it } from 'vitest';
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
import { canonicalMediaStateFromRoomData } from './canonical-media-state.js';
import {
discardQueuedMediaIntents,
drainQueuedBatch,
enqueueQueuedEvent,
hasQueuedMediaIntent,
materializeMediaIntent,
maxQueuedSequence,
normalizePersistedEventQueue,
queuedEntryWireCount,
queuedMediaIntentCount,
queuedWireCount,
reconcileQueuedRoomIntent,
reserveLatestMediaIntentSequence
} from './offline-media-intent.js';
const roomId = 'room-a';
const media = (event, data, queue = []) => enqueueQueuedEvent(queue, event, data, { roomId }).queue;
function reserve(queue, sequence) {
return reserveLatestMediaIntentSequence(queue, roomId, sequence).queue;
}
describe('offline media intent coalescing', () => {
it('normalizes an empty or roomless persisted queue to empty', () => {
expect(normalizePersistedEventQueue([], roomId)).toEqual([]);
expect(normalizePersistedEventQueue([{ event: EVENTS.PLAY, data: { seq: 1 } }], null)).toEqual([]);
});
it('creates a PLAY intent and reserves ordered legacy SEEK + PLAY frames', () => {
let queue = media(EVENTS.PLAY, { currentTime: 10, seq: 5, actionTimestamp: 100 });
queue = reserve(queue, 6);
expect(queue).toHaveLength(1);
expect(materializeMediaIntent(queue[0])).toEqual([
{ event: EVENTS.SEEK, data: { seq: 5, currentTime: 10, targetTime: 10 } },
{ event: EVENTS.PLAY, data: { seq: 6, actionTimestamp: 100, currentTime: 10 } }
]);
});
it('collapses repeated PLAY and SEEK while preserving final state and monotonic sequences', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
queue = media(EVENTS.PLAY, { currentTime: 20, seq: 3 }, queue);
queue = media(EVENTS.SEEK, { targetTime: 30, seq: 4 }, queue);
expect(queue).toHaveLength(1);
expect(queue[0].intent).toMatchObject({
playbackState: 'playing',
currentTime: 30,
latestEvent: EVENTS.SEEK,
previousSeq: 3,
latestSeq: 4,
sourceEventCount: 3
});
expect(materializeMediaIntent(queue[0]).map(frame => frame.data.seq)).toEqual([3, 4]);
});
it('drops a regressing queued sequence instead of emitting stale ordering', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 10 }), 11);
const result = enqueueQueuedEvent(queue, EVENTS.PAUSE, { currentTime: 20, seq: 9 }, { roomId });
expect(result.droppedStale).toBe(1);
expect(result.queue).toEqual(queue);
expect(maxQueuedSequence(result.queue)).toBe(11);
});
it('merges PLAY -> SEEK -> PAUSE into paused at the final position', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 500, seq: 1 }), 2);
queue = media(EVENTS.SEEK, { targetTime: 600, seq: 3 }, queue);
queue = media(EVENTS.PAUSE, { currentTime: 605, seq: 4 }, queue);
expect(materializeMediaIntent(queue[0])).toEqual([
{ event: EVENTS.SEEK, data: { seq: 3, currentTime: 605, targetTime: 605 } },
{ event: EVENTS.PAUSE, data: { seq: 4, currentTime: 605 } }
]);
});
it('merges PAUSE -> SEEK -> PLAY into playing at the final position', () => {
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 100, seq: 10 }), 11);
queue = media(EVENTS.SEEK, { targetTime: 200, seq: 12 }, queue);
queue = media(EVENTS.PLAY, { currentTime: 205, seq: 13 }, queue);
expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PLAY]);
expect(queue[0].intent).toMatchObject({ playbackState: 'playing', currentTime: 205 });
});
it('clamps finite positions and rejects a SEEK with no trustworthy position', () => {
expect(media(EVENTS.SEEK, { targetTime: NaN, seq: 1 })).toEqual([]);
expect(media(EVENTS.SEEK, { targetTime: -50, seq: 1 })[0].intent.currentTime).toBe(0);
expect(media(EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 50, seq: 1 })[0].intent.currentTime).toBe(MAX_MEDIA_TIME);
});
it('keeps title metadata bounded and never retains arbitrary payload fields', () => {
const queue = media(EVENTS.PAUSE, {
currentTime: 10,
seq: 1,
mediaTitle: 'x'.repeat(200),
password: 'secret',
chatKey: 'secret'
});
expect(queue[0].intent.mediaTitle).toHaveLength(100);
expect(JSON.stringify(queue)).not.toContain('password');
expect(JSON.stringify(queue)).not.toContain('chatKey');
});
it('keeps a thousand-event media burst at one logical entry', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 0, seq: 1 }), 2);
for (let index = 1; index <= 1000; index++) {
queue = media(EVENTS.SEEK, { targetTime: index, seq: index + 2 }, queue);
}
queue = media(EVENTS.PAUSE, { currentTime: 1000, seq: 1003 }, queue);
expect(queue).toHaveLength(1);
expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 1000, sourceEventCount: 1002 });
});
it('treats retained coordination events as barriers', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 50, seq: 3 }, { roomId }).queue;
queue = media(EVENTS.SEEK, { targetTime: 100, seq: 4 }, queue);
queue = media(EVENTS.PAUSE, { currentTime: 120, seq: 5 }, queue);
expect(queue).toHaveLength(3);
expect(queue[0].kind).toBe('media-intent');
expect(queue[1].event).toBe(EVENTS.FORCE_SYNC_PREPARE);
expect(queue[2].kind).toBe('media-intent');
expect(queue[2].intent).toMatchObject({ playbackState: 'paused', currentTime: 120 });
});
it('does not persist stale liveness and command ACK frames as ordering barriers', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
for (const event of [EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]) {
const result = enqueueQueuedEvent(queue, event, { seq: 99 }, { roomId });
expect(result.droppedStale).toBe(1);
queue = result.queue;
}
queue = media(EVENTS.PAUSE, { currentTime: 20, seq: 3 }, queue);
expect(queue).toHaveLength(1);
expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 20 });
});
it('preserves the bounded logical queue cap', () => {
let queue = [];
for (let index = 0; index < 60; index++) {
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { index }, { roomId }).queue;
}
expect(queue).toHaveLength(50);
expect(queue[0].data.index).toBe(10);
expect(queue.at(-1).data.index).toBe(59);
});
it('migrates old raw media entries without crossing transactional barriers', () => {
const restored = normalizePersistedEventQueue([
{ event: EVENTS.PLAY, data: { currentTime: 10, seq: 1 } },
{ event: EVENTS.SEEK, data: { targetTime: 20, seq: 2 } },
{ event: EVENTS.PAUSE, data: { currentTime: 25, seq: 3 } },
{ event: EVENTS.EPISODE_LOBBY, data: { expectedTitle: 'S01E02' } },
{ event: EVENTS.PLAY, data: { currentTime: 30, seq: 4 } }
], roomId);
expect(restored).toHaveLength(3);
expect(restored[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 25 });
expect(restored[1].event).toBe(EVENTS.EPISODE_LOBBY);
expect(restored[2].intent).toMatchObject({ playbackState: 'playing', currentTime: 30 });
expect(maxQueuedSequence(restored)).toBe(4);
});
it('enforces the cap while restoring consecutive persisted media intents', () => {
const persisted = [10, 20, 30].map((currentTime, index) => ({
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused',
currentTime,
latestEvent: EVENTS.PAUSE,
previousSeq: null,
latestSeq: index + 1,
actionTimestamp: index + 1,
mediaTitle: null,
sourceEventCount: 1
}
}));
const restored = normalizePersistedEventQueue(persisted, roomId, 2);
expect(restored).toHaveLength(2);
expect(restored.map(entry => entry.intent.currentTime)).toEqual([20, 30]);
});
it('drops room-scoped barriers from another room and unknown persisted events', () => {
const restored = normalizePersistedEventQueue([
{ kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } },
{ kind: 'event', roomId, event: 'unexpected_event', data: { secret: 'nope' } },
{ kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 } }
], roomId);
expect(restored).toEqual([{
kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 }
}]);
});
it('discards stale-room intent without affecting the new room', () => {
const queue = reserve(media(EVENTS.PAUSE, { currentTime: 500, seq: 1 }), 2);
expect(hasQueuedMediaIntent(queue, roomId)).toBe(true);
expect(discardQueuedMediaIntents(queue, roomId)).toEqual([]);
expect(hasQueuedMediaIntent(queue, 'room-b')).toBe(false);
});
it('drops queued shared intent after controller role loss so canonical recovery can proceed', () => {
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 1200, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 1300, seq: 3 }, { roomId }).queue;
const result = reconcileQueuedRoomIntent(queue, { roomId, canControl: false });
expect(result.queue).toEqual([]);
expect(result.discarded).toBe(2);
expect(result.hasPendingLocalIntent).toBe(false);
});
it('keeps an active Episode Lobby authoritative over queued media and Force Sync', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 100, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 500, seq: 3 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S01E02' }, { roomId }).queue;
const result = reconcileQueuedRoomIntent(queue, { roomId, activeLobby: true });
expect(result.queue).toEqual([{
kind: 'event',
roomId,
event: EVENTS.EPISODE_READY,
data: { title: 'S01E02' }
}]);
expect(result.hasPendingLocalIntent).toBe(false);
});
it('drops stale queued Episode Lobby coordination when ROOM_DATA has an authoritative lobby', () => {
let queue = enqueueQueuedEvent([], EVENTS.EPISODE_LOBBY, { expectedTitle: 'S02E01' }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S02E01' }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_LOBBY_CANCEL, {}, { roomId }).queue;
const result = reconcileQueuedRoomIntent(queue, {
roomId,
activeLobby: true,
authoritativeLobby: true
});
expect(result.queue).toEqual([]);
expect(result.discarded).toBe(3);
});
it('does not let intentional solo mode retain future room-driving intent', () => {
const queue = reserve(media(EVENTS.SEEK, { targetTime: 600, playbackState: 'paused', seq: 1 }), 2);
const result = reconcileQueuedRoomIntent(queue, { roomId, desynced: true });
expect(result.queue).toEqual([]);
expect(result.hasPendingLocalIntent).toBe(false);
});
it('materializes legacy events normally when an old relay has no media-state capability', () => {
expect(canonicalMediaStateFromRoomData({
roomId,
capabilities: ['host-control', 'chat-v1']
})).toEqual({ status: 'unsupported', mediaState: null });
const queue = reserve(media(EVENTS.PAUSE, { currentTime: 75, seq: 20 }), 21);
expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]);
expect(materializeMediaIntent(queue[0]).every(frame =>
frame.data.mediaState === undefined && frame.data.revision === undefined
)).toBe(true);
});
});
describe('offline media intent drain', () => {
it('counts actual wire frames and never splits an intent at a batch boundary', async () => {
let queue = enqueueQueuedEvent([], EVENTS.EPISODE_READY, { seq: 1 }, { roomId }).queue;
queue = reserve(media(EVENTS.PAUSE, { currentTime: 50, seq: 2 }, queue), 3);
const sent = [];
const first = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 2,
sendFrame: async frame => { sent.push(frame); return true; }
});
expect(first.sentWireEvents).toBe(1);
expect(first.queue).toHaveLength(1);
expect(sent.map(frame => frame.event)).toEqual([EVENTS.EPISODE_READY]);
const second = await drainQueuedBatch(first.queue, {
roomId,
maxWireEvents: 2,
sendFrame: async frame => { sent.push(frame); return true; }
});
expect(second.sentWireEvents).toBe(2);
expect(second.queue).toEqual([]);
expect(sent.slice(1).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]);
});
it('retains the whole logical intent after a partial send failure', async () => {
const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5, actionTimestamp: 500 }), 6);
let calls = 0;
const result = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 10,
sendFrame: async () => ++calls === 1
});
expect(result.status).toBe('send_failed');
expect(result.sentWireEvents).toBe(1);
expect(result.queue).toEqual(queue);
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.seq)).toEqual([5, 6]);
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.actionTimestamp))
.toEqual([undefined, 500]);
});
it('drops stale-room intent during drain and preserves unrelated events', async () => {
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 40, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: 3 }, { roomId }).queue;
const sent = [];
const result = await drainQueuedBatch(queue, {
roomId: 'room-b',
maxWireEvents: 10,
sendFrame: async frame => { sent.push(frame); return true; }
});
expect(result.droppedStaleIntents).toBe(2);
expect(sent).toEqual([]);
});
it('repairs regressing sequences across malformed persisted intent entries', () => {
const restored = normalizePersistedEventQueue([
{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'playing', currentTime: 10, latestEvent: EVENTS.PLAY,
previousSeq: 99, latestSeq: 100, actionTimestamp: 1, mediaTitle: null, sourceEventCount: 1
}
},
{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
previousSeq: 49, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
}
}
], roomId);
expect(materializeMediaIntent(restored[0]).map(frame => frame.data.seq)).toEqual([99, 100]);
expect(materializeMediaIntent(restored[1]).map(frame => frame.data.seq)).toEqual([101, 102]);
expect(maxQueuedSequence(restored)).toBe(102);
});
it('preserves a valid legacy single-frame intent during sequence repair', () => {
const restored = normalizePersistedEventQueue([{
kind: 'media-intent',
roomId,
intent: {
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
previousSeq: null, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
}
}], roomId);
expect(materializeMediaIntent(restored[0])).toEqual([{
event: EVENTS.PAUSE,
data: { seq: 50, actionTimestamp: 2, currentTime: 20 }
}]);
});
it('evicts a complete Force Sync transaction instead of orphaning EXECUTE at the cap', () => {
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
for (let index = 0; index < 49; index++) {
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_ACK, { seq: index + 3 }, { roomId }).queue;
}
expect(queue).toHaveLength(49);
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_PREPARE)).toBe(false);
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false);
});
it('keeps adjacent Force Sync PREPARE and EXECUTE in the same replay batch', async () => {
let queue = [];
for (let index = 0; index < 9; index++) {
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: index + 1 }, { roomId }).queue;
}
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 10 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 11 }, { roomId }).queue;
const sent = [];
const result = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 10,
sendFrame: async frame => { sent.push(frame.event); return true; }
});
expect(sent).toEqual(Array(9).fill(EVENTS.EPISODE_READY));
expect(result.queue.map(entry => entry.event)).toEqual([
EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_EXECUTE
]);
});
it('retains the full Force Sync transaction if EXECUTE replay fails', async () => {
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
const result = await drainQueuedBatch(queue, {
roomId,
maxWireEvents: 10,
sendFrame: async frame => frame.event !== EVENTS.FORCE_SYNC_EXECUTE
});
expect(result.status).toBe('send_failed');
expect(result.sentWireEvents).toBe(1);
expect(result.queue).toEqual(queue);
});
it('reports logical and actual-wire queue sizes separately', () => {
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { seq: 3 }, { roomId }).queue;
expect(queuedMediaIntentCount(queue, roomId)).toBe(1);
expect(queuedEntryWireCount(queue[0])).toBe(2);
expect(queuedWireCount(queue)).toBe(3);
});
});
+9 -3
View File
@@ -99,8 +99,8 @@ describe('target tab lifecycle', () => {
});
it('routes every terminal room exit through the full target unhook', () => {
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown');
const teardownEnd = backgroundSource.indexOf('async function endRoomSession', teardownStart);
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
@@ -116,11 +116,17 @@ describe('target tab lifecycle', () => {
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
const controlModeStart = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:');
const controlModeEnd = backgroundSource.indexOf('case EVENTS.ROOM_LIST:', controlModeStart);
expect(backgroundSource.slice(controlModeStart, controlModeEnd)).toContain('if (!currentRoom) break;');
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
expect(serverSource).toContain(
"removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false })"
);
});
it('does not promote a nested media target without confirmed parent visibility', () => {
+14 -1
View File
@@ -107,6 +107,7 @@ function copyExtensionFiles(targetDir, browserName) {
// Robust Extraction using flexible regex
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
const maxMediaTimeMatch = constantsContent.match(/export const MAX_MEDIA_TIME\s*=\s*(\d+);/);
if (!eventsMatch) {
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
@@ -114,9 +115,13 @@ function copyExtensionFiles(targetDir, browserName) {
if (!heartbeatMatch) {
throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js');
}
if (!maxMediaTimeMatch) {
throw new Error('CRITICAL: Could not find MAX_MEDIA_TIME in shared/constants.js');
}
const eventsObject = eventsMatch[1];
const heartbeatVal = heartbeatMatch[1];
const maxMediaTimeVal = maxMediaTimeMatch[1];
const items = fs.readdirSync(extDir);
for (const item of items) {
@@ -136,7 +141,7 @@ function copyExtensionFiles(targetDir, browserName) {
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n ${eEnd}`;
content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection');
@@ -184,6 +189,14 @@ function copyExtensionFiles(targetDir, browserName) {
fs.writeFileSync(destPath, content);
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
} else if (item === 'canonical-media-state.js' || item === 'offline-media-intent.js') {
let content = fs.readFileSync(srcPath, 'utf8');
const sourceImport = "from '../shared/constants.js'";
if (!content.includes(sourceImport)) {
throw new Error(`CRITICAL: Source shared constants import missing in ${item}. Aborting build.`);
}
content = content.replace(sourceImport, "from './shared/constants.js'");
fs.writeFileSync(destPath, content);
} else if (item === 'popup.html') {
let content = fs.readFileSync(srcPath, 'utf8');
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+3
View File
@@ -1,10 +1,12 @@
export const VITEST_COVERAGE_INCLUDE = Object.freeze([
'server/chat.js',
'server/media-state.js',
'server/ops.js',
'server/rate-limiter.js',
'shared/blacklist.js',
'shared/invite-links.js',
'shared/names.js',
'extension/canonical-media-state.js',
'extension/chat-activity.js',
'extension/chat-crypto.js',
'extension/chat-format.js',
@@ -13,6 +15,7 @@ export const VITEST_COVERAGE_INCLUDE = Object.freeze([
'extension/episode-utils.js',
'extension/host-access.js',
'extension/media-frame-target.js',
'extension/offline-media-intent.js',
'extension/title-privacy.js',
'scripts/release-artifact-checks.mjs'
]);
+29 -17
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -15,67 +13,81 @@ export function replaceExactly(text, pattern, replacement, label) {
return text.replace(pattern, replacement);
}
function writeJson(root, relativePath, update) {
function stageJson(stagedUpdates, root, relativePath, update) {
const absolutePath = path.join(root, relativePath);
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
const current = stagedUpdates.has(absolutePath)
? stagedUpdates.get(absolutePath)
: fs.readFileSync(absolutePath, 'utf8');
const value = JSON.parse(current);
update(value);
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
stagedUpdates.set(absolutePath, `${JSON.stringify(value, null, 2)}\n`);
}
function updateText(root, relativePath, pattern, replacement, label) {
function stageText(stagedUpdates, root, relativePath, pattern, replacement, label) {
const absolutePath = path.join(root, relativePath);
const current = fs.readFileSync(absolutePath, 'utf8');
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
const current = stagedUpdates.has(absolutePath)
? stagedUpdates.get(absolutePath)
: fs.readFileSync(absolutePath, 'utf8');
stagedUpdates.set(absolutePath, replaceExactly(current, pattern, replacement, label));
}
export function prepareRelease(version, date = new Date(), root = repoRoot) {
versionFromTag(`v${version}`);
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
writeJson(root, 'package.json', value => { value.version = version; });
writeJson(root, 'package-lock.json', value => {
const stagedUpdates = new Map();
stageJson(stagedUpdates, root, 'package.json', value => { value.version = version; });
stageJson(stagedUpdates, root, 'package-lock.json', value => {
value.version = version;
value.packages[''].version = version;
});
writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
writeJson(root, 'website/version.json', value => {
stageJson(stagedUpdates, root, 'extension/manifest.base.json', value => { value.version = version; });
stageJson(stagedUpdates, root, 'website/version.json', value => {
value.version = version;
value.date = timestamp;
});
updateText(
stageText(
stagedUpdates,
root,
'shared/constants.js',
/export const APP_VERSION = ["'][^"']+["'];/gu,
`export const APP_VERSION = "${version}";`,
'shared/constants.js'
);
updateText(
stageText(
stagedUpdates,
root,
'website/template.html',
/"softwareVersion": "[^"]+"/gu,
`"softwareVersion": "${version}"`,
'website/template.html'
);
updateText(
stageText(
stagedUpdates,
root,
'website/llms.txt',
/Current website release: .+/gu,
`Current website release: ${version}`,
'website/llms.txt'
);
updateText(
stageText(
stagedUpdates,
root,
'README.md',
/Release-v\d+\.\d+\.\d+-blue/gu,
`Release-v${version}-blue`,
'README.md release badge'
);
updateText(
stageText(
stagedUpdates,
root,
'README.md',
/New v\d+\.\d+\.\d+ Release!/gu,
`New v${version} Release!`,
'README.md release banner'
);
for (const [absolutePath, content] of stagedUpdates) {
fs.writeFileSync(absolutePath, content, 'utf8');
}
console.log(`Prepared release v${version} at ${timestamp}`);
}
+12
View File
@@ -87,4 +87,16 @@ describe('release preparation helpers', () => {
.toThrow('vMAJOR.MINOR.PATCH');
expect(readFixture(root)).toEqual(before);
});
it('does not partially update release sources when a later marker is invalid', () => {
const root = createReleaseFixture();
const llmsPath = path.join(root, 'website/llms.txt');
fs.writeFileSync(llmsPath, fs.readFileSync(llmsPath, 'utf8')
.replace(/Current website release: .+/u, 'Release marker intentionally missing'), 'utf8');
const before = readFixture(root);
expect(() => prepareRelease('9.8.7', new Date('2030-01-01T00:00:00Z'), root))
.toThrow('website/llms.txt must contain exactly one release-version marker');
expect(readFixture(root)).toEqual(before);
});
});
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
+581 -11
View File
@@ -5,6 +5,12 @@ import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
import {
enqueueQueuedEvent,
materializeMediaIntent,
reserveLatestMediaIntentSequence
} from '../extension/offline-media-intent.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'));
@@ -22,11 +28,14 @@ async function c() {
}
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e,d]=JSON.parse(r.substring(2));if(e===evt)return d}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
assert.equal((await a(ws))[0],'room_data');
const [event, data] = await a(ws);
assert.equal(event,'room_data');
return data;
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
@@ -40,10 +49,11 @@ try {
// --- Pool: 2 peers in 1 room, test everything ---
const rid = 't-'+Date.now();
const p1 = await c(), p2 = await c();
const p1 = await c(), p2 = await c(), p3 = await c();
// Room + join
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
await j(p1, rid, 'a'); await j(p2, rid, 'b'); await j(p3, rid, 'c');
p1._m.length = p2._m.length = p3._m.length = 0;
// Relay
s(p1,'play',{currentTime:10}); await w(p2,'play');
@@ -59,7 +69,33 @@ try {
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
// Lobby
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
s(p1,'episode_lobby',{expectedTitle:'S01E01'});
await w(p2,'episode_lobby'); await w(p3,'episode_lobby');
s(p3,'episode_lobby',{expectedTitle:'S01E02'});
const authoritativeLobby = await w(p3, 'episode_lobby');
assert.equal(authoritativeLobby.authoritative, true,
'competing initiator receives an authoritative lobby correction');
assert.equal(authoritativeLobby.expectedTitle, 'S01E01');
assert.deepEqual(authoritativeLobby.readyPeers, ['a']);
let competingLobbyDropped = false;
try { await w(p2, 'episode_lobby', 500); } catch { competingLobbyDropped = true; }
assert.ok(competingLobbyDropped, 'relay drops a competing lobby while one is active');
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01');
s(p3,'episode_ready',{expectedTitle:'S01E02',title:'S01E02'});
let staleReadyDropped = false;
try { await w(p2, 'episode_ready', 500); } catch { staleReadyDropped = true; }
assert.ok(staleReadyDropped, 'relay drops ready frames for an obsolete lobby');
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a']);
// Missing expectedTitle remains accepted for old-extension compatibility.
s(p2,'episode_ready',{title:'S01E01'}); await w(p1,'episode_ready');
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a', 'b']);
s(p3,'leave_room',{}); await w(p1,'peer_status');
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01',
'an unrelated departure does not dissolve a lobby with two peers left');
p1._m.length = p2._m.length = 0;
// Leave
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
@@ -67,6 +103,68 @@ try {
close();
resetConnectionRate();
// --- Combined reconnect model: compacted new-client intent over legacy wire ---
// The queue is entirely client-side. Feed its materialized PLAY/PAUSE/SEEK
// frames through the real relay and an old-client-like receiver to prove the
// optimization needs no new event, capability or ACK.
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', 'media-state-v1']);
legacyReceiver._m.length = coalescingSender._m.length = 0;
s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 });
await w(coalescingSender, 'play');
const canonicalBeforeReplay = { ...mod.rooms.get(coalescedRid).mediaState };
let compactedQueue = enqueueQueuedEvent([], 'play', {
currentTime: 500,
seq: 10,
actionTimestamp: 10
}, { roomId: coalescedRid }).queue;
compactedQueue = reserveLatestMediaIntentSequence(compactedQueue, coalescedRid, 11).queue;
for (const [targetTime, seq] of [[540, 12], [600, 13]]) {
compactedQueue = enqueueQueuedEvent(compactedQueue, 'seek', {
currentTime: targetTime,
targetTime,
seq,
actionTimestamp: seq
}, { roomId: coalescedRid }).queue;
}
compactedQueue = enqueueQueuedEvent(compactedQueue, 'pause', {
currentTime: 605,
seq: 14,
actionTimestamp: 14
}, { roomId: coalescedRid }).queue;
assert.equal(compactedQueue.length, 1, 'offline playback burst is one logical queue entry');
const replayFrames = materializeMediaIntent(compactedQueue[0]);
assert.deepEqual(replayFrames.map(frame => frame.event), ['seek', 'pause'],
'compacted intent uses only the minimum existing legacy events');
const legacyReplayPayloads = [];
for (const frame of replayFrames) {
s(coalescingSender, frame.event, frame.data);
legacyReplayPayloads.push([frame.event, await w(legacyReceiver, frame.event)]);
}
assert.equal(legacyReplayPayloads[0][1].targetTime, 605);
assert.equal(legacyReplayPayloads[1][1].currentTime, 605);
for (const [event, payload] of legacyReplayPayloads) {
assert.ok(event === 'seek' || event === 'pause');
assert.equal(payload.mediaState, undefined);
assert.equal(payload.revision, undefined);
}
const canonicalAfterReplay = mod.rooms.get(coalescedRid).mediaState;
assert.equal(canonicalAfterReplay.revision, canonicalBeforeReplay.revision + replayFrames.length);
assert.equal(canonicalAfterReplay.playbackState, 'paused');
assert.equal(canonicalAfterReplay.currentTime, 605);
assert.equal(canonicalAfterReplay.updatedBy, 'coalesce-sender');
const coalescedLateJoiner = await c();
const coalescedLateRoom = await j(coalescedLateJoiner, coalescedRid, 'coalesced-late');
assert.equal(coalescedLateRoom.mediaState.revision, canonicalAfterReplay.revision);
assert.equal(coalescedLateRoom.mediaState.playbackState, 'paused');
assert.equal(coalescedLateRoom.mediaState.currentTime, 605);
assert.equal(coalescedLateRoom.mediaState.updatedBy, 'coalesce-sender');
// --- Stale peer reaper: terminal timeout + clean rejoin ---
const staleClient = await c();
const staleRoomId = 'stale-'+Date.now();
@@ -95,23 +193,431 @@ try {
'ROOM_DATA advertises the host-control capability');
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.ok(capData.capabilities.includes('media-state-v1'), 'ROOM_DATA advertises canonical media state v1');
assert.equal(capData.mediaState, null, 'a new room starts without invented canonical media state');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close();
resetConnectionRate();
// --- 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
// 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', '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');
assert.ok(Array.isArray(legacyRoomData.controllers), 'legacy ROOM_DATA keeps controllers array');
assert.equal(legacyRoomData.mediaState, null, 'new ROOM_DATA field is additive and initially null');
assert.ok(currentRoomData.capabilities.includes('media-state-v1'), 'current client sees relay capability');
legacyMedia._m.length = currentMedia._m.length = 0;
const assertUnchangedMediaWire = (payload, label) => {
for (const field of ['revision', 'mediaState', 'updatedAt', 'updatedBy']) {
assert.equal(payload[field], undefined, `${label} does not add canonical field ${field}`);
}
};
// Legacy -> current: ordinary live relay is unchanged while internal state advances.
s(legacyMedia, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1001 });
const legacyPlayRelay = await w(currentMedia, 'play');
assert.equal(legacyPlayRelay.currentTime, 100);
assert.equal(legacyPlayRelay.senderId, 'legacy-media');
assertUnchangedMediaWire(legacyPlayRelay, 'legacy PLAY relay');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
revision: 1,
playbackState: 'playing',
currentTime: 100,
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
updatedBy: 'legacy-media'
}, 'legacy PLAY canonicalizes without a media-state client capability');
s(legacyMedia, 'seek', { currentTime: 1200, targetTime: 1200, seq: 2, actionTimestamp: 1002 });
const legacySeekRelay = await w(currentMedia, 'seek');
assert.equal(legacySeekRelay.currentTime, 1200);
assert.equal(legacySeekRelay.targetTime, 1200);
assertUnchangedMediaWire(legacySeekRelay, 'legacy SEEK relay');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
revision: 2,
playbackState: 'playing',
currentTime: 1200,
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
updatedBy: 'legacy-media'
});
s(legacyMedia, 'pause', { currentTime: 1200, seq: 3, actionTimestamp: 1003 });
const legacyPauseRelay = await w(currentMedia, 'pause');
assert.equal(legacyPauseRelay.currentTime, 1200);
assertUnchangedMediaWire(legacyPauseRelay, 'legacy PAUSE relay');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
revision: 3,
playbackState: 'paused',
currentTime: 1200,
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
updatedBy: 'legacy-media'
});
// Current -> legacy: old receive path sees the same ordinary events and fields.
s(currentMedia, 'play', { currentTime: 1300, seq: 1, actionTimestamp: 2001 });
const currentPlayRelay = await w(legacyMedia, 'play');
assert.equal(currentPlayRelay.currentTime, 1300);
assertUnchangedMediaWire(currentPlayRelay, 'current PLAY relay to legacy client');
s(currentMedia, 'seek', { currentTime: 1400, targetTime: 1400, seq: 2, actionTimestamp: 2002 });
const currentSeekRelay = await w(legacyMedia, 'seek');
assert.equal(currentSeekRelay.targetTime, 1400);
assertUnchangedMediaWire(currentSeekRelay, 'current SEEK relay to legacy client');
// Legacy Force Sync needs no new target on EXECUTE, event or ACK. Canonical
// bookkeeping remains internal and commits only after the existing execute.
const beforeLegacyPrepare = { ...mod.rooms.get(mixedMediaRid).mediaState };
s(legacyMedia, 'force_sync_prepare', { targetTime: 1600, seq: 4, actionTimestamp: 1004 });
const legacyPrepareRelay = await w(currentMedia, 'force_sync_prepare');
assert.equal(legacyPrepareRelay.targetTime, 1600);
assertUnchangedMediaWire(legacyPrepareRelay, 'legacy Force Sync PREPARE relay');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, beforeLegacyPrepare,
'legacy PREPARE remains choreography and does not commit canonical state');
s(currentMedia, 'force_sync_ack', { seq: 3 });
const currentAckRelay = await w(legacyMedia, 'force_sync_ack');
assert.equal(currentAckRelay.senderId, 'current-media');
assertUnchangedMediaWire(currentAckRelay, 'existing Force Sync ACK relay');
s(legacyMedia, 'force_sync_execute', { seq: 5, actionTimestamp: 1005 });
const legacyExecuteRelay = await w(currentMedia, 'force_sync_execute');
assert.equal(legacyExecuteRelay.targetTime, undefined, 'legacy EXECUTE still requires no target field');
assertUnchangedMediaWire(legacyExecuteRelay, 'legacy Force Sync EXECUTE relay');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
revision: beforeLegacyPrepare.revision + 1,
playbackState: 'playing',
currentTime: 1600,
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
updatedBy: 'legacy-media'
}, 'legacy Force Sync EXECUTE commits only internal canonical state');
// Host Control remains the sole authorization chokepoint, independent of
// media-state client knowledge.
s(legacyMedia, 'set_control_mode', { controlMode: 'host-only' });
await w(legacyMedia, 'control_mode');
await w(currentMedia, 'control_mode');
legacyMedia._m.length = currentMedia._m.length = 0;
const mixedHostBaseline = { ...mod.rooms.get(mixedMediaRid).mediaState };
s(currentMedia, 'seek', { currentTime: 1650, targetTime: 1650, seq: 4, actionTimestamp: 2004 });
let mixedGuestRejected = false;
try { await w(legacyMedia, 'seek', 600); } catch { mixedGuestRejected = true; }
assert.ok(mixedGuestRejected, 'current guest command remains rejected in mixed host-only room');
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, mixedHostBaseline,
'rejected mixed-version guest command does not mutate or revise canonical state');
s(legacyMedia, 'seek', { currentTime: 1700, targetTime: 1700, seq: 6, actionTimestamp: 1006 });
const legacyHostSeek = await w(currentMedia, 'seek');
assert.equal(legacyHostSeek.targetTime, 1700, 'allowed legacy controller command relays normally');
assert.equal(mod.rooms.get(mixedMediaRid).mediaState.updatedBy, 'legacy-media');
assert.equal(mod.rooms.get(mixedMediaRid).mediaState.currentTime, 1700);
// Make the final stable intent legacy-owned, then reconnect only the current client.
s(legacyMedia, 'pause', { currentTime: 1700, seq: 7, actionTimestamp: 1007 });
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', 'media-state-v1']);
assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision);
assert.equal(mixedRejoinData.mediaState.playbackState, 'paused');
assert.equal(mixedRejoinData.mediaState.currentTime, 1700);
assert.equal(mixedRejoinData.mediaState.updatedBy, 'legacy-media',
'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();
// --- Canonical Media State v1: late join, pause, seek and reconnect ---
const msrid = 'media-state-'+Date.now();
const msa = await c();
const initialMediaRoom = await j(msa, msrid, 'msa');
assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null');
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');
s(msa, 'pause', { currentTime: 150 });
await delay(40);
const pausedRevision = mod.rooms.get(msrid).mediaState.revision;
await delay(100);
const msc = await c();
const pausedJoin = await j(msc, msrid, 'msc');
assert.equal(pausedJoin.mediaState.revision, pausedRevision);
assert.equal(pausedJoin.mediaState.playbackState, 'paused');
assert.equal(pausedJoin.mediaState.currentTime, 150, 'paused late join position does not advance');
s(msa, 'seek', { currentTime: 500, targetTime: 600 });
await delay(40);
const seekState = mod.rooms.get(msrid).mediaState;
assert.equal(seekState.currentTime, 600, 'SEEK uses targetTime rather than currentTime');
assert.equal(seekState.playbackState, 'paused', 'SEEK preserves canonical playback state');
s(msa, 'play', { currentTime: 1800 });
await delay(60);
const reconnect1 = await c();
const reconnectFirst = await j(reconnect1, msrid, 'reconnect');
reconnect1.close();
await delay(120);
const beforePeerDedupe = { ...mod.rooms.get(msrid).mediaState };
const reconnect2 = await c();
const reconnectSecond = await j(reconnect2, msrid, 'reconnect');
assert.equal(reconnectSecond.mediaState.revision, reconnectFirst.mediaState.revision,
'lazy clock projection does not increment revision');
assert.ok(reconnectSecond.mediaState.currentTime > reconnectFirst.mediaState.currentTime,
'reconnect receives a newly projected playing position');
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforePeerDedupe,
'peer dedupe/reconnect does not reset or revise canonical state');
const beforeOrderingRevision = mod.rooms.get(msrid).mediaState.revision;
s(msa, 'seek', { targetTime: 100 });
await delay(30);
s(msb, 'seek', { targetTime: 200 });
await delay(40);
const orderedState = mod.rooms.get(msrid).mediaState;
assert.equal(orderedState.revision, beforeOrderingRevision + 2, 'accepted controllers increment revision in server order');
assert.equal(orderedState.currentTime, 200, 'last accepted controller wins');
assert.equal(orderedState.updatedBy, 'msb');
s(msb, 'seek', { targetTime: 300 });
await delay(30);
s(msa, 'seek', { targetTime: 400 });
await delay(40);
const reverseOrderedState = mod.rooms.get(msrid).mediaState;
assert.equal(reverseOrderedState.revision, orderedState.revision + 2);
assert.equal(reverseOrderedState.currentTime, 400, 'reversing server order reverses the winning controller');
assert.equal(reverseOrderedState.updatedBy, 'msa');
// Heartbeats remain observations and cannot rewrite canonical intent.
const beforeHeartbeat = { ...reverseOrderedState };
s(msa, 'peer_status', { playbackState: 'paused', currentTime: 999 });
await delay(40);
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'PEER_STATUS does not mutate canonical state');
// Force Sync PREPARE is choreography; matching EXECUTE commits its final target.
s(msa, 'force_sync_prepare', { targetTime: 700 });
await delay(40);
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'Force Sync PREPARE does not mutate canonical state');
s(msa, 'force_sync_execute', {});
await delay(40);
const forceSyncState = mod.rooms.get(msrid).mediaState;
assert.equal(forceSyncState.revision, beforeHeartbeat.revision + 1);
assert.equal(forceSyncState.playbackState, 'playing');
assert.equal(forceSyncState.currentTime, 700);
// The legacy wire exposes one room-wide prepared target. A later PREPARE
// replaces what every peer has just sought to; any currently authorized
// EXECUTE must commit that visible target instead of clearing it unmatched.
s(msa, 'force_sync_prepare', { targetTime: 800 });
await w(msb, 'force_sync_prepare');
s(msb, 'force_sync_prepare', { targetTime: 900 });
await w(msa, 'force_sync_prepare');
const beforeCompetingExecute = { ...mod.rooms.get(msrid).mediaState };
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
const competingForceState = mod.rooms.get(msrid).mediaState;
assert.equal(competingForceState.revision, beforeCompetingExecute.revision + 1);
assert.equal(competingForceState.currentTime, 900,
'authorized EXECUTE commits the latest target visible to legacy peers');
assert.equal(competingForceState.updatedBy, 'msa');
// 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;
s(msa, 'force_sync_execute', {});
await w(msb, 'force_sync_execute');
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_DELAY_WARNING - 1;
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
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');
s(msb, 'pause', { currentTime: 1_100 });
await w(msa, 'pause');
const supersedingMediaState = { ...mod.rooms.get(msrid).mediaState };
msa._m.length = msb._m.length = 0;
s(msa, 'force_sync_execute', {});
let orphanExecuteDropped = false;
try { await w(msb, 'force_sync_execute', 500); } catch { orphanExecuteDropped = true; }
assert.ok(orphanExecuteDropped, 'an action that supersedes PREPARE makes delayed EXECUTE invalid');
assert.deepEqual(mod.rooms.get(msrid).mediaState, supersedingMediaState);
// Active Episode Lobby is additive ROOM_DATA state and does not rewrite mediaState.
s(msa, 'episode_lobby', { expectedTitle: 'S02E03' });
await delay(40);
const beforeLobbyJoin = { ...mod.rooms.get(msrid).mediaState };
const msLobbyJoiner = await c();
const lobbyRoomData = await j(msLobbyJoiner, msrid, 'ms-lobby');
assert.equal(lobbyRoomData.activeLobby.expectedTitle, 'S02E03');
assert.equal(lobbyRoomData.mediaState.revision, beforeLobbyJoin.revision);
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, 'Episode Lobby does not mutate canonical state');
s(msa, 'leave_room', {});
await delay(50);
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin,
'host disconnect/reassignment preserves canonical state and revision');
close();
resetConnectionRate();
// --- Canonical Media State v1: Host Control and validation chokepoints ---
const msgateRid = 'media-gate-'+Date.now();
const msgHost = await c(), msgGuest = await c(), msgUnjoined = await c();
await j(msgHost, msgateRid, 'msg-host');
await j(msgGuest, msgateRid, 'msg-guest');
s(msgHost, 'play', { currentTime: 10 });
await delay(40);
s(msgHost, 'set_control_mode', { controlMode: 'host-only' });
await w(msgGuest, 'control_mode');
msgHost._m.length = msgGuest._m.length = 0;
const gatedBaseline = { ...mod.rooms.get(msgateRid).mediaState };
s(msgGuest, 'seek', { targetTime: 900 });
await delay(80);
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, gatedBaseline,
'host-only rejected guest cannot mutate canonical state or revision');
s(msgHost, 'seek', { targetTime: 800 });
await delay(40);
assert.equal(mod.rooms.get(msgateRid).mediaState.revision, gatedBaseline.revision + 1);
assert.equal(mod.rooms.get(msgateRid).mediaState.currentTime, 800);
// Current receivers ignore duplicate/regressing seq. The relay must make the
// same decision before canonical mutation so late joiners see the same truth.
msgHost._m.length = msgGuest._m.length = 0;
s(msgHost, 'play', { currentTime: 820, seq: 10 });
await w(msgGuest, 'play');
const sequencedBaseline = { ...mod.rooms.get(msgateRid).mediaState };
const sequencedPeerBaseline = {
...Array.from(mod.rooms.get(msgateRid).peerData.values())
.find(peer => peer.peerId === 'msg-host')
};
s(msgHost, 'pause', { currentTime: 1, playbackState: 'paused', seq: 10 });
let duplicateSequenceDropped = false;
try { await w(msgGuest, 'pause', 500); } catch { duplicateSequenceDropped = true; }
assert.ok(duplicateSequenceDropped, 'duplicate media seq is not relayed');
s(msgHost, 'seek', { targetTime: 5, seq: 9 });
let staleSequenceDropped = false;
try { await w(msgGuest, 'seek', 500); } catch { staleSequenceDropped = true; }
assert.ok(staleSequenceDropped, 'regressing media seq is not relayed');
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, sequencedBaseline,
'regressing media seq cannot revise canonical state');
assert.deepEqual(
Array.from(mod.rooms.get(msgateRid).peerData.values())
.find(peer => peer.peerId === 'msg-host'),
sequencedPeerBaseline,
'duplicate/regressing media seq cannot alter peer state used by later canonical updates');
const msgLateJoiner = await c();
const msgLateRoom = await j(msgLateJoiner, msgateRid, 'msg-late');
assert.equal(msgLateRoom.mediaState.revision, sequencedBaseline.revision);
assert.equal(msgLateRoom.mediaState.playbackState, sequencedBaseline.playbackState);
assert.ok(msgLateRoom.mediaState.currentTime >= sequencedBaseline.currentTime
&& msgLateRoom.mediaState.currentTime < sequencedBaseline.currentTime + 5,
'late joiner receives the accepted playing state with normal snapshot projection');
const validationBaseline = { ...mod.rooms.get(msgateRid).mediaState };
for (const invalidPayload of [{ targetTime: null }, { targetTime: '50' }, { targetTime: {} }, {}]) {
s(msgHost, 'seek', invalidPayload);
}
s(msgUnjoined, 'seek', { roomId: msgateRid, targetTime: 999, updatedBy: 'msg-host', revision: 999999 });
await delay(80);
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, validationBaseline,
'invalid and unjoined/cross-room payloads cannot corrupt canonical state');
s(msgHost, 'leave_room', {});
s(msgGuest, 'leave_room', {});
s(msgLateJoiner, 'leave_room', {});
await delay(80);
assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room');
// --- Terminal room timeout: coded error + complete membership cleanup ---
const timeoutClient = await c();
const timeoutPeer = await c();
const timeoutRoomId = 'timeout-'+Date.now();
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
timeoutClient._m.length = 0;
await j(timeoutPeer, timeoutRoomId, 'timeout-peer-2');
timeoutClient._m.length = timeoutPeer._m.length = 0;
mod.rooms.get(timeoutRoomId).lastActivity = 0;
mod.cleanupInactiveRooms(Date.now());
const [timeoutEvent, timeoutData] = await a(timeoutClient);
assert.equal(timeoutEvent, 'error');
const timeoutData = await w(timeoutClient, 'error');
const timeoutPeerData = await w(timeoutPeer, 'error');
assert.equal(timeoutData.code, 'room_closed');
assert.equal(timeoutData.message, 'Room closed');
assert.equal(timeoutPeerData.code, 'room_closed');
await delay(80);
assert.deepEqual(timeoutClient._m, [], 'terminal room cleanup emits nothing after room_closed');
assert.deepEqual(timeoutPeer._m, [], 'terminal room cleanup emits nothing after room_closed');
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
timeoutClient._m.length = 0;
// The same connected socket must be able to join that room again. This
// proves timeout cleanup removed its stale socketToRoom membership.
@@ -370,6 +876,70 @@ try {
close();
resetConnectionRate();
// A valid PREPARE remains executable if the room changes from everyone to
// host-only before EXECUTE. An invalid PREPARE grants no such exemption.
const transitionRid = 'force-transition-'+Date.now();
const transitionHost = await c(), transitionGuest = await c();
await j(transitionHost, transitionRid, 'transition-host');
await j(transitionGuest, transitionRid, 'transition-guest');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_prepare', { targetTime: 321 });
await w(transitionHost, 'force_sync_prepare');
s(transitionHost, 'set_control_mode', { controlMode: 'host-only' });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_execute', {});
await w(transitionHost, 'force_sync_execute');
assert.equal(mod.rooms.get(transitionRid).mediaState.currentTime, 321);
await delay(550);
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: true });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_prepare', { targetTime: 'invalid' });
let invalidPrepareRelayed = false;
try { await w(transitionHost, 'force_sync_prepare', 500); } catch { invalidPrepareRelayed = true; }
assert.ok(invalidPrepareRelayed, 'invalid PREPARE is not relayed');
await delay(550);
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: false });
await w(transitionGuest, 'control_mode');
transitionHost._m.length = transitionGuest._m.length = 0;
s(transitionGuest, 'force_sync_execute', {});
let invalidExecuteGated = false;
try { await w(transitionHost, 'force_sync_execute', 500); } catch { invalidExecuteGated = true; }
assert.ok(invalidExecuteGated, 'invalid PREPARE grants no post-demotion EXECUTE exemption');
close();
resetConnectionRate();
// 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();
await delay(100);
const forceReconnectReplacement = await c();
await j(forceReconnectReplacement, forceReconnectRid, 'force-peer');
forceReconnectHost._m.length = forceReconnectReplacement._m.length = 0;
s(forceReconnectReplacement, 'force_sync_execute', {});
await w(forceReconnectHost, 'force_sync_execute');
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.currentTime, 444);
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.playbackState, 'playing');
close();
resetConnectionRate();
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
const grid = 'h1b-'+Date.now();
const go = await c(), gg = await c();
@@ -450,12 +1020,12 @@ try {
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
// New → old
mxo._m.length = mxn._m.length = 0;
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
close();
resetConnectionRate();
+1
View File
@@ -106,5 +106,6 @@ and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
| File | Purpose |
|---|---|
| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown |
| `media-state.js` | Canonical room media clock, control updates, and projected ROOM_DATA snapshots |
| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals |
| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation |
+213 -34
View File
@@ -4,8 +4,13 @@ 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 } 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,
snapshotMediaState,
updateMediaStateFromControl
} from './media-state.js';
import {
buildHealthPayload,
checkCooldown,
@@ -171,6 +176,18 @@ const HOST_ONLY_GATED_EVENTS = new Set([
EVENTS.EPISODE_LOBBY_CANCEL
]);
// Current clients sequence room-moving media commands. The relay mirrors the
// receiver-side stale guard so a frame ignored by live peers cannot become the
// canonical snapshot shown to a later joiner. Legacy clients omit seq entirely
// and retain their pre-feature behavior.
const SEQUENCED_ROOM_EVENTS = new Set([
EVENTS.PLAY,
EVENTS.PAUSE,
EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE,
EVENTS.FORCE_SYNC_EXECUTE
]);
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
@@ -178,7 +195,8 @@ const SERVER_CAPABILITIES = [
CAPABILITIES.HOST_CONTROL,
CAPABILITIES.CO_HOST,
CAPABILITIES.CHAT,
CAPABILITIES.CHAT_V1
CAPABILITIES.CHAT_V1,
CAPABILITIES.MEDIA_STATE_V1
];
function normalizeClientCapabilities(value) {
@@ -186,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)
)];
}
@@ -195,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.
@@ -226,8 +250,10 @@ function log(type, message, details = '') {
* @param {string} socketId - The socket.id being removed.
* @param {string} roomId - The room it belongs to.
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
* @param {object} options
* @param {boolean} options.notifyRemainingPeers - Whether to emit room-state updates after removal.
*/
function removePeerFromRoom(socketId, roomId, reason) {
function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = true } = {}) {
const room = rooms.get(roomId);
if (!room) return;
@@ -251,14 +277,14 @@ function removePeerFromRoom(socketId, roomId, reason) {
// 3. Notify remaining peers (use io.to so the removed socket itself
// doesn't receive it — it has already left or is disconnecting)
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
if (!isPeerStillConnected) {
if (notifyRemainingPeers && !isPeerStillConnected) {
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
}
// 3.5. Clean up active lobby if a peer leaves
if (room.activeLobby) {
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
room.activeLobby = null; // Dissolve lobby
}
}
@@ -277,11 +303,22 @@ 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);
// H-1: a leaving initiator strands the room's force-sync — release the
// slot so a future controller's PREPARE can take over cleanly.
// Release the post-demotion exemption, but retain the validated target:
// an authorized initiator may reconnect and finish the already-visible
// choreography. A newer PREPARE still replaces it normally.
if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null;
if (room.hostPeerId === peerId) {
// Owner left → reassign owner + fall back to 'everyone' so the room is
@@ -290,11 +327,11 @@ function removePeerFromRoom(socketId, roomId, reason) {
room.hostPeerId = nextPeerData ? nextPeerData.peerId : null;
room.controlMode = CONTROL_MODES.EVERYONE;
room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []);
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`);
} else if (wasController) {
// A co-host left → keep the mode, just broadcast the updated controller list.
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`);
}
}
@@ -378,6 +415,7 @@ io.on('connection', (socket) => {
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
if (!socket.connected) return;
try {
// Protocol check
@@ -413,6 +451,7 @@ io.on('connection', (socket) => {
let lockPromise = roomCreationLocks.get(roomId);
if (lockPromise) {
await lockPromise;
if (!socket.connected) return;
room = rooms.get(roomId);
}
if (!room) {
@@ -445,7 +484,16 @@ io.on('connection', (socket) => {
// controller's FORCE_SYNC_EXECUTE through the host-only gate — without
// it, demoting a co-host mid-force-sync would drop their EXECUTE and
// leave every peer stuck paused.
forceSyncInitiator: null
forceSyncInitiator: null,
// Canonical Media State v1 is lazy, room-local and absent until
// the first accepted command establishes a trustworthy position.
mediaState: null,
// PREPARE is choreography, not stable room intent. Retain its
// validated target only so the matching EXECUTE can commit it.
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;
@@ -465,6 +513,7 @@ io.on('connection', (socket) => {
let peerLockPromise = peerJoinLocks.get(peerId);
if (peerLockPromise) {
await peerLockPromise;
if (!socket.connected) return;
room = rooms.get(roomId);
if (!room) {
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
@@ -475,6 +524,7 @@ io.on('connection', (socket) => {
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
peerJoinLocks.set(peerId, peerLockPromise);
try {
if (!socket.connected) return;
if (!createdByMe) {
if (room.passwordHash) {
if (!password || hashPassword(password) !== room.passwordHash) {
@@ -528,6 +578,7 @@ io.on('connection', (socket) => {
peerToSocket.set(peerId, socket.id);
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
const snapshotAt = Date.now();
socket.emit(EVENTS.ROOM_DATA, {
roomId,
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
@@ -535,6 +586,7 @@ io.on('connection', (socket) => {
hostPeerId: room.hostPeerId || null,
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
controllers: room.controllers ? Array.from(room.controllers) : [],
mediaState: snapshotMediaState(room.mediaState, snapshotAt),
capabilities: SERVER_CAPABILITIES
});
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
@@ -581,17 +633,15 @@ io.on('connection', (socket) => {
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
//
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
// land — otherwise their already-relayed PREPARE would leave the whole
// room stuck paused. Track the in-flight initiator on PREPARE and let
// their matching EXECUTE through regardless of current controllers set.
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
room.controlMode === CONTROL_MODES.HOST_ONLY &&
room.controllers && room.controllers.has(mapping.peerId)) {
room.forceSyncInitiator = mapping.peerId;
}
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
// H-1 exception: the latest valid PREPARE initiator's
// FORCE_SYNC_EXECUTE still has to land after demotion —
// otherwise the already-relayed room-wide choreography
// would leave peers paused.
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)) &&
@@ -599,17 +649,35 @@ io.on('connection', (socket) => {
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
return;
}
// Clear initiator tracking once the EXECUTE has been relayed.
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
room.forceSyncInitiator = null;
}
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
const hasSequenceField = data.seq !== undefined;
const sequence = Number.isSafeInteger(data.seq) && data.seq >= 0
? data.seq
: undefined;
if (SEQUENCED_ROOM_EVENTS.has(eventName)) {
if (hasSequenceField && sequence === undefined) {
log('ROOM', `Dropped ${eventName} with invalid seq from ${mapping.peerId}`);
return;
}
if (sequence !== undefined) {
if (socket.data.mediaSequencePeerId !== mapping.peerId) {
socket.data.mediaSequencePeerId = mapping.peerId;
socket.data.lastMediaSequence = null;
}
if (Number.isSafeInteger(socket.data.lastMediaSequence)
&& sequence <= socket.data.lastMediaSequence) {
log('ROOM', `Dropped stale ${eventName} from ${mapping.peerId} (seq ${sequence} <= ${socket.data.lastMediaSequence})`);
return;
}
socket.data.lastMediaSequence = sequence;
}
}
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
room.peerData.set(socket.id, {
...existing,
@@ -617,7 +685,7 @@ io.on('connection', (socket) => {
tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle),
mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle),
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime),
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, MAX_MEDIA_TIME) ?? existing.currentTime) : existing.currentTime),
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false),
@@ -627,9 +695,9 @@ io.on('connection', (socket) => {
// --- S-3: Construct clean relay payload — never forward raw client data ---
const relayPayload = {
senderId: mapping.peerId,
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400),
targetTime: clampNum(data.targetTime, 0, 86400),
seq: sequence,
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, MAX_MEDIA_TIME),
targetTime: clampNum(data.targetTime, 0, MAX_MEDIA_TIME),
playbackState: validState(data.playbackState),
username: clamp(data.username, 30),
tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100),
@@ -645,10 +713,120 @@ io.on('connection', (socket) => {
};
// Strip undefined keys for clean wire format
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
// The first live lobby owns the room until completion or
// cancellation. Drop concurrent lobby starts and stale ready
// frames instead of letting clients build divergent lobbies.
if (eventName === EVENTS.EPISODE_LOBBY && room.activeLobby) {
log('ROOM', `Dropped competing episode lobby from ${mapping.peerId}`);
socket.emit(EVENTS.EPISODE_LOBBY, {
senderId: room.activeLobby.initiatorPeerId,
peerId: room.activeLobby.initiatorPeerId,
expectedTitle: room.activeLobby.expectedTitle,
readyPeers: [...room.activeLobby.readyPeers],
authoritative: true
});
return;
}
if (eventName === EVENTS.EPISODE_LOBBY && !relayPayload.expectedTitle) {
log('ROOM', `Dropped malformed episode lobby from ${mapping.peerId}`);
return;
}
if (eventName === EVENTS.EPISODE_READY) {
if (!room.activeLobby) {
log('ROOM', `Dropped stale episode ready from ${mapping.peerId}`);
return;
}
if (relayPayload.expectedTitle
&& relayPayload.expectedTitle !== room.activeLobby.expectedTitle) {
log('ROOM', `Dropped episode ready for an obsolete lobby from ${mapping.peerId}`);
return;
}
}
const mediaStateNow = Date.now();
// Canonical Media State v1: mutate only after rate limiting,
// room mapping, Host Control authorization and sanitization.
// Heartbeats remain observational and never enter this path.
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.
if (!Number.isFinite(relayPayload.targetTime)) {
log('ROOM', `Dropped invalid force_sync_prepare from ${mapping.peerId}`);
return;
}
// One room-wide choreography is visible on the legacy
// wire. A newer PREPARE replaces the target every peer
// most recently received. Track its initiator in every
// 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,
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,
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);
// --- Side-effects: Server-side Episode Lobby Tracking ---
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
room.activeLobby = {
expectedTitle: relayPayload.expectedTitle,
initiatorPeerId: mapping.peerId,
@@ -969,7 +1147,7 @@ export function cleanupInactiveRooms(now = Date.now()) {
for (const sid of Array.from(currentRoom.peers)) {
const memberSocket = io.sockets?.sockets?.get(sid);
if (memberSocket) memberSocket.leave(roomId);
removePeerFromRoom(sid, roomId, 'room-timeout');
removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false });
}
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
@@ -1032,7 +1210,8 @@ export async function stopServerForTests() {
peerJoinLocks.clear();
clearRateLimitMaps();
healthResponseCache.clear();
io.removeAllListeners();
// Keep the server's connection handler installed so an E2E process can
// stop and restart this singleton relay for a later isolated scenario.
io.disconnectSockets(true);
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
if (!httpServer.listening) return;
+101
View File
@@ -0,0 +1,101 @@
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
function clampMediaTime(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
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);
if (currentTime === null) return null;
if (mediaState.playbackState !== 'playing') return currentTime;
const updatedAt = typeof mediaState.updatedAt === 'number' && Number.isFinite(mediaState.updatedAt)
? mediaState.updatedAt
: now;
return clampMediaTime(currentTime + Math.max(0, now - updatedAt) / 1000);
}
export function snapshotMediaState(mediaState, now = Date.now()) {
if (!mediaState) return null;
const currentTime = effectiveMediaPosition(mediaState, now);
if (currentTime === null
|| !Number.isSafeInteger(mediaState.revision)
|| mediaState.revision < 1
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) {
return null;
}
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, mediaTitle = null) {
const normalizedTime = clampMediaTime(currentTime);
if (normalizedTime === null
|| (playbackState !== 'playing' && playbackState !== 'paused')
|| typeof updatedBy !== 'string'
|| !updatedBy) {
return false;
}
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,
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);
const currentTime = eventPosition ?? effectiveMediaPosition(room.mediaState, now);
if (currentTime === null) return false;
return commitMediaState(
room,
eventName === EVENTS.PLAY ? 'playing' : 'paused',
currentTime,
senderPeerId,
now,
mediaTitle
);
}
if (eventName === EVENTS.SEEK) {
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, mediaTitle);
}
return false;
}
export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now(), mediaTitle = null) {
return commitMediaState(room, 'playing', targetTime, senderPeerId, now, mediaTitle);
}
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
import {
commitForceSyncMediaState,
effectiveMediaPosition,
snapshotMediaState,
updateMediaStateFromControl
} from './media-state.js';
function room(mediaState = null) {
return { mediaState };
}
describe('canonical media state', () => {
it('starts null and snapshots null', () => {
expect(snapshotMediaState(null, 1000)).toBeNull();
});
it('projects playing state and clamps elapsed time', () => {
const state = { revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' };
expect(effectiveMediaPosition(state, 3500)).toBe(12.5);
expect(effectiveMediaPosition(state, 500)).toBe(10);
expect(effectiveMediaPosition({ ...state, currentTime: MAX_MEDIA_TIME }, 3500)).toBe(MAX_MEDIA_TIME);
});
it('keeps paused state fixed', () => {
const state = { revision: 1, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' };
expect(effectiveMediaPosition(state, 601000)).toBe(10);
expect(snapshotMediaState(state, 601000)).toEqual({
revision: 1,
playbackState: 'paused',
currentTime: 10,
updatedBy: 'a'
});
});
it('initializes and updates PLAY with server-owned revisions', () => {
const target = room();
expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 12, revision: 999 }, 'a', { now: 1000 })).toBe(true);
expect(target.mediaState).toEqual({ revision: 1, playbackState: 'playing', currentTime: 12, updatedAt: 1000, updatedBy: 'a' });
expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 20 }, 'b', { now: 2000 })).toBe(true);
expect(target.mediaState.revision).toBe(2);
expect(target.mediaState.updatedBy).toBe('b');
});
it('does not invent an initial PLAY position and preserves a known effective position', () => {
const target = room();
expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 1000 })).toBe(false);
expect(target.mediaState).toBeNull();
target.mediaState = { revision: 1, playbackState: 'playing', currentTime: 5, updatedAt: 1000, updatedBy: 'a' };
expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 3000 })).toBe(true);
expect(target.mediaState.currentTime).toBe(7);
});
it('freezes PAUSE at its event or effective canonical position', () => {
const target = room({ revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
expect(updateMediaStateFromControl(target, EVENTS.PAUSE, {}, 'a', { now: 4000 })).toBe(true);
expect(target.mediaState).toEqual({ revision: 2, playbackState: 'paused', currentTime: 13, updatedAt: 4000, updatedBy: 'a' });
expect(effectiveMediaPosition(target.mediaState, 9000)).toBe(13);
});
it('uses SEEK targetTime, preserves playback state, and lets the second controller win', () => {
const target = room({ revision: 3, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { currentTime: 50, targetTime: 100 }, 'a', { now: 2000 })).toBe(true);
expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'playing', currentTime: 100, updatedBy: 'a' });
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 200 }, 'b', { now: 2001 })).toBe(true);
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(
target,
EVENTS.SEEK,
{ targetTime: 100, playbackState: 'playing' },
'b',
{ now: 2000, senderPlaybackState: 'playing' }
)).toBe(true);
expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'paused', currentTime: 100 });
});
it('uses an observed sender state only to establish an otherwise ambiguous first SEEK', () => {
const target = room();
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000 })).toBe(false);
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000, senderPlaybackState: 'paused' })).toBe(true);
expect(target.mediaState).toMatchObject({ revision: 1, playbackState: 'paused', currentTime: 50 });
});
it('rejects non-finite/missing controls without corruption and clamps existing protocol bounds', () => {
const original = { revision: 2, playbackState: 'paused', currentTime: 30, updatedAt: 1000, updatedBy: 'a' };
for (const payload of [{ targetTime: NaN }, { targetTime: Infinity }, { targetTime: '50' }, {}]) {
const target = room({ ...original });
expect(updateMediaStateFromControl(target, EVENTS.SEEK, payload, 'b', { now: 2000 })).toBe(false);
expect(target.mediaState).toEqual(original);
}
const low = room({ ...original });
updateMediaStateFromControl(low, EVENTS.SEEK, { targetTime: -5 }, 'b', { now: 2000 });
expect(low.mediaState.currentTime).toBe(0);
const high = room({ ...original });
updateMediaStateFromControl(high, EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 5 }, 'b', { now: 2000 });
expect(high.mediaState.currentTime).toBe(MAX_MEDIA_TIME);
});
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, 'Series S02E03')).toBe(true);
expect(target.mediaState).toEqual({
revision: 5,
playbackState: 'playing',
currentTime: 500,
updatedAt: 2000,
updatedBy: 'b',
mediaTitle: 'Series S02E03'
});
});
});
+3
View File
@@ -31,6 +31,7 @@ Browser extensions cannot import files outside their own root directory, so the
- `CONTROL_MODES.HOST_ONLY`: only the host and promoted controllers may move playback.
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
@@ -63,7 +64,9 @@ 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_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.
## Do Not Break
+8 -1
View File
@@ -90,9 +90,16 @@ export const CAPABILITIES = {
HOST_CONTROL: 'host-control',
CO_HOST: 'co-host', // owner promotes guests to additional controllers
CHAT: 'chat', // legacy server capability used by the first chat beta
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
CHAT_V1: 'chat-v1', // versioned client/server chat wire contract
MEDIA_STATE_V1: 'media-state-v1' // server-authoritative room playback recovery snapshot
};
// Relay and extension media-time validation must use the same upper bound.
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)
// 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
+6
View File
@@ -21,6 +21,7 @@ npm run test:e2e:race # @race scenarios, repeated 20 times
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
| `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery |
| `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup |
| `global-setup.mjs` | Owns the fixture server for the complete Playwright run and closes it during teardown |
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
| `fixtures/pages/` | One page per scenario |
| `fixtures/media/` | Small generated clips (see below) |
@@ -32,6 +33,11 @@ Chrome MV3 APIs and a persistent service-worker context. The scheduled
`.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads
traces/results on failure.
Locally, global setup reuses an already running fixture server on the configured
port. CI always owns a fresh server so a port collision fails instead of testing
against an unknown process. Keeping the server inside Playwright's lifecycle
also prevents an orphaned `node` process from blocking teardown on Windows.
## Two rules worth keeping
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
+696 -1
View File
@@ -1,4 +1,12 @@
import { test, expect } from './helpers/extension-fixture.mjs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../../shared/constants.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(testDir, '..', '..', 'server', 'package.json'));
const NodeWebSocket = require('ws');
/**
* Drives the packed extension itself: real background service worker, real
@@ -10,7 +18,9 @@ import { test, expect } from './helpers/extension-fixture.mjs';
/** Runs code in an extension page, where the privileged chrome.* APIs exist. */
async function withExtensionPage(context, extensionId, fn) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
// popup.html performs normal connection/settings initialization. Using it as
// a test transport can race and overwrite a just-seeded custom relay URL.
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
const result = await fn(page);
await page.close();
return result;
@@ -54,6 +64,109 @@ async function getExtensionState(context, extensionId, message) {
));
}
async function expectConnectedRoom(context, extensionId, roomId, expected = {}) {
try {
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ status: 'connected', roomId, ...expected });
} catch (error) {
const [status, logs] = await Promise.all([
getExtensionState(context, extensionId, { type: 'GET_STATUS' }).catch(() => null),
getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => [])
]);
console.error(`Extension connection diagnostics: ${JSON.stringify({ status, logs: logs?.slice?.(0, 20) || logs })}`);
throw error;
}
}
async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, mediaState }) => {
return chrome.tabs.sendMessage(tabId, {
type: 'APPLY_CANONICAL_MEDIA_STATE',
mediaState
});
}, { tabId, mediaState }));
}
async function sendContentServerCommand(context, extensionId, tabId, action, payload = {}) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => {
return chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
payload,
actionTimestamp: Date.now(),
commandSenderId: 'e2e-newer-command'
});
}, { tabId, action, payload }));
}
async function connectLegacyRelayClient(port) {
const socket = new NodeWebSocket(
`ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=3.1.3&token=${OFFICIAL_SERVER_TOKEN}`
);
socket.messages = [];
socket.on('message', value => socket.messages.push(value.toString()));
await new Promise((resolve, reject) => {
let timeout;
const onError = error => {
clearTimeout(timeout);
socket.off('open', onOpen);
reject(new Error(`legacy relay connection failed: ${error.message}`));
};
const onOpen = () => {
clearTimeout(timeout);
socket.off('error', onError);
resolve();
};
timeout = setTimeout(() => {
socket.off('open', onOpen);
socket.off('error', onError);
reject(new Error('legacy relay connection timed out'));
}, 5000);
socket.once('error', onError);
socket.once('open', onOpen);
});
socket.send('40');
await expect.poll(() => socket.messages.filter(message => message.startsWith('0') || message.startsWith('40')).length).toBeGreaterThanOrEqual(2);
socket.messages.length = 0;
return socket;
}
function sendLegacyRelayEvent(socket, event, data = {}) {
socket.send(`42${JSON.stringify([event, data])}`);
}
async function waitForLegacyRelayEvent(socket, event, timeoutMs = 10_000) {
await expect.poll(() => socket.messages.some(message => {
if (!message.startsWith('42')) return false;
try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; }
}), { timeout: timeoutMs }).toBe(true);
const index = socket.messages.findIndex(message => {
if (!message.startsWith('42')) return false;
try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; }
});
return JSON.parse(socket.messages.splice(index, 1)[0].substring(2))[1];
}
async function joinLegacyRelayRoom(socket, roomId, peerId) {
sendLegacyRelayEvent(socket, 'join_room', { roomId, peerId, protocolVersion: PROTOCOL_VERSION });
return waitForLegacyRelayEvent(socket, 'room_data');
}
async function terminateExtensionServiceWorker(context, extensionId, page) {
const session = await context.newCDPSession(page);
try {
const { targetInfos } = await session.send('Target.getTargets');
const target = targetInfos.find(candidate =>
candidate.type === 'service_worker'
&& candidate.url.startsWith(`chrome-extension://${extensionId}/`)
);
if (!target) throw new Error('extension service worker target not found');
await session.send('Target.closeTarget', { targetId: target.targetId });
} finally {
await session.detach();
}
}
async function setAudioSettings(context, extensionId, settings) {
return withExtensionPage(context, extensionId, page => page.evaluate(
value => chrome.storage.local.set({ audioSettings: value }),
@@ -263,6 +376,462 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
).toBeGreaterThan(5);
});
test('applies canonical recovery without echoing media commands or activity', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
const historyBefore = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' });
const playingApply = await applyCanonicalMediaState(context, extensionId, tabId, {
revision: 10,
playbackState: 'playing',
currentTime: 6,
updatedBy: 'peer-a'
});
expect(playingApply).toMatchObject({ status: 'applied', revision: 10 });
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: false });
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(5);
const pausedApply = await applyCanonicalMediaState(context, extensionId, tabId, {
revision: 11,
playbackState: 'paused',
currentTime: 10,
updatedBy: 'peer-a'
});
expect(pausedApply).toMatchObject({ status: 'applied', revision: 11 });
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);
const historyAfter = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' });
expect(historyAfter).toEqual(historyBefore);
});
test('local media input cancels an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
const video = document.querySelector('#player');
if (!video) throw new Error('canonical local-supersession fixture video missing');
video.pause();
video.currentTime = 0;
const nativePlay = video.play.bind(video);
video.dataset.koalaDelayedPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
video.dataset.koalaDelayedPlayAttempts = String(attempts);
return nativePlay().then(() => new Promise((resolve, reject) => setTimeout(() => {
if (video.paused) {
const error = new Error('play interrupted by local pause');
error.name = 'AbortError';
reject(error);
} else {
resolve();
}
}, 400)));
}
});
}
});
}, tabId));
let applyResponse = null;
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
revision: 20,
playbackState: 'playing',
currentTime: 6,
updatedBy: 'peer-a'
}).then(response => {
applyResponse = response;
return response;
});
await expect.poll(async () => ({
attempts: await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0')),
response: applyResponse
})).toMatchObject({ attempts: 1, response: null });
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
await page.locator('#player').evaluate(video => {
video.pause();
video.currentTime = 10;
});
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
.toBeGreaterThan(9);
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
await page.waitForTimeout(700);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
});
test('newer server command clears local recovery state before a delayed apply resolves', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
const video = document.querySelector('#player');
if (!video) throw new Error('canonical server-supersession fixture video missing');
video.pause();
video.currentTime = 0;
const nativePlay = video.play.bind(video);
video.dataset.koalaDelayedPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
video.dataset.koalaDelayedPlayAttempts = String(attempts);
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400)));
}
});
}
});
}, tabId));
let applyResponse = null;
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
revision: 21,
playbackState: 'playing',
currentTime: 6,
updatedBy: 'peer-a'
}).then(response => {
applyResponse = response;
return response;
});
await expect.poll(async () => ({
attempts: await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0')),
response: applyResponse
})).toMatchObject({ attempts: 1, response: null });
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
await page.locator('#player').evaluate(video => {
video.pause();
video.currentTime = 10;
});
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
await sendContentServerCommand(context, extensionId, tabId, 'play');
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
await page.waitForTimeout(700);
expect(await page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false);
});
test('newer remote pause wins after a stale restoration play settles late', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/simple-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
await chrome.scripting.executeScript({
target: { tabId: selectedTabId },
world: 'ISOLATED',
func: () => {
const video = document.querySelector('#player');
if (!video) throw new Error('late restoration fixture video missing');
video.pause();
video.currentTime = 0;
const nativePlay = video.play.bind(video);
video.dataset.koalaDelayedPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
video.dataset.koalaDelayedPlayAttempts = String(attempt);
if (attempt === 1) {
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200)));
}
return new Promise((resolve, reject) => setTimeout(() => {
nativePlay().then(resolve, reject);
}, 400));
}
});
}
});
}, tabId));
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
revision: 22,
playbackState: 'playing',
currentTime: 6,
updatedBy: 'peer-a'
});
await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1);
// Capture a superseding play intent while leaving the fixture paused, so
// stale recovery has to enter its delayed restoration play path.
await page.locator('#player').evaluate(video => {
video.pause();
});
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
selectedTabId => chrome.tabs.sendMessage(selectedTabId, {
type: 'CANCEL_CANONICAL_MEDIA_STATE',
reason: 'local play',
action: 'play',
payload: { currentTime: 0 }
}),
tabId
));
await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
await sendContentServerCommand(context, extensionId, tabId, 'pause');
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
await page.waitForTimeout(500);
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
});
test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => {
test.setTimeout(45_000);
const relay = await import('../../server/index.js');
let legacy = null;
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-canonical-room-data-${Date.now()}`;
legacy = await connectLegacyRelayClient(port);
await joinLegacyRelayRoom(legacy, roomId, 'canonical-source');
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 recovery fixture video missing');
const nativePlay = video.play.bind(video);
video.dataset.koalaCanonicalPlayAttempts = '0';
Object.defineProperty(video, 'play', {
configurable: true,
value: () => {
const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1;
video.dataset.koalaCanonicalPlayAttempts = String(attempts);
if (attempts === 1) {
return Promise.reject(Object.assign(
new Error('audit autoplay rejection'),
{ name: 'NotAllowedError' }
));
}
return nativePlay();
}
});
}
});
}, tabId));
legacy.messages.length = 0;
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: 'canonical-receiver'
}));
await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 });
await expect.poll(() => page.locator('#player').evaluate(video =>
Number(video.dataset.koalaCanonicalPlayAttempts || '0')))
.toBeGreaterThanOrEqual(2);
await expect.poll(() => page.locator('#player').evaluate(video => ({
paused: video.paused,
currentTime: video.currentTime
}))).toMatchObject({ paused: false });
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
.toBeGreaterThan(5);
await page.waitForTimeout(700);
const recoveryEchoes = legacy.messages.filter(message => {
if (!message.startsWith('42')) return false;
try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; }
});
expect(recoveryEchoes).toEqual([]);
} finally {
try { legacy?.close(); } catch { /* already closed */ }
await relay.stopServerForTests();
}
});
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 expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 });
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 expectConnectedRoom(context, extensionId, roomId);
expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 8 }))
.toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 9 }))
.toMatchObject({ status: 'ok' });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 2, playbackState: 'paused', currentTime: 9 });
} 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();
@@ -998,6 +1567,132 @@ test('keeps controlling a Drive-style player across an ordinary play and pause',
await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false);
});
test('coalesces persisted offline media intent before canonical reconnect recovery', async ({ context, extensionId, baseURL }) => {
test.setTimeout(60_000);
const relay = await import('../../server/index.js');
let legacy = null;
let canonicalKeeper = null;
let lateJoiner = null;
try {
await relay.startServer(0, '127.0.0.1');
const port = relay.httpServer.address().port;
const roomId = `e2e-coalesced-${Date.now()}`;
legacy = await connectLegacyRelayClient(port);
await joinLegacyRelayRoom(legacy, roomId, 'legacy-e2e');
canonicalKeeper = await connectLegacyRelayClient(port);
await joinLegacyRelayRoom(canonicalKeeper, roomId, 'legacy-keeper');
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-e2e'
}));
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 });
// Establish accepted server truth that would be stale for this client
// after its later offline actions.
sendLegacyRelayEvent(legacy, 'play', { currentTime: 1, seq: 1, actionTimestamp: 1 });
sendLegacyRelayEvent(legacy, 'seek', { currentTime: 1, targetTime: 1, seq: 2, actionTimestamp: 2 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 1, updatedBy: 'legacy-e2e' });
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(0.8);
legacy.messages.length = 0;
const connectedStatus = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
const extensionSocketId = Array.from(relay.rooms.get(roomId).peerData.entries())
.find(([, data]) => data.peerId === connectedStatus.peerId)?.[0];
expect(extensionSocketId).toBeTruthy();
// Point future reconnect attempts at an unused local port, then sever
// only the extension socket. The relay and legacy peer stay live.
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
serverUrl => chrome.storage.local.set({ serverUrl }),
'ws://127.0.0.1:1'
));
relay.io.sockets.sockets.get(extensionSocketId).disconnect(true);
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.status))
.not.toBe('connected');
expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 2 })).toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 4 })).toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 })).toMatchObject({ status: 'ok' });
expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 6 })).toMatchObject({ status: 'ok' });
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ queuedLogicalEvents: 1, queuedMediaIntents: 1, queuedWireEvents: 2 });
await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime })))
.toMatchObject({ paused: true });
// Terminate the actual MV3 worker. The next runtime message starts a new
// worker, which must migrate/restore the logical queue and local sequence.
await terminateExtensionServiceWorker(context, extensionId, page);
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.queuedMediaIntents))
.toBe(1);
const restoredQueue = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(restoredQueue.queuedLogicalEvents).toBeGreaterThanOrEqual(1);
expect(restoredQueue.queuedWireEvents).toBeGreaterThanOrEqual(2);
await page.locator('#player').evaluate(video => {
window.__koalaReconnectSeeks = [];
video.addEventListener('seeked', () => window.__koalaReconnectSeeks.push(video.currentTime));
});
const retryResult = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => {
await chrome.storage.local.set({ serverUrl });
return chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}, `ws://127.0.0.1:${port}`));
expect(retryResult).toMatchObject({ status: 'ok' });
const replaySeek = await waitForLegacyRelayEvent(legacy, 'seek', 20_000);
const replayPause = await waitForLegacyRelayEvent(legacy, 'pause', 20_000);
expect(replaySeek).toMatchObject({ currentTime: 6, targetTime: 6 });
expect(replayPause).toMatchObject({ currentTime: 6 });
expect(replaySeek.seq).toBeLessThan(replayPause.seq);
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
.toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0, queuedMediaIntents: 0 });
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
.toMatchObject({ playbackState: 'paused', currentTime: 6 });
expect(relay.rooms.get(roomId).mediaState.revision).toBeGreaterThan(2);
// The stale r2 snapshot must never have sought the local player back to 1
// before its authorized pending intent replayed.
const reconnectSeeks = await page.evaluate(() => window.__koalaReconnectSeeks || []);
expect(reconnectSeeks.some(value => value < 3)).toBe(false);
await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime })))
.toMatchObject({ paused: true });
lateJoiner = await connectLegacyRelayClient(port);
const lateRoom = await joinLegacyRelayRoom(lateJoiner, roomId, 'late-e2e');
expect(lateRoom.mediaState).toMatchObject({
revision: relay.rooms.get(roomId).mediaState.revision,
playbackState: 'paused',
currentTime: 6
});
await page.waitForTimeout(700);
const leakedMediaEvents = legacy.messages.filter(message => {
if (!message.startsWith('42')) return false;
try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; }
});
expect(leakedMediaEvents).toEqual([]);
} finally {
await context.setOffline(false).catch(() => {});
try { legacy?.close(); } catch { /* already closed */ }
try { canonicalKeeper?.close(); } catch { /* already closed */ }
try { lateJoiner?.close(); } catch { /* already closed */ }
await relay.stopServerForTests();
}
});
function FRAMED_VIDEO_PAUSED() {
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
}
+73 -47
View File
@@ -8,8 +8,8 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
const port = Number(process.argv[2] || 4173);
const modulePath = fileURLToPath(import.meta.url);
const root = path.resolve(path.dirname(modulePath), 'fixtures');
const TYPES = {
'.html': 'text/html; charset=utf-8',
@@ -18,62 +18,88 @@ const TYPES = {
'.json': 'application/json'
};
const server = http.createServer((req, res) => {
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (requested === '/redirect/hidden-player') {
res.writeHead(302, {
Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`,
'Cache-Control': 'no-store'
}).end();
return;
}
const filePath = path.resolve(root, `.${requested}`);
export function createFixtureServer(port) {
return http.createServer((req, res) => {
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
if (requested === '/redirect/hidden-player') {
res.writeHead(302, {
Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`,
'Cache-Control': 'no-store'
}).end();
return;
}
const filePath = path.resolve(root, `.${requested}`);
if (!filePath.startsWith(root + path.sep)) {
res.writeHead(403).end('forbidden');
return;
}
fs.stat(filePath, (statErr, stat) => {
if (statErr || !stat.isFile()) {
res.writeHead(404).end('not found');
if (!filePath.startsWith(root + path.sep)) {
res.writeHead(403).end('forbidden');
return;
}
const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream';
// Media needs byte ranges: without them Chromium reports an empty
// seekable range and seeking silently does nothing, which would make
// the remote-seek test fail for a reason that has nothing to do with
// the extension.
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (range) {
const start = range[1] ? Number(range[1]) : 0;
const end = range[2] ? Number(range[2]) : stat.size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
fs.stat(filePath, (statErr, stat) => {
if (statErr || !stat.isFile()) {
res.writeHead(404).end('not found');
return;
}
res.writeHead(206, {
const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream';
// Media needs byte ranges: without them Chromium reports an empty
// seekable range and seeking silently does nothing, which would make
// the remote-seek test fail for a reason that has nothing to do with
// the extension.
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (range) {
const start = range[1] ? Number(range[1]) : 0;
const end = range[2] ? Number(range[2]) : stat.size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
return;
}
res.writeHead(206, {
'Content-Type': contentType,
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
});
fs.createReadStream(filePath, { start, end }).pipe(res);
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
});
fs.createReadStream(filePath, { start, end }).pipe(res);
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
fs.createReadStream(filePath).pipe(res);
});
fs.createReadStream(filePath).pipe(res);
});
});
}
server.listen(port, '127.0.0.1', () => {
export async function startFixtureServer(port = Number(process.env.KOALA_E2E_PORT || 4173)) {
const server = createFixtureServer(port);
await new Promise((resolve, reject) => {
const onError = error => reject(error);
server.once('error', onError);
server.listen(port, '127.0.0.1', () => {
server.off('error', onError);
resolve();
});
});
return server;
}
export async function stopFixtureServer(server) {
if (!server?.listening) return;
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections?.();
});
}
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath);
if (isMainModule) {
const port = Number(process.argv[2] || process.env.KOALA_E2E_PORT || 4173);
await startFixtureServer(port);
console.log(`fixture server on http://localhost:${port}`);
});
}
+35
View File
@@ -0,0 +1,35 @@
import http from 'node:http';
import { startFixtureServer, stopFixtureServer } from './fixture-server.mjs';
function fixtureIsRunning(port) {
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
resolve(result);
};
const request = http.get({
hostname: '127.0.0.1',
port,
path: '/pages/simple-player.html'
}, response => {
response.resume();
response.once('error', () => finish(false));
response.once('end', () => finish(response.statusCode === 200));
});
request.once('error', () => finish(false));
request.setTimeout(1000, () => {
request.destroy();
finish(false);
});
});
}
export default async function globalSetup() {
const port = Number(process.env.KOALA_E2E_PORT || 4173);
if (!process.env.CI && await fixtureIsRunning(port)) return undefined;
const server = await startFixtureServer(port);
return async () => stopFixtureServer(server);
}
+2 -8
View File
@@ -6,6 +6,7 @@ const PORT = Number(process.env.KOALA_E2E_PORT || 4173);
export default defineConfig({
testDir: '.',
testMatch: '**/*.spec.mjs',
globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)),
// Extension tests drive a persistent context and a service worker; running
// them in parallel makes the profile directories fight each other.
workers: 1,
@@ -46,12 +47,5 @@ export default defineConfig({
name: 'extension-chromium',
testIgnore: 'detection.spec.mjs'
}
],
webServer: {
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
url: `http://localhost:${PORT}/pages/simple-player.html`,
reuseExistingServer: !process.env.CI,
stdout: 'ignore',
stderr: 'pipe'
}
]
});