Inspected the live KODIK layout: the player is a cross-origin frame at depth 2
inside a visible same-origin wrapper, with no sandbox attribute and no video
element at all until playback starts.
The registry wipe added in the previous commit was wrong. tabs.onUpdated reports
status 'loading' for same-document History API navigations as well, which is
exactly what these sites do when you switch mirror or episode part. The wipe
therefore landed while the player frame was being built, leaving the recovery
probe with nothing to fall back on; if the all-frames sweep was rejected at that
moment the target stayed on frame 0 with no way back.
The registry now self-corrects instead: a probe that reached more than the top
frame is authoritative and current, so it supersedes the stored ids. A probe
that only reached frame 0 proves nothing and merges. Stale ids cost one bounded,
isolated probe until the next good resolve replaces them.
This matches the reported symptom, where audio boost and compressor worked on
KODIK — proving the content script was running in the player frame — while the
dev panel still reported frame 0 with no video, because the target had fallen
back after promotion had already succeeded once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read the whole target workflow end to end rather than re-running the suite.
Five defects, none of which the existing tests could have caught.
A srcless iframe resolves to its parent document's URL, so a hidden ad slot
could mark the page containing it as hidden and exclude the real player. The
hidden-frame filter now requires the frame element to have actually carried a
src, and ignores any frame claiming the href of the document that reported it.
The frame registry was fed only by incoming sender.frameId, which is empty
during the first activation — exactly when the all-frames sweep needs a
fallback. The resolver now reports every frame it reached and those ids are
recorded before anything is injected.
Monitor injection and deactivation both went through the sweep alone. A
rejected sweep therefore left deep frames without a monitor (so they could
never report themselves, keeping the registry empty) and, on the way out, left
stale monitors reporting after a target switch. Both now address known frames
individually as well.
Registry eviction skipped when the oldest entry was the top frame, so the set
could grow without bound, and a cap of 64 meant up to 64 individual probes —
the same cost the removed 0..64 sweep had. The cap is 24, eviction always
removes a non-top frame, and a committed navigation drops the tab's ids so dead
frames from the previous page are not probed forever.
The stuck-activation watchdog only ran inside GET_STATUS, so it never fired
while the popup was closed — the one situation it exists for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagnosed against a fixture rebuilt from the live yummyanime.tv page, with the
ad churn the real site produces. Under that churn the resolver reported
frame=0, hasVideo=false while the video demonstrably existed two frame levels
down: one ad slot tearing down mid-call makes Chromium reject the whole
allFrames sweep, and the resolver then silently fell back to the top frame and
never looked again.
v3.1.2 did not have this failure because webNavigation.getAllFrames() gave it
an explicit frame list. That list is now rebuilt without the permission: every
content script that messages the background carries sender.frameId, so the
background keeps a per-tab registry of frames it has seen and the resolver asks
any frame the sweep missed directly. One rejected probe now costs one frame
instead of the whole page.
Two supporting fixes fell out of the same investigation. Frames reported hidden
by an ancestor that could inspect them directly — the 0x0 same-origin wrapper an
anime host parks unwatched mirrors in — are now excluded without waiting for the
postMessage visibility handshake, which was the tie the resolver kept failing to
break. And leaf frames with no video and no nested frames are left out of that
handshake entirely, so a churning ad slot can no longer make every phase wait on
a frame that is already gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Google Drive and YummyAnime host their player in a cross-origin iframe. The
3.1.2 targeting work reached those frames but misdiagnosed and destabilized
them in four separate ways. No manifest permission is added or restored;
webNavigation stays removed.
Access diagnosis was inferred, not measured. Every frame probe error was
swallowed, and any origin that failed to answer was reported as missing host
access. A slow or still-loading player frame therefore produced
"Host access required for youtube.googleapis.com" for an origin the extension
already held. The resolver now asks permissions.contains() before raising an
access error, and treats a granted-but-unresponsive origin as a retry, not a
user decision.
Probes were unbounded. Every executeScript in the resolver now runs under a
timeout, so one unreachable frame can no longer stall an activation, and the
retry budget drops from eight passes to three.
The chat overlay followed the player into its frame, which rendered it on top
of the video and scoped closing and minimizing to that frame. It is now always
installed in the tab's top document, with all chat traffic routed to frame 0,
while only the playback controller goes into the selected media frame.
Nested targets reactivated continuously. Every heartbeat and content event
revalidated the target with a full teardown and reinjection, and the media
monitor treated ordinary play, pause and buffering as frame layout changes.
Both paths now reactivate only when the selected frame or document actually
moves.
Also restores the audio-route retention that keeps a deselected tab audible:
createMediaElementSource() can only be called once per element, so a
reinjected content script must adopt the existing route rather than rebuild it.
Verified with 90 unit tests, 40 browser E2E tests including two new
Drive-shaped fixtures that assert the controller lands in the player frame
while the chat stays in the top document, and npm run verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores extension/ to the state directly after webNavigation was removed
(4d78970). The six follow-up commits layered heuristics on an unverified
premise (frame-ID sweeps, multi-phase probes, retry loops) without fixing
the underlying resolver. They are removed so the real fix can be built on
a known state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>