fix(extension): stop the video-state poll from restarting the target

getReadyTabVideoState() treated "no video found" as a broken injection and
forced a full reactivation. On a page that legitimately has no video yet — an
anime or Drive page before playback starts — that fired on every call, and the
dev panel polls it on a timer. The result was an endless teardown and
reinjection cycle: the target never settled, the popup showed "activating"
forever, and the panel reported "Target tab changed before content script
recovery completed" because each read raced the reactivation it had triggered.

Only an unreachable content script justifies recovery now, and that recovery no
longer reinjects unless the selected frame actually moved.

Audited against v3.1.2, which worked on these pages. The only unjustified
deviation left was the retry budget, which had been cut from eight passes to
three and shortened the window for a late-loading player; it is back at eight,
now bounded by a wall-clock deadline instead of being unbounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 01:05:03 +02:00
parent ac0093b043
commit f450584562
4 changed files with 60 additions and 9 deletions
+14 -5
View File
@@ -2059,17 +2059,26 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio
return { error: 'Target tab changed before video state could be read' };
}
let state = await getTabVideoState(tabId);
if (!state || state.error || state.found === false) {
const activation = await refreshCurrentMediaTarget(tabId);
if (activation?.status !== 'ok') {
// "No video" is a legitimate answer, not a broken injection: an anime or
// Drive page has no video element until the viewer starts playback. Forcing
// a reactivation for it made every poll of this function tear the content
// script down and reinject it, which kept the target permanently activating.
// Only an unreachable content script justifies recovery.
if (!state || state.error) {
const activation = await refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true });
if (activation?.status !== 'ok' && activation?.status !== 'unchanged') {
return { error: 'Target tab changed before content script recovery completed' };
}
// An unchanged target reports no generation of its own.
const generation = Number.isInteger(activation.generation)
? activation.generation
: targetActivationGeneration;
await new Promise(resolve => setTimeout(resolve, 250));
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
if (!isCurrentTargetIdentity(tabId, generation)) {
return { error: 'Target tab changed before video state could be read' };
}
state = await getTabVideoState(tabId);
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
if (!isCurrentTargetIdentity(tabId, generation)) {
return { error: 'Target tab changed while video state was being read' };
}
}
+11 -2
View File
@@ -526,11 +526,17 @@ async function originAccessIsWithheld(chromeApi, originPattern) {
}
export async function resolveMediaContentTarget(chromeApi, tabId, {
attempts = 3,
// v3.1.2's retry budget: a player frame can take several seconds to appear,
// and giving up early is what turns a slow page into "no video found".
attempts = 8,
retryDelayMs = 200,
probeDelayMs = 60,
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
// ...but the budget is now wall-clock bounded, so a page whose frames all
// time out cannot hold the activation open for minutes.
deadlineMs = 12000
} = {}) {
const startedAt = Date.now();
let fallback = null;
let missingAccess = null;
let ambiguous = false;
@@ -636,6 +642,9 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
}
}
if (Number.isFinite(deadlineMs) && deadlineMs > 0 && Date.now() - startedAt >= deadlineMs) {
break;
}
if (attempt < attempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
+3 -2
View File
@@ -46,7 +46,7 @@ describe('target tab lifecycle', () => {
it('does not reactivate the target for ordinary playback churn', () => {
expect(backgroundSource).toContain('async function selectedMediaTargetMoved(tabId)');
expect(backgroundSource).toContain('onlyIfTargetMoved = false');
expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(4);
expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(5);
// Playback state must stay out of the candidate signature, otherwise
// every play/pause looks like a frame layout change.
expect(monitorSource).not.toContain('element.paused ? 0 : 1');
@@ -60,7 +60,8 @@ describe('target tab lifecycle', () => {
);
expect(resolverSource).toContain('async function originAccessIsWithheld(chromeApi, originPattern)');
expect(resolverSource).toContain('probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS');
expect(resolverSource).toContain('attempts = 3');
expect(resolverSource).toContain('attempts = 8');
expect(resolverSource).toContain('deadlineMs = 12000');
// A swallowed probe error is what turned a slow player frame into a
// permission prompt for an origin the extension already held.
expect(resolverSource).toContain('errors.push({ target, error })');