fix(extension): release a dead frame election and break the discovery deadlock

Reported as "Receiving end does not exist" with targetReady true and no
activation errors: the election named a frame the player had already torn down.
Three defects were stacked, each hidden by the one in front of it.

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 by setting hasVideo,
after which 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.

Switching frames destroyed the top frame's scripts. Promoting the target out of
frame 0 called deactivateTargetTab() on the previous target, which sent
TARGET_DEACTIVATE to frame 0 and tore down its content script and the chat
overlay with it. 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.

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. Monitors
are reinstalled on every lifecycle notification with a trailing-edge debounce,
and a bounded discovery poll breaks the cycle when no notification arrives at
all: 2s, capped, only while a tab is selected with no video found, stopping the
moment one is.

The new browser test adopts a nested player, destroys its document the way the
real player does, and asserts both the release and that the rebuilt player is
picked up again without touching the popup. It was flaky until the deadlock was
closed, and now passes repeatedly at a stable 8.3s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 20:43:58 +02:00
parent 488a9a086f
commit 226453fd45
3 changed files with 248 additions and 47 deletions
+30 -40
View File
@@ -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.
---
+156 -7
View File
@@ -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)) {
+62
View File
@@ -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