diff --git a/constants/dnr.ts b/constants/dnr.ts new file mode 100644 index 0000000000..e5f2f6cb37 --- /dev/null +++ b/constants/dnr.ts @@ -0,0 +1,8 @@ +export const DNR_UPDATE_SESSION_RULES_TIMEOUT_MS = 5_000 +export const DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS = 2 +export const DNR_UPDATE_SESSION_RULES_RETRY_DELAY_MS = 250 + +export const DNR_UPDATE_SESSION_RULES_TIME_BUDGET_MS = + DNR_UPDATE_SESSION_RULES_TIMEOUT_MS * DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS + + DNR_UPDATE_SESSION_RULES_RETRY_DELAY_MS * + (DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS - 1) diff --git a/features/AccountManagement/components/AccountDialog/hooks/useAccountDialog.ts b/features/AccountManagement/components/AccountDialog/hooks/useAccountDialog.ts index 43035d73b1..30c663a886 100644 --- a/features/AccountManagement/components/AccountDialog/hooks/useAccountDialog.ts +++ b/features/AccountManagement/components/AccountDialog/hooks/useAccountDialog.ts @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next" import { useChannelDialog } from "~/components/ChannelDialog" import { DIALOG_MODES, type DialogMode } from "~/constants/dialogModes" +import { DNR_UPDATE_SESSION_RULES_TIME_BUDGET_MS } from "~/constants/dnr" import { RuntimeActionIds } from "~/constants/runtimeActions" import { autoDetectAccount, @@ -27,7 +28,7 @@ import { } from "~/utils/browserApi" import { createLogger } from "~/utils/logger" -const AUTO_DETECT_SLOW_HINT_DELAY_MS = 10_000 +const AUTO_DETECT_SLOW_HINT_DELAY_MS = DNR_UPDATE_SESSION_RULES_TIME_BUDGET_MS /** * Logger scoped to the account dialog lifecycle. Ensure we never include raw tokens/cookies in log details. diff --git a/tests/utils/dnrCookieInjector.test.ts b/tests/utils/dnrCookieInjector.test.ts index 9fcc13123f..43f6a5a50b 100644 --- a/tests/utils/dnrCookieInjector.test.ts +++ b/tests/utils/dnrCookieInjector.test.ts @@ -17,6 +17,7 @@ describe("dnrCookieInjector", () => { afterEach(() => { ;(globalThis as any).chrome = originalChrome + vi.useRealTimers() }) it("buildTempWindowCookieRule should create a per-tab rule with stable id and cookie header override", () => { @@ -75,4 +76,31 @@ describe("dnrCookieInjector", () => { expect(updateSessionRules).toHaveBeenCalledTimes(1) expect(updateSessionRules).toHaveBeenCalledWith({ removeRuleIds: [42] }) }) + + it("applyTempWindowCookieRule should time out instead of hanging when updateSessionRules never settles", async () => { + vi.useFakeTimers() + + const updateSessionRules = vi.fn().mockImplementation( + () => + new Promise(() => { + // never resolves + }), + ) + + ;(globalThis as any).chrome = { + declarativeNetRequest: { updateSessionRules }, + } + + const promise = applyTempWindowCookieRule({ + tabId: 5, + url: "https://example.com/api", + cookieHeader: "cf_clearance=abc", + }) + + // Flush the internal timeout + retry timers. + await vi.runAllTimersAsync() + + await expect(promise).resolves.toBeNull() + expect(updateSessionRules).toHaveBeenCalled() + }) }) diff --git a/utils/dnrCookieInjector.ts b/utils/dnrCookieInjector.ts index b3c8272437..2038c92a8a 100644 --- a/utils/dnrCookieInjector.ts +++ b/utils/dnrCookieInjector.ts @@ -1,8 +1,14 @@ +import { + DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS, + DNR_UPDATE_SESSION_RULES_RETRY_DELAY_MS, + DNR_UPDATE_SESSION_RULES_TIMEOUT_MS, +} from "~/constants/dnr" import { COOKIE_AUTH_HEADER_NAME, EXTENSION_HEADER_NAME, } from "~/utils/cookieHelper" import { createLogger } from "~/utils/logger" +import { sleep, withTimeout } from "~/utils/timeout" /** * Unified logger scoped to DNR cookie header injection helpers. @@ -39,6 +45,78 @@ function hasDnrApi(): boolean { } } +/** + * Best-effort wrapper around `declarativeNetRequest.updateSessionRules` that: + * - times out instead of hanging forever (Chromium quirk after permission changes) + * - retries once to smooth over short-lived initialization races + */ +async function updateSessionRulesSafe( + params: any, + meta: { label: string }, +): Promise { + if (!hasDnrApi()) { + return false + } + + const updateSessionRules = (globalThis as any).chrome.declarativeNetRequest + .updateSessionRules as ((details: any) => Promise) | undefined + + if (!updateSessionRules) { + return false + } + + for ( + let attempt = 1; + attempt <= DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + await withTimeout(updateSessionRules(params), { + timeoutMs: DNR_UPDATE_SESSION_RULES_TIMEOUT_MS, + label: meta.label, + }) + return true + } catch (error) { + const message = (error as any)?.message || String(error || "") + const normalized = message.toLowerCase() + const isTimeout = + (error as any)?.name === "TimeoutError" || + normalized.includes("timed out") + const isPermissionError = + normalized.includes("permission") && + (normalized.includes("required") || + normalized.includes("denied") || + normalized.includes("not allowed")) + + if (isPermissionError) { + logger.debug("DNR session rules blocked by missing permission", { + label: meta.label, + attempt, + error, + }) + return false + } + + logger.warn("Failed to update DNR session rules", { + attempt, + label: meta.label, + error, + }) + + // Retry only on timeouts; other failures are usually deterministic (e.g. invalid args). + if (!isTimeout) { + return false + } + + if (attempt < DNR_UPDATE_SESSION_RULES_MAX_ATTEMPTS) { + await sleep(DNR_UPDATE_SESSION_RULES_RETRY_DELAY_MS) + } + } + } + + return false +} + /** * Builds a stable rule ID for a given tab. */ @@ -92,16 +170,18 @@ export async function applyTempWindowCookieRule( const ruleId = buildRuleId(params.tabId) const rule = buildTempWindowCookieRule(params) - try { - await (globalThis as any).chrome.declarativeNetRequest.updateSessionRules({ + const ok = await updateSessionRulesSafe( + { removeRuleIds: [ruleId], addRules: [rule], - }) - return ruleId - } catch (error) { - logger.warn("Failed to install temp-window cookie rule", error) - return null - } + }, + { + label: + "declarativeNetRequest.updateSessionRules(installTempWindowCookieRule)", + }, + ) + + return ok ? ruleId : null } /** @@ -114,11 +194,15 @@ export async function removeTempWindowCookieRule( return } - try { - await (globalThis as any).chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [ruleId], - }) - } catch (error) { - logger.warn("Failed to remove temp-window cookie rule", error) + const ok = await updateSessionRulesSafe( + { removeRuleIds: [ruleId] }, + { + label: + "declarativeNetRequest.updateSessionRules(removeTempWindowCookieRule)", + }, + ) + + if (!ok) { + logger.warn("Failed to remove temp-window cookie rule", { ruleId }) } } diff --git a/utils/timeout.ts b/utils/timeout.ts new file mode 100644 index 0000000000..1f64311d62 --- /dev/null +++ b/utils/timeout.ts @@ -0,0 +1,60 @@ +/** + * Promise timeout utilities. + * + * Browser extension APIs (especially in MV3 service workers) can occasionally + * return Promises that never settle (neither resolve nor reject), typically + * around permission changes or early startup race conditions. + * + * These helpers keep the app responsive by turning "hung" Promises into + * deterministic failures that callers can handle and retry. + */ + +export class TimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = "TimeoutError" + } +} + +/** + * Returns a Promise that resolves after the specified delay in milliseconds. + * @param ms Number of milliseconds to sleep. + */ +export function sleep(ms: number): Promise { + const delay = Number.isFinite(ms) ? Math.max(0, ms) : 0 + return new Promise((resolve) => setTimeout(resolve, delay)) +} + +/** + * Resolves/rejects with the supplied promise, but rejects with {@link TimeoutError} + * if it does not settle within `timeoutMs`. + */ +export function withTimeout( + promise: Promise, + options: { timeoutMs: number; label?: string }, +): Promise { + const timeoutMs = Number.isFinite(options.timeoutMs) + ? Math.max(0, options.timeoutMs) + : 0 + const label = options.label ?? "Operation" + + if (timeoutMs <= 0) { + return promise + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new TimeoutError(`${label} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + promise + .then((value) => { + clearTimeout(timer) + resolve(value) + }) + .catch((error) => { + clearTimeout(timer) + reject(error) + }) + }) +}