Skip to content
Merged
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
60 changes: 53 additions & 7 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ let knockTabs = []
let lastKnockProfile
let routingMigration = false
let knockRefresh = { pending: null, profileId: null, tabs: [] }
const EARLY_KNOCK_REQUEST_LIMIT = 256
const earlyKnockRequests = new Map()
let collectEarlyKnockRequests = platform.id === 'chromium'
const ready = Promise.all([
Expand Down Expand Up @@ -63,7 +64,8 @@ const ready = Promise.all([
'knockRefresh',
'knockTabs',
'assignedKnockLease',
'transientProxyLease'
'transientProxyLease',
'tabOverrides'
])
.then(data => {
knockTabs = data.knockTabs || []
Expand All @@ -73,8 +75,19 @@ const ready = Promise.all([
if (data.knockRefresh) {
collectEarlyKnockRequests = false
}
return data
})
])
]).then(async ([, session]) => {
const saved = session?.tabOverrides
if (platform.supportsTabRouting && saved?.routing === JSON.stringify(state.browserRouting)) {
const open = new Set((await api.tabs.query({})).map(tab => tab.id))
for (const [tabId, enabled] of saved.entries) {
if (open.has(tabId) && typeof enabled === 'boolean') {
tabOverrides.set(tabId, enabled)
}
}
}
})
let queue = Promise.resolve()
let networkController = new AbortController()
let pendingConnectionChanges = 0
Expand Down Expand Up @@ -729,6 +742,12 @@ async function runConnectionCheck() {
}
}

async function storeTabOverrides(overrides = tabOverrides) {
await api.storage.session?.set({
tabOverrides: { routing: JSON.stringify(state.browserRouting), entries: [...overrides] }
})
}

async function currentSite(tab) {
if (!tab) {
;[tab] = await api.tabs.query({ active: true, currentWindow: true })
Expand Down Expand Up @@ -1072,6 +1091,7 @@ async function handle(message) {
throw new Error('errorCurrentSite')
}

await storeTabOverrides(new Map(tabOverrides).set(site.tabId, !site.proxied))
tabOverrides.set(site.tabId, !site.proxied)
await api.tabs.reload(site.tabId)
return { ok: true, currentSite: await currentSite() }
Expand Down Expand Up @@ -1431,10 +1451,13 @@ async function handle(message) {

if (JSON.stringify(old.browserRouting) !== JSON.stringify(next.browserRouting)) {
tabOverrides.clear()
await storeTabOverrides()
}

if (message.reloadTabId !== undefined) {
tabOverrides.delete(message.reloadTabId)
if (tabOverrides.delete(message.reloadTabId)) {
await storeTabOverrides()
}
await api.tabs.reload(message.reloadTabId).catch(() => {})
}

Expand Down Expand Up @@ -1564,7 +1587,19 @@ platform.registerRouting({
})

api.tabs.onRemoved?.addListener(tabId => {
queue = queue.then(() => updateKnockTab(tabId, { closed: true })).catch(() => {})
queue = queue
.then(async () => {
await updateKnockTab(tabId, { closed: true })
if (tabOverrides.delete(tabId)) {
await storeTabOverrides()
}
})
.catch(() => {})
for (const [key, details] of earlyKnockRequests) {
if (details.tabId === tabId) {
earlyKnockRequests.delete(key)
}
}
for (const dialog of authDialogs.values()) {
if (dialog.dialogTabId === tabId) {
cancelAuth(dialog)
Expand All @@ -1580,7 +1615,6 @@ api.tabs.onRemoved?.addListener(tabId => {
badgeVersions.delete(tabId)
networkLog.delete(tabId)
tabUrls.delete(tabId)
tabOverrides.delete(tabId)
forcedTabs.delete(tabId)
})
api.tabs.onUpdated?.addListener((tabId, change, tab) => {
Expand Down Expand Up @@ -1950,7 +1984,13 @@ ready.then(configureStatistics)
api.webRequest.onBeforeRequest?.addListener(
details => {
if (collectEarlyKnockRequests && details.tabId >= 0 && /^https?:/.test(details.url)) {
earlyKnockRequests.set(`${details.tabId}:${new URL(details.url).hostname}`, details)
const url = new URL(details.url)
const key = `${details.tabId}:${url.hostname}`
// ponytail: retain at most 256 early tab/host pairs; oldest startup evidence is expendable.
if (!earlyKnockRequests.has(key) && earlyKnockRequests.size >= EARLY_KNOCK_REQUEST_LIMIT) {
earlyKnockRequests.delete(earlyKnockRequests.keys().next().value)
}
earlyKnockRequests.set(key, { tabId: details.tabId, type: details.type, url: url.origin })
}
queue = queue
.then(async () => {
Expand Down Expand Up @@ -2589,7 +2629,10 @@ async function receiveSync() {
startupError = unsupportedRouting ? 'splitUnsupportedWarning' : undefined
startupErrorDetails = undefined
reconcileAuthDialogs()
tabOverrides.clear()
if (JSON.stringify(old.browserRouting) !== JSON.stringify(next.browserRouting)) {
tabOverrides.clear()
await storeTabOverrides()
}
connectionCheck = null
await storeConnectionCheck()
if (
Expand Down Expand Up @@ -2722,6 +2765,9 @@ queue = queue
.then(async () => {
await ready
diagnosticLog?.write('background_started', { mode: state.connectionMode })
if (platform.id === 'chromium') {
await storeKnockRefresh()
}
if (recoverTransientProxy || routingMigration) {
await apply(state)
await api.storage.session?.set({ assignedKnockLease: false, transientProxyLease: false })
Expand Down
14 changes: 12 additions & 2 deletions scripts/http-request.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ export async function requestWithRetry(
request,
url,
options,
{ secrets = [], wait = setTimeout, maxAttempts = 3, initialDelay = 1000 } = {}
{ secrets = [], wait = setTimeout, maxAttempts = 3, initialDelay = 1000, timeoutMs } = {}
) {
for (let attempt = 0, rateLimits = 0; ; attempt++) {
const response = await request(url, options)
options?.signal?.throwIfAborted()
const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
const response = await request(
url,
signal
? {
...options,
signal: options?.signal ? AbortSignal.any([options.signal, signal]) : signal
}
: options
)
const limited = response.status === 429
const attempts = limited ? 6 : maxAttempts
if (
Expand Down
3 changes: 2 additions & 1 deletion scripts/prepare-gost-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ if (!bytes || createHash('sha256').update(bytes).digest('hex') !== checksum) {
const response = await requestWithRetry(
fetch,
`https://github.com/go-gost/gost/releases/download/v${version}/${path.basename(archive)}`,
{ signal: AbortSignal.timeout(60000) }
{},
{ timeoutMs: 60000 }
)
if (!response.ok) {
throw await globalThis.MegaErrors.httpError(
Expand Down
12 changes: 8 additions & 4 deletions scripts/renew-config-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ const ref = args[0]?.slice(6) || 'main'
const directory = fileURLToPath(new URL('../config-schema/', import.meta.url))

async function download(url) {
const response = await requestWithRetry(fetch, url, {
signal: AbortSignal.timeout(30000),
headers: { 'User-Agent': 'MegaProxy-schema-sync', 'Cache-Control': 'no-cache' }
})
const response = await requestWithRetry(
fetch,
url,
{
headers: { 'User-Agent': 'MegaProxy-schema-sync', 'Cache-Control': 'no-cache' }
},
{ timeoutMs: 30000 }
)
if (!response.ok) {
throw await globalThis.MegaErrors.httpError(
response,
Expand Down
2 changes: 1 addition & 1 deletion scripts/renew-identity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ if (args.length > 1 || args.some(arg => !/^--ref=[A-Za-z0-9._/-]+$/.test(arg)))
throw new Error('Use npm run renew-identity -- [--ref=<commit-or-branch>]')
}
async function download(path) {
const response = await requestWithRetry(fetch, path, { signal: AbortSignal.timeout(30000) })
const response = await requestWithRetry(fetch, path, {}, { timeoutMs: 30000 })
if (!response.ok) {
throw new Error(`Identity download failed: HTTP ${response.status} ${path}`)
}
Expand Down
4 changes: 3 additions & 1 deletion scripts/renew-list-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import '../extension/subscriptions.js'

const catalog = await globalThis.MegaSubscriptions.refreshCatalog(
undefined,
(url, options) => requestWithRetry(fetch, url, options),
// The browser downloader supplies an overall timeout; CLI retries need a fresh deadline per attempt.
(url, options) =>
requestWithRetry(fetch, url, { ...options, signal: undefined }, { timeoutMs: 30000 }),
true
)
if (catalog.error) {
Expand Down
56 changes: 56 additions & 0 deletions tests/features.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2845,3 +2845,59 @@ test('worker restart expires unconfirmed authentication without persisting candi
assert.equal(state.profiles[0].password, 'saved')
assert.equal(JSON.stringify(h.stored() || {}).includes('unconfirmed-secret'), false)
})

test('Firefox tab overrides survive background restarts and unchanged synced routing, but not closed tabs or changed rules', async () => {
for (const proxied of [false, true]) {
const shared = {}
const initial = {
session: {},
state: {
profiles: [{ id: 'one', host: 'proxy.example', port: 443 }],
activeId: 'one',
browserRouting: {
enabled: true,
strategy: 'tabs',
sites: proxied ? [] : ['**.example.com']
}
}
}
const first = harness('firefox', shared, undefined, initial)
await first.flush()
assert.equal((await first.send({ command: 'toggleTab' })).currentSite.proxied, proxied)
assert.deepEqual(initial.session.tabOverrides.entries, [[1, proxied]])
initial.session.tabOverrides.entries.push([999, proxied])
const restarted = harness('firefox', shared, undefined, {
state: (await first.send({ command: 'get' })).state,
session: initial.session
})
await restarted.flush()
assert.equal((await restarted.send({ command: 'currentSite' })).currentSite.proxied, proxied)
assert.equal(vm.runInContext('tabOverrides.has(999)', restarted.context), false)

await first.send({ command: 'theme', theme: 'light' })
restarted.events.changed({ megaConfig: {} }, 'sync')
await restarted.flush()
assert.equal((await restarted.send({ command: 'currentSite' })).currentSite.proxied, proxied)

const saved = structuredClone(initial.session)
const changed = harness('firefox', {}, undefined, {
state: {
...initial.state,
browserRouting: { ...initial.state.browserRouting, domains: ['changed.example'] }
},
session: saved
})
await changed.flush()
assert.equal((await changed.send({ command: 'currentSite' })).currentSite.proxied, !proxied)

restarted.events.removed(1)
await restarted.flush()
assert.deepEqual(initial.session.tabOverrides.entries, [])
const before = (await restarted.send({ command: 'currentSite' })).currentSite.proxied
restarted.api.storage.session.set = async () => {
throw new Error('session unavailable')
}
assert.equal((await restarted.send({ command: 'toggleTab' })).ok, false)
assert.equal((await restarted.send({ command: 'currentSite' })).currentSite.proxied, before)
}
})
39 changes: 39 additions & 0 deletions tests/http-errors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,42 @@ test('both stores recover from 429 and expose sanitized 400 diagnostics', async
}
}
})

test('retry timeouts are per attempt while an explicit caller signal still cancels the operation', async () => {
for (const status of [429, 503]) {
let previous
let calls = 0
const result = await requestWithRetry(
async (_url, options) => {
options.signal.throwIfAborted()
if (previous) {
assert.notEqual(options.signal, previous)
assert.equal(previous.aborted, true)
}
previous = options.signal
return new Response('ok', { status: ++calls === 1 ? status : 200 })
},
'https://example.org/',
{},
{ timeoutMs: 5, wait: async () => new Promise(resolve => setTimeout(resolve, 20)) }
)
assert.equal(result.status, 200)
assert.equal(calls, 2)
}
const controller = new AbortController()
const cancelled = new Error('caller cancelled')
let calls = 0
await assert.rejects(
requestWithRetry(
async () => {
calls++
return new Response('', { status: 429 })
},
'https://example.org/',
{ signal: controller.signal },
{ timeoutMs: 1000, wait: async () => controller.abort(cancelled) }
),
error => error === cancelled
)
assert.equal(calls, 1)
})
29 changes: 28 additions & 1 deletion tests/knock-refresh.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ function harness(
opened,
session,
created,
navigations
navigations,
context
}
}

Expand Down Expand Up @@ -619,3 +620,29 @@ for (const target of ['chromium', 'firefox']) {
assert.equal(resumed.created.length, 0)
})
}

test('early Chromium request evidence is bounded, omits private URL data, and stops collecting after worker recreation', async () => {
const session = {}
const h = harness('chromium', session, tabs())
for (let index = 0; index < 1000; index++) {
h.events.onBeforeRequest({
tabId: 1,
type: 'image',
url: `https://host${index}.example/private-path?secret=private-token`
})
}
assert.equal(vm.runInContext('earlyKnockRequests.size', h.context), 256)
const entries = vm.runInContext('JSON.stringify([...earlyKnockRequests.values()])', h.context)
assert.doesNotMatch(entries, /private-path|private-token|secret/)
h.events.removed(1)
assert.equal(vm.runInContext('earlyKnockRequests.size', h.context), 0)
await h.flush()
assert.ok(session.knockRefresh)

const restarted = harness('chromium', session, tabs())
await restarted.flush()
restarted.events.onBeforeRequest({ tabId: 1, type: 'image', url: 'https://example.com/icon' })
await restarted.flush()
assert.equal(vm.runInContext('collectEarlyKnockRequests', restarted.context), false)
assert.equal(vm.runInContext('earlyKnockRequests.size', restarted.context), 0)
})
Loading