From 027d2dbde597d8baafa5d1a7eeb32fbe533ab2a3 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:26:31 +0200 Subject: [PATCH] fix(sync): coalesce play/pause bursts and quiet absent-peer ACK logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media players fire bursts of native play/pause events (source swaps, ABR, ads, teardown). Each was relayed as a distinct command, spamming peers and tripping the relay's event rate limit. Add leading+trailing coalescing in the content script: emit the first event instantly (zero added latency) and, on a 150ms window, collapse a burst to its final state. Echo-suppression and seek-flush stay synchronous on event arrival; the trailing send is never deduped against the leading one (a remote command may change shared state mid-window — re-sending is the safe, idempotent choice). Server: split the EVENT_ACK handler's logging so only a genuine different-room ACK is logged as [SECURITY]; an ACK to a peer that already left is logged quietly as [ACKDROP] (verbose-only), so real signals are no longer drowned out. Co-Authored-By: Claude Opus 4.8 --- extension/content.js | 109 ++++++++++++++++++++++++++++++++++++------- server/index.js | 12 +++-- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/extension/content.js b/extension/content.js index ce1118b..e53e488 100644 --- a/extension/content.js +++ b/extension/content.js @@ -57,6 +57,29 @@ let seekDebounceTimer = null; // debounce timer for rapid seek events let expectedSeekTime = null; // strictly track programmatic seeks + // --- Play/Pause Coalescing (leading + trailing) --- + // Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps, + // page teardown) fire bursts of native play/pause events within a few hundred + // ms. Relaying each as a distinct command spams peers and the relay. + // + // Strategy: emit the FIRST event immediately (leading edge → a deliberate + // single play/pause has zero added latency), then hold a short window. If + // more play/pause events arrive during the window it's a burst — we suppress + // the intermediate churn and, once it settles, emit the FINAL state on the + // trailing edge. + // + // We deliberately do NOT dedup the trailing send against the leading one. A + // remote play/pause may be applied mid-window (e.g. I pause, peer plays, I + // pause again within 150ms): the settled state then equals my last *sent* + // state yet is a genuine change versus the now-shared state — suppressing it + // would desync. Re-sending an unchanged state is a harmless no-op on peers, + // so re-sending is always the safe choice. Echo-suppression and seek-flush + // still run synchronously on event arrival (see reportEvent) — only the + // network emit is governed here. + const PLAY_PAUSE_COALESCE_MS = 150; + let playPauseCoalesceTimer = null; // non-null = a coalescing window is open + let pendingPlayPauseAction = null; // last play/pause seen during the window, awaiting trailing flush + // --- Episode Auto-Sync State --- let lastKnownMediaTitle = null; let episodeTransitionDebounce = null; @@ -768,6 +791,46 @@ }); // Detect native events + // Build the relay payload from the *current* media state and send it to + // background.js. Re-reads the video each call so deferred (coalesced) emits + // carry an up-to-date position. Safe with no/invalid video — it no-ops. + function sendContentEvent(action) { + const video = findVideo(); + if (!video) return; + + const current = video.currentTime; + if (!Number.isFinite(current)) return; + + const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; + + chrome.runtime.sendMessage({ + type: 'CONTENT_EVENT', + action, + payload: { + currentTime: current, + targetTime: current, + mediaTitle: mediaTitle, + timestamp: Date.now() + } + }).catch(() => {}); + + // Trigger proactive heartbeat to push stabilized state + scheduleProactiveHeartbeat(); + } + + // Trailing-edge flush of a coalesced play/pause burst: emit the final settled + // state. No-ops only when no burst followed the leading edge. We do NOT skip + // when the state matches the leading send — a remote command may have changed + // the shared state mid-window, so re-sending is the safe (idempotent) choice. + function flushPlayPause() { + playPauseCoalesceTimer = null; + const finalAction = pendingPlayPauseAction; + pendingPlayPauseAction = null; + // No burst follow-up (only the leading edge fired), or invalid state. + if (finalAction !== EVENTS.PLAY && finalAction !== EVENTS.PAUSE) return; + sendContentEvent(finalAction); + } + function reportEvent(action) { if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { clearTimeout(seekDebounceTimer); @@ -786,10 +849,11 @@ const current = video.currentTime; if (!Number.isFinite(current)) return; - const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; - const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null)); - + + // Echo-suppression for remotely-applied commands. MUST stay synchronous + // on event arrival: the suppress timer is short-lived (300ms) and would + // expire if deferred, leaking an echo back to peers. if (_suppressTimers[eventState]) { _clearSuppress(eventState); return; @@ -798,20 +862,29 @@ // 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. if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return; - - chrome.runtime.sendMessage({ - type: 'CONTENT_EVENT', - action, - payload: { - currentTime: current, - targetTime: current, - mediaTitle: mediaTitle, - timestamp: Date.now() - } - }).catch(() => {}); - // Trigger proactive heartbeat to push stabilized state - scheduleProactiveHeartbeat(); + // Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The + // synchronous gates above have already run; only the network emit is + // governed here. Leading edge sends the first event instantly; further + // events within the window are collapsed to the final state by the + // trailing flush. A pending burst is REPLACED, never dropped — the last + // event in the window always wins. + if (action === EVENTS.PLAY || action === EVENTS.PAUSE) { + if (playPauseCoalesceTimer === null) { + // Window closed → fresh action. Emit now (zero added latency). + sendContentEvent(action); + pendingPlayPauseAction = null; + } else { + // Window open → part of a burst. Defer to the trailing flush. + pendingPlayPauseAction = action; + clearTimeout(playPauseCoalesceTimer); + } + playPauseCoalesceTimer = setTimeout(flushPlayPause, PLAY_PAUSE_COALESCE_MS); + return; + } + + // SEEK (and any non play/pause action): emit immediately. + sendContentEvent(action); } // --- Tab Visibility Handling --- @@ -843,6 +916,10 @@ if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; } if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; } + // Drop any pending coalesced play/pause: we are tearing down and will + // disconnect — peers learn we are gone via PEER_STATUS 'left'. This also + // intentionally suppresses the teardown play/pause burst at the source. + if (playPauseCoalesceTimer) { clearTimeout(playPauseCoalesceTimer); playPauseCoalesceTimer = null; pendingPlayPauseAction = null; } observer.disconnect(); }); window.addEventListener('pageshow', (event) => { diff --git a/server/index.js b/server/index.js index 2cc126d..a8e78de 100644 --- a/server/index.js +++ b/server/index.js @@ -150,7 +150,7 @@ const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same pee function log(type, message, details = '') { const debugLogging = process.env.DEBUG_LOGGING === '1'; - const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS'; + const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP'; if (!debugLogging && isVerbose) return; const timestamp = new Date().toISOString(); @@ -569,12 +569,18 @@ io.on('connection', (socket) => { // Security: Only relay ACK if both peers are in the same room if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) { - io.to(targetSocketId).emit(EVENTS.EVENT_ACK, { + io.to(targetSocketId).emit(EVENTS.EVENT_ACK, { senderId: senderMapping.peerId, actionTimestamp: data.actionTimestamp }); - } else { + } else if (senderMapping && targetMapping) { + // Both peers exist but live in different rooms — genuinely suspicious. log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`); + } else { + // Benign + common: sender or target left/disconnected before the ACK + // arrived (a command was in-flight when they went). Not an attack — + // log quietly (verbose only) so it doesn't drown out real signals. + log('ACKDROP', `Dropped ACK from ${socket.id} to absent peer ${data.targetId}`); } });