Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions constants/dnr.ts
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions tests/utils/dnrCookieInjector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<void>(() => {
// 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()
})
})
112 changes: 98 additions & 14 deletions utils/dnrCookieInjector.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<boolean> {
if (!hasDnrApi()) {
return false
}

const updateSessionRules = (globalThis as any).chrome.declarativeNetRequest
.updateSessionRules as ((details: any) => Promise<void>) | 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.
*/
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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 })
}
}
60 changes: 60 additions & 0 deletions utils/timeout.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<T>(
promise: Promise<T>,
options: { timeoutMs: number; label?: string },
): Promise<T> {
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<T>((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)
})
})
}