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 <noreply@openai.com>
This commit is contained in:
jason5ng32andGPT-6 committed 2026-09-17 10:23:24 +08:00
1 parent 7c146976ef
commit b2b98b519c
7 files changed
+216 -16

No files matched your search

+15 -8
View File
@@ -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;
@@ -10,7 +10,7 @@
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="provider in section.providers" :key="provider.id" class="hover:bg-muted/50 transition-colors">
<tr v-for="(provider, index) in section.providers" :key="`${provider.id}:${index}`" class="hover:bg-muted/50 transition-colors">
<td class="px-3 py-2 whitespace-nowrap">{{ provider.name }}</td>
<td class="px-3 py-2 font-mono whitespace-nowrap">{{ provider.ip }}</td>
<td class="px-3 py-2"><GeoCell :code="provider.countryCode" /></td>
+15 -4
View File
@@ -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);
};
+10 -2
View File
@@ -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;
};
+110
View File
@@ -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);
});
+48 -1
View File
@@ -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');
+17
View File
@@ -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']);