From b2b98b519cfbcd0d0c1df4df74023a4cd5354f23 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 17 Sep 2026 10:23:24 +0800 Subject: [PATCH] Fix(dns): share fallback probes within each test run Reuse pending, successful, and exhausted provider probes across cards. Keep refreshes isolated and collapse repeated observations in reports. Cover shared retry budgets, refresh races, and report deduplication. Co-Authored-By: GPT-6 --- frontend/components/DnsLeaksTest.vue | 23 ++-- .../report/sections/ReportDnsleak.vue | 2 +- frontend/utils/dnsleaks/index.js | 19 ++- frontend/utils/report-builders.js | 12 +- tests/dnsleaks-run.test.js | 110 ++++++++++++++++++ tests/dnsleaks.test.js | 49 +++++++- tests/report-builders.test.js | 17 +++ 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 tests/dnsleaks-run.test.js diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index eb6098ab..58c9aedb 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -108,7 +108,7 @@ import InfoBanner from '@/components/widgets/InfoBanner.vue'; import { INLINE_TIERS } from '@/composables/use-fit-text.js'; import { ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak, - buildFallbackChain, runWithFallback, + createDnsLeakRunner, } from '@/utils/dnsleaks'; // All providers in priority order: the first SLOT_COUNT hold a card each, @@ -128,6 +128,7 @@ const userPreferences = computed(() => store.userPreferences); const isSimpleMode = computed(() => userPreferences.value.simpleMode); // Sticky settled flag for the section's banner slot. const hasEverSettled = ref(false); +let runId = 0; const { dotClass, textClass } = useStatusTone(); @@ -162,8 +163,9 @@ const leakTest = reactive(ACTIVE.map((p) => ({ }))); // Apply MaxMind lookup to a card that already has a resolved leak IP. -const applyMaxMindGeo = async (index, ip) => { +const applyMaxMindGeo = async (index, ip, currentRun) => { const geo = await lookupMaxmind(ip); + if (currentRun !== runId) return; if (geo) { leakTest[index].country_code = geo.country_code; leakTest[index].country = geo.country; @@ -184,23 +186,27 @@ const markLeakCardError = (index) => { // Run one slot through its fallback chain (see utils/dnsleaks); the card // flips to error only when the whole chain fails. -const runProvider = async (index) => { - const chain = buildFallbackChain(index, PROVIDERS, SLOT_COUNT); +const runProvider = async (index, run, currentRun) => { + if (currentRun !== runId) return; try { - const { ip, provider } = await runWithFallback(chain); + const { ip, provider } = await run(index); + if (currentRun !== runId) return; leakTest[index].id = provider.id; leakTest[index].providerName = provider.name; leakTest[index].ip = ip; - await applyMaxMindGeo(index, ip); + await applyMaxMindGeo(index, ip, currentRun); } catch (error) { + if (currentRun !== runId) return; console.error('Error fetching leak test data:', error); markLeakCardError(index); } }; -// Check all. Staggers startup by 300ms per provider to avoid a thundering- +// Check all. Staggers startup by 200ms per provider to avoid a thundering- // herd on first paint. const checkAllDNSLeakTest = async (isRefresh) => { + const currentRun = ++runId; + const run = createDnsLeakRunner(PROVIDERS, SLOT_COUNT); isStarted.value = true; if (isRefresh) trackEvent('Section', 'RefreshClick', 'DNSLeakTest'); // Every run starts from the primary provider and "Testing…" — on boot @@ -216,7 +222,7 @@ const checkAllDNSLeakTest = async (isRefresh) => { const delayedRun = (index, delay) => new Promise((resolve) => { setTimeout(() => { - runProvider(index).then(resolve, resolve); + runProvider(index, run, currentRun).then(resolve, resolve); }, delay); }); @@ -226,6 +232,7 @@ const checkAllDNSLeakTest = async (isRefresh) => { const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000)); return Promise.race([allSettledPromise, timeoutPromise]).then(() => { + if (currentRun !== runId) return; store.setLoadingStatus('DNSLeakTest', true); // Local sticky flag for the Enhanced DNS Leak Test banner hasEverSettled.value = true; diff --git a/frontend/components/report/sections/ReportDnsleak.vue b/frontend/components/report/sections/ReportDnsleak.vue index bafd02b1..37b95363 100644 --- a/frontend/components/report/sections/ReportDnsleak.vue +++ b/frontend/components/report/sections/ReportDnsleak.vue @@ -10,7 +10,7 @@ - + {{ provider.name }} {{ provider.ip }} diff --git a/frontend/utils/dnsleaks/index.js b/frontend/utils/dnsleaks/index.js index b33fab84..7dd6fbdb 100644 --- a/frontend/utils/dnsleaks/index.js +++ b/frontend/utils/dnsleaks/index.js @@ -40,8 +40,8 @@ export async function runWithRetry(provider, attempts = 2) { } // Try order for card slot `index`: own provider → standbys (index ≥ -// slotCount) → the other slots' providers from 0. Each call derives a fresh -// hostname, so a neighbour's provider still yields an independent lookup. +// slotCount) → the other slots' providers from 0. A run shares each provider's +// probe across slots, including when it is reached through a neighbour. export const buildFallbackChain = (index, providers, slotCount) => { const primary = providers[index]; if (!primary) return []; @@ -53,11 +53,11 @@ export const buildFallbackChain = (index, providers, slotCount) => { // Walk the chain once, each provider through `runWithRetry`; resolves // `{ ip, provider }` so the caller can name the upstream that answered. // Throws the last error. -export const runWithFallback = async (chain) => { +export const runWithFallback = async (chain, runProvider = runWithRetry) => { let lastError = new Error('dnsleak: empty provider chain'); for (const provider of chain) { try { - const { ip } = await runWithRetry(provider); + const { ip } = await runProvider(provider); return { ip, provider }; } catch (err) { lastError = err; @@ -65,3 +65,14 @@ export const runWithFallback = async (chain) => { } throw lastError; }; + +// One cache per test run: pending, successful and exhausted probes all share +// the same retry budget. A fresh runner makes refresh probe every provider anew. +export const createDnsLeakRunner = (providers, slotCount) => { + const probes = new Map(); + const probe = (provider) => { + if (!probes.has(provider.id)) probes.set(provider.id, runWithRetry(provider)); + return probes.get(provider.id); + }; + return (index) => runWithFallback(buildFallbackChain(index, providers, slotCount), probe); +}; diff --git a/frontend/utils/report-builders.js b/frontend/utils/report-builders.js index 999f1fb2..0bd80b75 100644 --- a/frontend/utils/report-builders.js +++ b/frontend/utils/report-builders.js @@ -119,9 +119,9 @@ const buildWebrtc = (payload) => { // dnsleak:finished — { providers: [{ id, name, ip, country_code, org }] } const buildDnsleak = (payload) => { + const seen = new Set(); const providers = (payload?.providers ?? []) .filter((provider) => isValidIP(provider?.ip)) - .slice(0, 8) .map((provider) => { const cc = countryCode(provider.country_code); return compact({ @@ -131,7 +131,15 @@ const buildDnsleak = (payload) => { countryCode: cc, org: cc ? clip(provider.org, 128) : undefined, }); - }); + }) + // Several homepage cards can share one fallback probe's result. + .filter((provider) => { + const key = `${provider.id}:${provider.ip}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 8); return providers.length ? { providers } : null; }; diff --git a/tests/dnsleaks-run.test.js b/tests/dnsleaks-run.test.js new file mode 100644 index 00000000..0d0747cd --- /dev/null +++ b/tests/dnsleaks-run.test.js @@ -0,0 +1,110 @@ +// Exercise DNS card refreshes and shared fallbacks without DOM or network access. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { computed, reactive, ref } from 'vue'; +import { parse } from 'vue/compiler-sfc'; +import { createDnsLeakRunner } from '../frontend/utils/dnsleaks/index.js'; + +const source = readFileSync(new URL('../frontend/components/DnsLeaksTest.vue', import.meta.url), 'utf8'); +const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, ''); +const deferred = () => { + let resolve; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +}; +const flush = () => new Promise(resolve => setImmediate(resolve)); + +const fixture = (lookupMaxmind = async () => null) => { + const events = []; + const timers = []; + const providers = Object.fromEntries(['ipApi', 'surfshark', 'myipstack', 'fastly', 'bashws', 'browserleaks', 'ipleak'] + .map(id => [id, { id, name: id, run: async () => ({ ip: '192.0.2.53' }) }])); + const bindings = { + computed, reactive, ref, createDnsLeakRunner, ...providers, + useMainStore: () => ({ userPreferences: {}, setLoadingStatus: () => {} }), + useI18n: () => ({ t: key => key }), + useMaxmind: () => ({ lookupMaxmind }), + createMaskGate: () => () => {}, + useStatusTone: () => ({}), + useAppCommand: () => {}, + onMounted: () => {}, + trackEvent: () => {}, + emitAppEvent: (name, payload) => events.push({ name, payload }), + setTimeout: (callback, delay) => timers.push({ callback, delay, fired: false }), + }; + const setup = new Function(...Object.keys(bindings), `${script}\nreturn { checkAllDNSLeakTest, leakTest };`); + const component = setup(...Object.values(bindings)); + const startSlots = () => { + for (const timer of timers.filter(timer => !timer.fired && timer.delay < 6000)) { + timer.fired = true; + timer.callback(); + } + }; + return { ...component, providers, events, startSlots }; +}; + +test('cards share one standby request per run and refresh obtains a fresh result', async () => { + const run = fixture(); + const calls = {}; + for (const [id, provider] of Object.entries(run.providers)) { + calls[id] = 0; + provider.run = async () => { + calls[id]++; + if (id !== 'bashws') throw new Error('unavailable'); + return { ip: `192.0.2.${calls[id]}` }; + }; + } + for (const attempt of [1, 2]) { + const finished = run.checkAllDNSLeakTest(attempt > 1); + run.startSlots(); + await finished; + assert(run.leakTest.every(card => card.id === 'bashws' && card.ip === `192.0.2.${attempt}`)); + assert.equal(calls.bashws, attempt); + assert.equal(calls.ipApi, attempt * 2); + assert.equal(run.events.length, attempt); + assert.equal(calls.browserleaks, 0); + assert.equal(calls.ipleak, 0); + } +}); + +test('a superseded probe cannot overwrite refreshed cards or emit a stale report', async () => { + const old = deferred(); + const run = fixture(); + for (const provider of Object.values(run.providers)) { + let calls = 0; + provider.run = () => ++calls === 1 ? old.promise : Promise.resolve({ ip: '192.0.2.2' }); + } + const first = run.checkAllDNSLeakTest(false); + run.startSlots(); + const second = run.checkAllDNSLeakTest(true); + run.startSlots(); + await second; + old.resolve({ ip: '192.0.2.1' }); + await first; + assert(run.leakTest.every(card => card.ip === '192.0.2.2')); + assert.equal(run.events.length, 1); + assert(run.events[0].payload.providers.every(provider => provider.ip === '192.0.2.2')); +}); + +test('a delayed geo result cannot overwrite the new run', async () => { + const oldGeo = deferred(); + const run = fixture(ip => ip === '192.0.2.1' + ? oldGeo.promise + : Promise.resolve({ country_code: 'US', country: 'New country', org: 'New org' })); + for (const provider of Object.values(run.providers)) { + let calls = 0; + provider.run = async () => ({ ip: `192.0.2.${++calls}` }); + } + const first = run.checkAllDNSLeakTest(false); + run.startSlots(); + await flush(); + assert(run.leakTest.every(card => card.ip === '192.0.2.1')); + const second = run.checkAllDNSLeakTest(true); + run.startSlots(); + await second; + oldGeo.resolve({ country_code: 'GB', country: 'Old country', org: 'Old org' }); + await first; + assert(run.leakTest.every(card => card.ip === '192.0.2.2' && card.org === 'New org')); + assert.equal(run.events.length, 1); +}); diff --git a/tests/dnsleaks.test.js b/tests/dnsleaks.test.js index 4f63e305..5470dd85 100644 --- a/tests/dnsleaks.test.js +++ b/tests/dnsleaks.test.js @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { - runWithRetry, buildFallbackChain, runWithFallback, + runWithRetry, buildFallbackChain, runWithFallback, createDnsLeakRunner, } from '../frontend/utils/dnsleaks/index.js'; const providerReturning = (results) => { @@ -153,6 +153,53 @@ describe('runWithFallback', () => { }); }); +describe('per-run shared probes', () => { + it('shares an in-flight standby across failed slots and reuses the settled result', async () => { + let resolve; + let calls = 0; + const pending = new Promise(yes => { resolve = yes; }); + const spare = { id: 'spare', run: () => { calls++; return pending; } }; + const dead = [provider('a', [new Error('down')]), provider('b', [new Error('down')])]; + const run = createDnsLeakRunner([...dead, spare], 2); + const results = [run(0), run(1)]; + await new Promise(yes => setImmediate(yes)); + assert.equal(calls, 1, 'concurrent slots share the pending request'); + resolve({ ip: '192.0.2.53' }); + for (const result of await Promise.all(results)) { + assert.equal(result.provider, spare); + assert.equal(result.ip, '192.0.2.53'); + } + await run(0); + assert.equal(calls, 1, 'the completed result is cached for the run'); + assert.deepEqual(dead.map(p => p.calls()), [2, 2]); + }); + + it('shares exhausted failures and retries them only in a fresh run', async () => { + const registry = ['a', 'b', 'spare'].map(id => provider(id, [new Error(`${id} down`)])); + const first = createDnsLeakRunner(registry, 2); + const results = await Promise.allSettled([first(0), first(1)]); + assert(results.every(result => result.status === 'rejected')); + assert.deepEqual(registry.map(p => p.calls()), [2, 2, 2]); + await assert.rejects(first(0)); + assert.deepEqual(registry.map(p => p.calls()), [2, 2, 2]); + await assert.rejects(createDnsLeakRunner(registry, 2)(0)); + assert.deepEqual(registry.map(p => p.calls()), [4, 4, 4]); + }); + + it('shares a neighbour primary and refreshes successful results in a new run', async () => { + const dead = provider('dead', [new Error('down')]); + const live = provider('live', [{ ip: '192.0.2.1' }, { ip: '192.0.2.2' }]); + const spare = provider('spare', [new Error('down')]); + const registry = [dead, live, spare]; + const first = createDnsLeakRunner(registry, 2); + const results = await Promise.all([first(0), first(1)]); + assert(results.every(result => result.provider === live && result.ip === '192.0.2.1')); + assert.equal(live.calls(), 1); + assert.equal((await createDnsLeakRunner(registry, 2)(1)).ip, '192.0.2.2'); + assert.equal(live.calls(), 2); + }); +}); + describe('provider response parsers', () => { it('bash.ws: picks the first entry tagged as a resolver', async () => { const { pickDnsIp } = await import('../frontend/utils/dnsleaks/bashws.js'); diff --git a/tests/report-builders.test.js b/tests/report-builders.test.js index 849215c8..56c5005a 100644 --- a/tests/report-builders.test.js +++ b/tests/report-builders.test.js @@ -238,6 +238,23 @@ describe('cleaning rules', () => { assert.equal('org' in errored, false); // no geo lookup → org distrusted }); + it('dnsleak collapses reused probes without dropping distinct providers or resolver IPs', () => { + const { build } = REPORT_EVENT_BUILDERS['dnsleak:finished']; + const shared = { id: 'spare', name: 'Standby', ip: '192.0.2.53', country_code: 'US', org: 'Example' }; + const section = build({ providers: [ + ...Array(9).fill(shared), + { ...shared, ip: '192.0.2.54' }, + { ...shared, id: 'other' }, + { ...shared, ip: 'Testing…' }, + ] }); + assert.deepEqual(section.providers.map(({ id, ip }) => ({ id, ip })), [ + { id: 'spare', ip: '192.0.2.53' }, + { id: 'spare', ip: '192.0.2.54' }, + { id: 'other', ip: '192.0.2.53' }, + ]); + assert.equal(validateReport(wrap('dnsleak', section)).ok, true); + }); + it('speedtest turns "-" placeholders into absent keys', () => { const { build } = REPORT_EVENT_BUILDERS['speedtest:finished']; const section = build(PAYLOADS['speedtest:finished']);