diff --git a/apps/field/src/composables/useSpeedTest.js b/apps/field/src/composables/useSpeedTest.js index 16ca283..2269485 100644 --- a/apps/field/src/composables/useSpeedTest.js +++ b/apps/field/src/composables/useSpeedTest.js @@ -55,11 +55,21 @@ export function useSpeedTest () { } // HTTP resolve — check if a host is reachable via HTTP + // + // Why two fetches: browser fetch() includes DNS + TCP + TLS + HTTP on top + // of real RTT. First call pays the cold-connection tax (easily 200–400ms + // on mobile LTE when the radio is idle); second call on the now-warm + // connection reports something close to actual RTT. Before this change + // techs saw "cloudflare.com a répondu en 350ms" on what was actually a + // 5ms link and opened false-positive tickets. async function resolveHost (host) { resolveResult.value = null const url = host.startsWith('http') ? host : 'https://' + host try { + // Warm-up — result discarded. Pays DNS + TCP + TLS + LTE wake-up. + await fetch(url, { mode: 'no-cors', cache: 'no-store' }) + // Steady-state measurement on the warm connection. const t0 = performance.now() const res = await fetch(url, { mode: 'no-cors', cache: 'no-store' }) const t1 = performance.now() diff --git a/apps/field/src/pages/DiagnosticPage.vue b/apps/field/src/pages/DiagnosticPage.vue index e78ef3d..b2a00c5 100644 --- a/apps/field/src/pages/DiagnosticPage.vue +++ b/apps/field/src/pages/DiagnosticPage.vue @@ -91,7 +91,10 @@ const resolveResults = ref([]) const batchRunning = ref(false) const batchResults = ref([]) -const quickHosts = ['google.ca', 'erp.gigafibre.ca', 'cloudflare.com', '8.8.8.8'] +// 1.1.1.1/cdn-cgi/trace instead of bare cloudflare.com — the bare host +// redirects 301 → www.cloudflare.com, doubling the handshake cost. The +// trace endpoint is 88 bytes, no redirect, canonical "internet is up" probe. +const quickHosts = ['google.ca', 'erp.gigafibre.ca', '1.1.1.1/cdn-cgi/trace', '8.8.8.8'] const batchHosts = [ 'erp.gigafibre.ca', 'dispatch.gigafibre.ca', diff --git a/apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue b/apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue index 08a8748..82172ca 100644 --- a/apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue +++ b/apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue @@ -40,32 +40,60 @@ const speed = ref(null) const latency = ref(null) const checking = ref(false) +// ─── Hosts to check ───────────────────────────────────────────────────────── +// We deliberately use 1.1.1.1 (not cloudflare.com) because the bare +// cloudflare.com redirects 301 → www.cloudflare.com, forcing a second DNS + +// TCP + TLS handshake on every "ping" and making the browser report +// 300–500ms on a 5ms link. 1.1.1.1/cdn-cgi/trace is a single no-redirect +// 88-byte response — the canonical "is the internet up" endpoint. const hosts = ref([ { host: 'google.ca', status: null, ms: null }, { host: 'erp.gigafibre.ca', status: null, ms: null }, - { host: 'cloudflare.com', status: null, ms: null }, + { host: '1.1.1.1/cdn-cgi/trace', status: null, ms: null }, ]) +// ─── Speed test ───────────────────────────────────────────────────────────── +// Browser fetch() ≠ ICMP ping. A cold HTTPS request pays DNS + TCP + TLS + +// HTTP on top of the real RTT, which on mobile LTE is ~150–300ms before +// the first byte moves. We split this into two separate measurements: +// 1. Ping: warm-up + measured call against /cdn-cgi/trace (88 bytes), +// so the second call runs on a warm connection and reports close to +// real RTT. +// 2. Throughput: 10MB download on the SAME warm connection (same origin +// → connection reuse, no second handshake tax). async function runSpeed () { testing.value = true; speed.value = null; latency.value = null try { - const url = 'https://speed.cloudflare.com/__down?bytes=10000000' + const pingUrl = 'https://speed.cloudflare.com/cdn-cgi/trace' + // Warm-up — pays DNS + TCP + TLS so the next call is steady-state. + await fetch(pingUrl, { mode: 'no-cors', cache: 'no-store' }) + // Measured ping on a warm connection. + const p0 = performance.now() + await fetch(pingUrl, { mode: 'no-cors', cache: 'no-store' }) + latency.value = Math.round(performance.now() - p0) + + // Throughput — 10MB download, same origin so TLS session is reused. + const dlUrl = 'https://speed.cloudflare.com/__down?bytes=10000000' const t0 = performance.now() - const res = await fetch(url, { mode: 'no-cors' }) - const t1 = performance.now() - const ms = t1 - t0 - latency.value = Math.round(ms) - // Approximate: 10MB / time = speed - speed.value = Math.round((10 * 8) / (ms / 1000)) + await fetch(dlUrl, { mode: 'no-cors' }) + const dlMs = performance.now() - t0 + speed.value = Math.round((10 * 8) / (dlMs / 1000)) } catch { speed.value = 0 } finally { testing.value = false } } +// Warm-up + measure pattern so "cloudflare.com a répondu en 300ms" stops +// showing up for what is actually a 5ms link. First call absorbs the +// cold-connection penalty (DNS + TCP + TLS + LTE RRC wake); second call +// reports real RTT + HTTP round trip. async function checkHosts () { checking.value = true for (const h of hosts.value) { try { + // Warm-up — result discarded. + await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) }) + // Measurement. const t0 = performance.now() await fetch('https://' + h.host, { mode: 'no-cors', signal: AbortSignal.timeout(5000) }) h.ms = Math.round(performance.now() - t0)