From 4316905197441152256279fa081ddee2e09ec07c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 22 Oct 2026 22:16:42 +0300 Subject: [PATCH] Preserve tab routing overrides and fix retry deadlines and startup cache --- extension/background.js | 60 +++++++++++++++++++++++++++++---- scripts/http-request.mjs | 14 ++++++-- scripts/prepare-gost-e2e.mjs | 3 +- scripts/renew-config-schema.mjs | 12 ++++--- scripts/renew-identity.mjs | 2 +- scripts/renew-list-catalog.mjs | 4 ++- tests/features.test.mjs | 56 ++++++++++++++++++++++++++++++ tests/http-errors.test.mjs | 39 +++++++++++++++++++++ tests/knock-refresh.test.mjs | 29 +++++++++++++++- 9 files changed, 202 insertions(+), 17 deletions(-) diff --git a/extension/background.js b/extension/background.js index 8132588..f868b1c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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([ @@ -63,7 +64,8 @@ const ready = Promise.all([ 'knockRefresh', 'knockTabs', 'assignedKnockLease', - 'transientProxyLease' + 'transientProxyLease', + 'tabOverrides' ]) .then(data => { knockTabs = data.knockTabs || [] @@ -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 @@ -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 }) @@ -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() } @@ -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(() => {}) } @@ -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) @@ -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) => { @@ -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 () => { @@ -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 ( @@ -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 }) diff --git a/scripts/http-request.mjs b/scripts/http-request.mjs index 93e159f..3d29b88 100644 --- a/scripts/http-request.mjs +++ b/scripts/http-request.mjs @@ -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 ( diff --git a/scripts/prepare-gost-e2e.mjs b/scripts/prepare-gost-e2e.mjs index 0e88473..e43ec42 100644 --- a/scripts/prepare-gost-e2e.mjs +++ b/scripts/prepare-gost-e2e.mjs @@ -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( diff --git a/scripts/renew-config-schema.mjs b/scripts/renew-config-schema.mjs index 3a0c106..55dee8d 100644 --- a/scripts/renew-config-schema.mjs +++ b/scripts/renew-config-schema.mjs @@ -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, diff --git a/scripts/renew-identity.mjs b/scripts/renew-identity.mjs index 2178dc8..82b6c78 100644 --- a/scripts/renew-identity.mjs +++ b/scripts/renew-identity.mjs @@ -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=]') } 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}`) } diff --git a/scripts/renew-list-catalog.mjs b/scripts/renew-list-catalog.mjs index f53947a..d874518 100644 --- a/scripts/renew-list-catalog.mjs +++ b/scripts/renew-list-catalog.mjs @@ -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) { diff --git a/tests/features.test.mjs b/tests/features.test.mjs index e0a168d..a80c9d3 100644 --- a/tests/features.test.mjs +++ b/tests/features.test.mjs @@ -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) + } +}) diff --git a/tests/http-errors.test.mjs b/tests/http-errors.test.mjs index a774a77..10ca15f 100644 --- a/tests/http-errors.test.mjs +++ b/tests/http-errors.test.mjs @@ -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) +}) diff --git a/tests/knock-refresh.test.mjs b/tests/knock-refresh.test.mjs index 2f2afd4..93e53b5 100644 --- a/tests/knock-refresh.test.mjs +++ b/tests/knock-refresh.test.mjs @@ -136,7 +136,8 @@ function harness( opened, session, created, - navigations + navigations, + context } } @@ -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) +})