mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
fix(extension): stop a single unresponsive frame from stalling activation
v3.1.2 worked on the nested anime players. Removing the webNavigation
permission in 4d78970 replaced its per-frame listing with one allFrames call
and left the injection path unbounded, and that is what broke them.
Both properties are restored without the permission. The allFrames sweep now
only discovers the frame list — it already reports frameId and documentId for
every frame it reached — and each probe after it is addressed to a single
frame, so a player or ad frame that never answers can no longer cancel the
others. Every chrome.scripting.executeScript in the injection path is bounded
by a timeout; nine of them could previously stay pending forever, which pinned
activeTargetActivation and left the popup reporting "activating" with nothing
in the log.
A watchdog abandons any activation still running after 30s and turns it into a
reportable error, so that state cannot be permanent again regardless of cause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+77
-12
@@ -2330,15 +2330,46 @@ function createTargetActivationSupersededError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
const SCRIPT_INJECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* chrome.scripting.executeScript can stay pending indefinitely when a target
|
||||
* frame is busy or navigating — an embedded player or ad frame is enough, and
|
||||
* an allFrames call only needs one of them. An activation that never settles
|
||||
* leaves the popup on "activating" forever, so every injection is bounded.
|
||||
*/
|
||||
function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_MS) {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return chrome.scripting.executeScript(options);
|
||||
}
|
||||
let timeoutId = null;
|
||||
const label = Array.isArray(options?.files) && options.files.length > 0
|
||||
? options.files.join(', ')
|
||||
: 'function injection';
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
const error = new Error(`Script injection timed out after ${timeoutMs}ms (${label})`);
|
||||
error.code = 'script_injection_timeout';
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
return Promise.race([
|
||||
chrome.scripting.executeScript(options),
|
||||
timeout
|
||||
]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
async function injectMediaFrameMonitors(tabId, contentTarget) {
|
||||
const targets = listMediaFrameScriptTargets(tabId);
|
||||
let injectedCount = 0;
|
||||
await Promise.all(targets.map(async target => {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target,
|
||||
files: ['media-frame-monitor.js']
|
||||
});
|
||||
}, 2000);
|
||||
injectedCount++;
|
||||
} catch {
|
||||
// One denied widget frame must not block the selected player.
|
||||
@@ -2353,10 +2384,10 @@ async function injectMediaFrameMonitors(tabId, contentTarget) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target,
|
||||
files: ['media-frame-monitor.js']
|
||||
});
|
||||
}, 2000);
|
||||
injectedCount++;
|
||||
} catch {
|
||||
// Main injection below reports a real selected-target failure.
|
||||
@@ -2425,12 +2456,12 @@ async function injectContentScript(tabId, {
|
||||
}
|
||||
if (needsPageApiSeek) {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
world: 'MAIN',
|
||||
files: ['page-api-seek-overrides.js']
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
world: 'MAIN',
|
||||
func: installPageApiSeekBridge
|
||||
@@ -2441,11 +2472,11 @@ async function injectContentScript(tabId, {
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
files: ['page-api-seek-overrides.js']
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
func: setPageApiSeekEnabled,
|
||||
args: [pageApiSeekReady]
|
||||
@@ -2456,13 +2487,13 @@ async function injectContentScript(tabId, {
|
||||
// selected media frame.
|
||||
let injectionResults;
|
||||
if (contentTarget.frameId === 0) {
|
||||
injectionResults = await chrome.scripting.executeScript({
|
||||
injectionResults = await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
await executeScriptWithTimeout({
|
||||
target: { tabId, frameIds: [0] },
|
||||
files: ['chat-format.js', 'chat-overlay.js']
|
||||
});
|
||||
@@ -2477,7 +2508,7 @@ async function injectContentScript(tabId, {
|
||||
null,
|
||||
contentTarget.documentId
|
||||
).catch(() => {});
|
||||
injectionResults = await chrome.scripting.executeScript({
|
||||
injectionResults = await executeScriptWithTimeout({
|
||||
target: scriptTarget,
|
||||
files: ['content.js']
|
||||
});
|
||||
@@ -2684,6 +2715,35 @@ async function clearPendingTarget({ expectedRequestId = null, expectedTabId = nu
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVATION_DEADLINE_MS = 30000;
|
||||
|
||||
/**
|
||||
* Last line of defence for the "activating" state.
|
||||
*
|
||||
* Every known way an activation can stall is bounded by now, but a browser call
|
||||
* that never settles would still pin activeTargetActivation and leave the popup
|
||||
* spinning with nothing in the log. Past the deadline the attempt is declared
|
||||
* dead so the selection can report a real error and be retried deliberately.
|
||||
*/
|
||||
function expireStuckActivation() {
|
||||
const startedAt = activeTargetActivation?.startedAt;
|
||||
if (!Number.isFinite(startedAt) || Date.now() - startedAt < ACTIVATION_DEADLINE_MS) {
|
||||
return false;
|
||||
}
|
||||
const stalledTabId = normalizeTabId(activeTargetActivation.tabId);
|
||||
addLog(`Target activation for tab ${stalledTabId} exceeded ${ACTIVATION_DEADLINE_MS}ms; abandoning it`, 'warn');
|
||||
activeTargetActivation = null;
|
||||
if (stalledTabId !== null && normalizeTabId(userSelectedTabId) === stalledTabId) {
|
||||
userSelectionErrorTabId = stalledTabId;
|
||||
userSelectionErrorMessage = 'The page never finished responding to script injection';
|
||||
chrome.storage.session.set({
|
||||
selectionErrorTabId: userSelectionErrorTabId,
|
||||
selectionErrorMessage: userSelectionErrorMessage
|
||||
}).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function rememberUserSelection(tabId, tabTitle) {
|
||||
const normalizedTabId = normalizeTabId(tabId);
|
||||
if (normalizedTabId === null) return false;
|
||||
@@ -2758,7 +2818,11 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
|
||||
completeForceSyncBeforeTargetChange(selectedTabId);
|
||||
const activationGeneration = ++targetActivationGeneration;
|
||||
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
|
||||
activeTargetActivation = {
|
||||
generation: activationGeneration,
|
||||
tabId: selectedTabId,
|
||||
startedAt: Date.now()
|
||||
};
|
||||
const previousTabId = normalizeTabId(currentTabId);
|
||||
const previousContentTarget = currentContentTarget();
|
||||
let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false };
|
||||
@@ -3444,6 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.retryPendingTarget === true) {
|
||||
await retryPendingTarget();
|
||||
}
|
||||
expireStuckActivation();
|
||||
const pendingTarget = await readPendingTarget();
|
||||
const settings = await getSettings();
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
|
||||
@@ -437,6 +437,30 @@ export function listMediaFrameScriptTargets(tabId) {
|
||||
return [{ tabId, allFrames: true }];
|
||||
}
|
||||
|
||||
/** Pins one probe to one frame, preferring the exact document when known. */
|
||||
function frameScriptTarget(tabId, entry) {
|
||||
const frameId = normalizeFrameId(entry?.frameId);
|
||||
return typeof entry?.documentId === 'string' && entry.documentId
|
||||
? { tabId, documentIds: [entry.documentId] }
|
||||
: { tabId, frameIds: [frameId] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Later results replace earlier ones for the same frame. A frame that navigated
|
||||
* between two probes must not appear twice, because two stale copies of one
|
||||
* frame look exactly like two competing players.
|
||||
*/
|
||||
function mergeFrameResults(...groups) {
|
||||
const merged = new Map();
|
||||
for (const group of groups) {
|
||||
for (const entry of Array.isArray(group) ? group : []) {
|
||||
if (!Number.isInteger(entry?.frameId)) continue;
|
||||
merged.set(`frame:${entry.frameId}`, entry);
|
||||
}
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function probeTimeoutError(label, timeoutMs) {
|
||||
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
||||
error.code = MEDIA_FRAME_PROBE_TIMEOUT;
|
||||
@@ -544,10 +568,17 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
||||
|
||||
if (results.length > 1) {
|
||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||
// Address each discovered frame on its own from here on. A single
|
||||
// allFrames call is all-or-nothing: one player or ad frame that
|
||||
// never answers takes the whole probe down with it. v3.1.2 avoided
|
||||
// that by listing frames through webNavigation — but the sweep
|
||||
// above already reports frameId and documentId for every frame it
|
||||
// reached, so the same isolation costs no permission at all.
|
||||
const frameTargets = results.map(entry => frameScriptTarget(tabId, entry));
|
||||
try {
|
||||
await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
frameTargets,
|
||||
installParentFrameVisibilityProbe,
|
||||
[token],
|
||||
probeTimeoutMs
|
||||
@@ -556,7 +587,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
frameTargets,
|
||||
dispatchParentFrameVisibilityProbe,
|
||||
[token],
|
||||
probeTimeoutMs
|
||||
@@ -565,12 +596,12 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
||||
}
|
||||
const { results: inspected } = await executeInAccessibleFrames(
|
||||
chromeApi,
|
||||
scriptTargets,
|
||||
frameTargets,
|
||||
inspectMediaFrame,
|
||||
[token],
|
||||
probeTimeoutMs
|
||||
);
|
||||
if (inspected.length > 0) results = inspected;
|
||||
if (inspected.length > 0) results = mergeFrameResults(results, inspected);
|
||||
} catch {
|
||||
// Initial results remain usable, but equally-ranked unknown
|
||||
// frames will be rejected below rather than guessed.
|
||||
|
||||
@@ -132,7 +132,10 @@ describe('cross-origin media-frame targeting', () => {
|
||||
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
|
||||
options.func?.name === 'dispatchParentFrameVisibilityProbe'
|
||||
));
|
||||
expect(visibilityDispatches).toHaveLength(4);
|
||||
// Four passes across both discovered frames, each addressed on its own
|
||||
// so a frame that never answers cannot cancel the others.
|
||||
expect(visibilityDispatches).toHaveLength(8);
|
||||
expect(visibilityDispatches.every(([options]) => options.target.allFrames !== true)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the top target inactive when the only discovered video is hidden', async () => {
|
||||
|
||||
Reference in New Issue
Block a user