mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-31 13:08:15 +00:00
fix(sync): coalesce play/pause bursts and quiet absent-peer ACK logs
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 <noreply@anthropic.com>
This commit is contained in:
+93
-16
@@ -57,6 +57,29 @@
|
|||||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
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 ---
|
// --- Episode Auto-Sync State ---
|
||||||
let lastKnownMediaTitle = null;
|
let lastKnownMediaTitle = null;
|
||||||
let episodeTransitionDebounce = null;
|
let episodeTransitionDebounce = null;
|
||||||
@@ -768,6 +791,46 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Detect native events
|
// 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) {
|
function reportEvent(action) {
|
||||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||||
clearTimeout(seekDebounceTimer);
|
clearTimeout(seekDebounceTimer);
|
||||||
@@ -786,10 +849,11 @@
|
|||||||
const current = video.currentTime;
|
const current = video.currentTime;
|
||||||
if (!Number.isFinite(current)) return;
|
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));
|
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]) {
|
if (_suppressTimers[eventState]) {
|
||||||
_clearSuppress(eventState);
|
_clearSuppress(eventState);
|
||||||
return;
|
return;
|
||||||
@@ -798,20 +862,29 @@
|
|||||||
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
|
// 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.
|
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||||
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
|
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
|
// Coalesce play/pause bursts (source swaps, ABR, ads, teardown). The
|
||||||
scheduleProactiveHeartbeat();
|
// 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 ---
|
// --- Tab Visibility Handling ---
|
||||||
@@ -843,6 +916,10 @@
|
|||||||
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
|
if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; }
|
||||||
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; }
|
||||||
if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = 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();
|
observer.disconnect();
|
||||||
});
|
});
|
||||||
window.addEventListener('pageshow', (event) => {
|
window.addEventListener('pageshow', (event) => {
|
||||||
|
|||||||
+9
-3
@@ -150,7 +150,7 @@ const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same pee
|
|||||||
|
|
||||||
function log(type, message, details = '') {
|
function log(type, message, details = '') {
|
||||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
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;
|
if (!debugLogging && isVerbose) return;
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
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
|
// Security: Only relay ACK if both peers are in the same room
|
||||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||||
senderId: senderMapping.peerId,
|
senderId: senderMapping.peerId,
|
||||||
actionTimestamp: data.actionTimestamp
|
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}`);
|
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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user