diff --git a/docs/nested-player-frame-targeting.md b/docs/nested-player-frame-targeting.md index b7f5b98..08e2182 100644 --- a/docs/nested-player-frame-targeting.md +++ b/docs/nested-player-frame-targeting.md @@ -1,8 +1,7 @@ -# Targeting a nested cross-origin player without `webNavigation` (open) +# Targeting a nested cross-origin player without `webNavigation` -> **Status:** Largely fixed, one confirmed defect still open — see -> [Open: a dead frame election is never released](#open-a-dead-frame-election-is-never-released). -> Everything under *Fixed* was verified against the live site or a fixture rebuilt from it. +> **Status:** Fixed. Everything below was verified against the live site or a fixture +> rebuilt from it. > > Context: v3.1.2 added support for players inside cross-origin frames (Google Drive, > anime hosts) and shipped with a `webNavigation` permission. The permission was not @@ -148,48 +147,39 @@ overlay, content script), not discovery. --- -## Open: a dead frame election is never released +## A dead frame election, and the deadlock behind it -**Reported:** 2026-08-18, against the build at `e0c6865`. +**Reported:** 2026-08-18. `Could not establish connection. Receiving end does not exist.` +with `targetReady: true` and no activation errors — the election named a frame the player +had already torn down. -Symptom in the dev panel: +Three defects were stacked here, each hidden by the one in front of it. -``` -Kommunikation mit dem Tab-Video fehlgeschlagen. -(Could not establish connection. Receiving end does not exist.) -``` +**The election was never released.** `getReadyTabVideoState()` recovered through the +guarded refresh, which reports `unchanged` when no video is reachable, so the stale +`frameId`/`documentId` survived. Adoption compounded it: it sets `hasVideo`, and once that +is true the target only moves on a frame change. An unreachable content script — as +opposed to a page that simply has no video yet — now releases the **frame** election back +to the top frame. The tab selection is never touched. -with `targetReady: true`, `Video Count: 0`, `In Iframe: NO`, and no activation errors in -the log. +**Switching frames destroyed the top frame's scripts.** Promoting the target from frame 0 +into a nested player called `deactivateTargetTab()` on the previous target, which sent +`TARGET_DEACTIVATE` to frame 0 and tore down both its content script and the chat overlay +there. That is why chat delivery failed after promotion, and why releasing the election +pointed at an empty frame. An in-tab frame switch now leaves the top frame alone. -### What is verified +**Discovery could deadlock.** Monitors announce new players, but a rebuilt frame is a new +document with no monitor, so the video created in it was never reported — and nothing then +triggered the upkeep that would have installed one. Reinstalling monitors is cheap, +bounded and idempotent, so it now runs on every lifecycle notification with a +trailing-edge debounce; and a bounded discovery poll (2s, capped, only while a tab is +selected with no video found, stopping the moment one is) breaks the cycle when no +notification arrives at all. -- `targetReady` is true, so activation completed and the tab selection is intact. -- The error is the raw failure of `chrome.tabs.sendMessage` to the **elected frame**: - nothing is listening there. -- Kodik rebuilds and renavigates its player frame, which invalidates the `documentId` the - election is pinned to. - -### Root cause (verified by reading, not yet by instrumentation) - -`getReadyTabVideoState()` recovers via -`refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true })`. On a page with no -reachable video, `selectedMediaTargetMoved()` returns `false`, the refresh reports -`unchanged`, and **the stale `currentTargetFrameId` / `currentTargetDocumentId` are kept**. -The second read fails identically and the error is returned. - -`adoptReportingFrame()` compounds it: adoption sets `currentTargetHasVideo = true`, and -once that is true `selectedMediaTargetMoved()` only moves on a frame/document *change*. If -the adopted frame then dies, there is no path back. - -### Intended fix - -When the content script in the elected frame is provably unreachable -(`Receiving end does not exist`, `No document with id`), the election is invalid: reset -the **frame** election to the top frame with `hasVideo: false` — never the tab selection — -so ordinary discovery and adoption can find the player again. Apply it in both places that -see the error: `getReadyTabVideoState()` and the connection-error branch of -`_routeToContentInternal()`. +Covered by `recovers when the adopted player frame is torn down and rebuilt`, which +adopts a nested player, destroys its document the way the real player does, and asserts +both that the election is released and that the rebuilt player is picked up again without +touching the popup. --- diff --git a/extension/background.js b/extension/background.js index 74d9b8d..cf2f654 100644 --- a/extension/background.js +++ b/extension/background.js @@ -145,6 +145,81 @@ function forgetFrameIds(tabId) { knownFrameIdsByTab.delete(normalizeTabId(tabId)); } +// A frame that was rebuilt is a new document: it carries neither the content +// script nor a monitor, so nothing in it can report the player that appears +// there later. Reinstalling monitors is cheap, bounded and idempotent, unlike a +// full reactivation — but it still needs a floor so page churn cannot turn it +// into a storm. +const MONITOR_REFRESH_INTERVAL_MS = 1500; +const lastMonitorRefreshByTab = new Map(); +const pendingMonitorRefreshByTab = new Map(); + +async function refreshMediaFrameMonitors(tabId) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) return false; + const last = lastMonitorRefreshByTab.get(normalizedTabId) || 0; + const waited = Date.now() - last; + if (waited < MONITOR_REFRESH_INTERVAL_MS) { + // Run once more after the cooldown instead of dropping this call. A + // rebuilt frame announces itself while its new document is still + // loading, so the reinstall that matters is usually the one a + // leading-edge-only debounce throws away. + if (!pendingMonitorRefreshByTab.has(normalizedTabId)) { + const timer = setTimeout(() => { + pendingMonitorRefreshByTab.delete(normalizedTabId); + refreshMediaFrameMonitors(normalizedTabId).catch(() => {}); + }, MONITOR_REFRESH_INTERVAL_MS - waited); + pendingMonitorRefreshByTab.set(normalizedTabId, timer); + } + return false; + } + lastMonitorRefreshByTab.set(normalizedTabId, Date.now()); + await injectMediaFrameMonitors(normalizedTabId, currentContentTarget()).catch(() => {}); + return true; +} + +/** + * Bounded poll for a player that no monitor can announce. + * + * Discovery is event-driven, and events come from monitors — so a frame that was + * rebuilt without one is invisible, and the video created there is never + * reported. Nothing then triggers the upkeep that would install the monitor: + * that is a deadlock, and it is what a real player does on a quality or part + * change. This runs only while a tab is selected and no video has been found, + * stops the moment one is, and costs a resolve that is fast precisely because + * there is no video to rank. + */ +const MEDIA_DISCOVERY_POLL_MS = 2000; +const MEDIA_DISCOVERY_POLL_LIMIT = 20; +let mediaDiscoveryPollTimer = null; +let mediaDiscoveryPollTicks = 0; + +function stopMediaDiscoveryPoll() { + if (mediaDiscoveryPollTimer !== null) { + clearTimeout(mediaDiscoveryPollTimer); + mediaDiscoveryPollTimer = null; + } + mediaDiscoveryPollTicks = 0; +} + +function startMediaDiscoveryPoll(tabId) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) return; + stopMediaDiscoveryPoll(); + const tick = async () => { + mediaDiscoveryPollTimer = null; + if (normalizeTabId(currentTabId) !== normalizedTabId) return; + if (currentTargetHasVideo === true) return; + if (++mediaDiscoveryPollTicks > MEDIA_DISCOVERY_POLL_LIMIT) return; + await refreshCurrentMediaTarget(normalizedTabId, { onlyIfTargetMoved: true }) + .catch(() => {}); + if (normalizeTabId(currentTabId) !== normalizedTabId) return; + if (currentTargetHasVideo === true) return; + mediaDiscoveryPollTimer = setTimeout(tick, MEDIA_DISCOVERY_POLL_MS); + }; + mediaDiscoveryPollTimer = setTimeout(tick, MEDIA_DISCOVERY_POLL_MS); +} + let mediaTargetRefreshTask = null; let mediaTargetRefreshTabId = null; let mediaTargetRefreshDirty = false; @@ -834,11 +909,51 @@ function sameContentTarget(left, right) { } function clearCurrentContentTarget() { + stopMediaDiscoveryPoll(); currentTargetFrameId = 0; currentTargetDocumentId = null; currentTargetHasVideo = false; } +/** The elected frame is gone, as opposed to merely holding no video. */ +function isContentUnreachableError(error) { + const message = String(typeof error === 'string' ? error : (error?.message || '')); + return message.includes('Receiving end does not exist') + || message.includes('Extension context invalidated') + || message.includes('No document with id') + || message.includes('No document with ID'); +} + +/** + * Gives up the frame election — never the tab selection — when the frame it + * names no longer exists. + * + * A player that rebuilds its frame (Kodik does this on quality and part + * changes) invalidates the documentId the election is pinned to. Without this, + * the pointer stayed dead forever: the guarded refresh reports "unchanged" + * because no video is reachable, and adoption had already set hasVideo, so + * nothing would move the target back. Clearing hasVideo re-opens both the + * ordinary promotion path and adoption. + */ +function releaseUnreachableFrameTarget(tabId) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null || normalizedTabId !== normalizeTabId(currentTabId)) return false; + if (normalizeFrameId(currentTargetFrameId) === 0 + && currentTargetDocumentId === null + && currentTargetHasVideo !== true) { + return false; + } + addLog(`Media frame ${currentTargetFrameId} is unreachable; releasing the frame election`, 'warn'); + clearCurrentContentTarget(); + startMediaDiscoveryPoll(normalizedTabId); + chrome.storage.session.set({ + currentTargetFrameId: 0, + currentTargetDocumentId: null, + currentTargetHasVideo: false + }).catch(() => {}); + return true; +} + function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { return false; @@ -2182,6 +2297,13 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio // script down and reinject it, which kept the target permanently activating. // Only an unreachable content script justifies recovery. if (!state || state.error) { + // Distinguish "this frame is gone" from "this page has no video yet". + // Only the former invalidates the election — and it also means there is + // no content script left to talk to: switching the target away from a + // frame tears its script down deliberately, so releasing the election + // alone would point at an empty frame. That needs a real rebuild, which + // cannot loop because only an unreachable script triggers it. + if (isContentUnreachableError(state?.error)) releaseUnreachableFrameTarget(tabId); const activation = await refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true }); if (activation?.status !== 'ok' && activation?.status !== 'unchanged') { return { error: 'Target tab changed before content script recovery completed' }; @@ -2199,6 +2321,7 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio return { error: 'Target tab changed while video state was being read' }; } } + if (currentTargetHasVideo !== true) refreshMediaFrameMonitors(tabId).catch(() => {}); return decorateVideoState(tabId, state); } @@ -3079,7 +3202,13 @@ async function activateTargetTab(tabId, tabTitle, { return { status: 'superseded' }; } if (previousTabId === selectedTabId - && !sameContentTarget(previousContentTarget, injectedContentTarget)) { + && !sameContentTarget(previousContentTarget, injectedContentTarget) + && normalizeFrameId(previousContentTarget?.frameId) !== 0) { + // A frame switch inside the same tab must not tear down the top + // frame. It hosts the chat overlay, answers status queries, and is + // what the frame election falls back to when a player frame dies — + // destroying it is why chat delivery failed after promotion and why + // that fallback pointed at an empty frame. await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false }); } currentTabId = selectedTabId; @@ -3092,6 +3221,8 @@ async function activateTargetTab(tabId, tabTitle, { ? injectedContentTarget.documentId : null; currentTargetHasVideo = injectedContentTarget.hasVideo === true; + if (currentTargetHasVideo) stopMediaDiscoveryPoll(); + else startMediaDiscoveryPoll(selectedTabId); lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); await chrome.storage.session.set({ @@ -3162,7 +3293,14 @@ async function selectedMediaTargetMoved(tabId) { // still loading, or that offers several equally-ranked mirrors, resolves // differently from one moment to the next; acting on that flips the target // back and forth and leaves activation running forever. - if (resolved.hasVideo !== true) return false; + if (resolved.hasVideo !== true) { + // No player anywhere yet. This runs on every lifecycle wake-up, so it is + // the reliable place to make sure freshly rebuilt documents carry a + // monitor — without one, the video created there next is never reported + // and the target can never come back. + refreshMediaFrameMonitors(tabId).catch(() => {}); + return false; + } if (currentTargetHasVideo !== true) return true; return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId) || (typeof resolved.documentId === 'string' @@ -3324,6 +3462,12 @@ if (chrome.tabs?.onRemoved?.addListener) { const isPending = pending?.tabId === tabId; const isActivating = activeTargetActivation?.tabId === tabId; forgetFrameIds(tabId); + lastMonitorRefreshByTab.delete(tabId); + const pendingMonitorTimer = pendingMonitorRefreshByTab.get(tabId); + if (pendingMonitorTimer !== undefined) { + clearTimeout(pendingMonitorTimer); + pendingMonitorRefreshByTab.delete(tabId); + } const isSelected = normalizeTabId(userSelectedTabId) === tabId; if (isSelected) await clearUserSelection(tabId); if (!isCurrent && !isPending && !isActivating) return; @@ -3438,12 +3582,11 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp, return; } - const message = String(error?.message || ''); - if (message.includes('Receiving end does not exist') - || message.includes('Extension context invalidated') - || message.includes('No document with id') - || message.includes('No document with ID')) { + if (isContentUnreachableError(error)) { try { + // Drop the dead election first so the rebuild is not anchored to + // a frame that no longer exists. + releaseUnreachableFrameTarget(tabId); const response = await refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: false }); if (response?.status !== 'ok' && response?.status !== 'activation_in_progress') return; await new Promise(resolve => setTimeout(resolve, 150)); @@ -4608,6 +4751,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { addLog(`Media frame candidate refresh failed: ${error.message}`, 'warn'); return { status: 'error', message: error.message }; }); + // A layout change is exactly the shape of a rebuilt player frame, and a + // new document carries no monitor, so the video it creates next would go + // unreported. Do this on every notification rather than on a particular + // status: a concurrent refresh masks the status, and the call is already + // debounced and idempotent. + await refreshMediaFrameMonitors(tabId); sendResponse(activation || { status: 'invalid_tab' }); } else if (message.type === 'MEDIA_FRAME_VISIBILITY') { if (!isCurrentContentSender(sender)) { diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index dbcf615..9886c76 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -773,6 +773,68 @@ test('controls and adopts a nested player even while the top frame is elected', expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true }); }); +test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => { + test.setTimeout(90000); + // Kodik rebuilds its player frame on quality and part changes, which kills + // the documentId the election is pinned to. The election has to be given up, + // otherwise every later message fails with "Receiving end does not exist" + // and nothing moves the target back. + const url = `${baseURL}/pages/yummy-deferred-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId } = await selectTargetTab(context, extensionId, url); + // After the rebuild both the detached and the live frame carry the same URL, + // so take the most recent attached one or the test drives a dead document. + const deferredFrame = () => page.frames() + .filter(frame => !frame.isDetached() + && frame.url().endsWith('/frames/deferred-player-frame.html')) + .pop(); + + await deferredFrame().locator('#poster').click(); + await expect + .poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }) + .then(state => state.targetHasVideo), { timeout: 20000 }) + .toBe(true); + const adoptedFrameId = (await getExtensionState(context, extensionId, { type: 'GET_STATUS' })).targetFrameId; + expect(adoptedFrameId).not.toBe(0); + + // Destroy the elected document the way the real player does. + const wrapper = page.frames().find(frame => frame.url().includes('xfp-wrapper.html?player=deferred')); + await wrapper.evaluate(() => { + const inner = document.getElementById('inner'); + inner.src = inner.src; + }); + await expect.poll(() => deferredFrame()?.locator('#poster').count().catch(() => 0)).toBe(1); + + // The dead election must be released rather than kept forever. + await expect + .poll(() => getExtensionState(context, extensionId, { type: 'GET_VIDEO_STATE', tabId }) + .then(state => state?.error || null), { timeout: 20000 }) + .toBeNull(); + const released = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(released).toMatchObject({ targetTabId: tabId, targetHasVideo: false }); + + // And the rebuilt player is picked up again without touching the popup. The + // rebuilt document wires its poster from an inline script, so a click can + // land before the handler exists — retry until the player is really built. + await expect.poll(async () => { + const frame = deferredFrame(); + if (!frame) return 0; + if (await frame.locator('video').count() > 0) return 1; + await frame.locator('#poster').click({ timeout: 2000 }).catch(() => {}); + return 0; + }, { timeout: 20000 }).toBe(1); + await expect + .poll(() => deferredFrame().locator('video').getAttribute('data-koala-attached'), { timeout: 20000 }) + .toBe('true'); + await expect + .poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }) + .then(state => state.targetHasVideo), { timeout: 20000 }) + .toBe(true); +}); + test('keeps the tab selected when its activation fails', async ({ context, extensionId }) => { // A page the extension is not allowed to script stands in for any activation // failure the user can act on. Losing the selection here is what made the