STATUS: v1.0.0 was measuring the wrong thing confidently. Two bugs, found by
using it rather than testing it.
UPLOAD, hard failure: crypto.getRandomValues() is spec-capped at 65536 bytes
PER CALL and I asked for 1048576. It threw, and the whole upload leg died with
"can only generate maximum 65536 bytes". Now: fill one 64 KiB block and tile it
into the Blob. Still incompressible, and one call instead of sixteen.
DOWNLOAD, the interesting one: the test finished in half the expected time AND
reported half the expected speed. Both symptoms, one cause. v1.0.0 pulled a
FIXED 50 MiB and averaged over the entire transfer — on a fast link that
completes before TCP is out of slow-start, so the congestion-window ramp is
baked into the average. A short run and a low number are the same bug wearing
two hats.
Fixed by measuring the way the real ones do:
- FIXED DURATION, not fixed size: 1.5s warmup DISCARDED, then a 6s measured
window. Slow-start never enters the average.
- PARALLEL STREAMS (4): a single TCP connection cannot fill a high
bandwidth-delay-product path. One stream measures the connection; several
measure the link. This is why naive single-stream tests read low and
everyone else's numbers look inflated by comparison. They are not.
- The live readout now reports the measured window only, so the number stops
drifting upward as the ramp washes out of a cumulative average.
Brock's verdict on the shape of the thing stands: "perfect for a first version,
just a cut off small utility." The utility was fine. The methodology was not.
The node remains online. The first number it gave you was wrong. Against my
better judgment, it now is not.
205 lines
7.1 KiB
JavaScript
205 lines
7.1 KiB
JavaScript
/**
|
|
* Speedtest — all measurement happens here, in the browser.
|
|
*
|
|
* The server only streams incompressible bytes and sinks an upload; every
|
|
* number on screen is timed client-side. No build step, no framework: this is
|
|
* plain JS talking to three trivial routes.
|
|
*
|
|
* ── Two things learned the hard way, both load-bearing ──────────────────────
|
|
*
|
|
* FIXED DURATION, NOT FIXED SIZE. v1.0.0 pulled a fixed 50 MiB and averaged
|
|
* over the whole transfer. On a fast link that finishes before TCP is out of
|
|
* slow-start, so the ramp is baked into the average: the test ran in half the
|
|
* time and reported half the speed. We now run for a fixed WINDOW, discard a
|
|
* WARMUP period, and only count bytes that arrive after the connection has
|
|
* reached steady state.
|
|
*
|
|
* PARALLEL STREAMS. A single TCP connection cannot fill a high
|
|
* bandwidth-delay-product path — one stream measures the connection, not the
|
|
* link. Several concurrent streams is what every real speedtest does, and it
|
|
* is why they agree with reality and a naive single-stream test does not.
|
|
*/
|
|
(function () {
|
|
'use strict'
|
|
|
|
var base = OC.generateUrl('/apps/speedtest')
|
|
|
|
// tuning
|
|
var STREAMS = 4 // concurrent connections per direction
|
|
var WARMUP_MS = 1500 // discarded: TCP slow-start / congestion-window ramp
|
|
var WINDOW_MS = 6000 // measured window, after warmup
|
|
var UP_CHUNK_MIB = 4 // per-request upload payload
|
|
|
|
var el = {
|
|
go: document.getElementById('st-go'),
|
|
status: document.getElementById('st-status'),
|
|
progress: document.getElementById('st-progress'),
|
|
down: document.querySelector('#st-down [data-v]'),
|
|
up: document.querySelector('#st-up [data-v]'),
|
|
ping: document.querySelector('#st-ping [data-v]'),
|
|
jitter: document.querySelector('#st-jitter [data-v]'),
|
|
}
|
|
var tile = {
|
|
down: document.getElementById('st-down'),
|
|
up: document.getElementById('st-up'),
|
|
ping: document.getElementById('st-ping'),
|
|
jitter: document.getElementById('st-jitter'),
|
|
}
|
|
|
|
function say (t) { el.status.textContent = t }
|
|
function pct (p) { el.progress.style.width = Math.max(0, Math.min(100, p)) + '%' }
|
|
function show (n, v) { n.textContent = v }
|
|
function mbps (bytes, seconds) { return seconds > 0 ? (bytes * 8) / seconds / 1e6 : 0 }
|
|
function fmt (n) { return n >= 100 ? n.toFixed(0) : n.toFixed(1) }
|
|
function focus (name) {
|
|
Object.keys(tile).forEach(function (k) { tile[k].classList.toggle('active', k === name) })
|
|
}
|
|
function clearFocus () {
|
|
Object.keys(tile).forEach(function (k) { tile[k].classList.remove('active') })
|
|
}
|
|
function nocache (u) { return u + (u.indexOf('?') === -1 ? '?' : '&') + 'r=' + Math.random() + Math.random() }
|
|
|
|
/**
|
|
* crypto.getRandomValues() is spec-capped at 65536 bytes PER CALL. Asking
|
|
* for a megabyte throws. Fill one 64 KiB block and tile it — still
|
|
* incompressible, and it costs one syscall instead of sixteen.
|
|
*/
|
|
function randomBlob (mib) {
|
|
var seed = new Uint8Array(65536)
|
|
crypto.getRandomValues(seed)
|
|
var parts = []
|
|
for (var i = 0; i < mib * 16; i++) { parts.push(seed) }
|
|
return new Blob(parts, { type: 'application/octet-stream' })
|
|
}
|
|
|
|
/** Latency + jitter: sequential, so probes never overlap each other. */
|
|
async function measureLatency (samples) {
|
|
var times = []
|
|
for (var i = 0; i < samples; i++) {
|
|
var t0 = performance.now()
|
|
try { await fetch(nocache(base + '/ping'), { cache: 'no-store' }) } catch (e) { continue }
|
|
times.push(performance.now() - t0)
|
|
pct((i + 1) / samples * 12)
|
|
}
|
|
if (!times.length) { return { ping: 0, jitter: 0 } }
|
|
var sorted = times.slice().sort(function (a, b) { return a - b })
|
|
var median = sorted[Math.floor(sorted.length / 2)]
|
|
var diffs = []
|
|
for (var j = 1; j < times.length; j++) { diffs.push(Math.abs(times[j] - times[j - 1])) }
|
|
var jitter = diffs.length ? diffs.reduce(function (a, b) { return a + b }, 0) / diffs.length : 0
|
|
return { ping: median, jitter: jitter }
|
|
}
|
|
|
|
/**
|
|
* Shared clock for a directional test. Bytes are only counted once warmup
|
|
* has elapsed, so slow-start never enters the average.
|
|
*/
|
|
function makeMeter (node, basePct, spanPct) {
|
|
var start = performance.now()
|
|
var counted = 0
|
|
var measuredFrom = null
|
|
return {
|
|
done: function () { return performance.now() - start >= WARMUP_MS + WINDOW_MS },
|
|
add: function (n) {
|
|
var now = performance.now()
|
|
var elapsed = now - start
|
|
if (elapsed < WARMUP_MS) { return }
|
|
if (measuredFrom === null) { measuredFrom = now; return }
|
|
counted += n
|
|
var secs = (now - measuredFrom) / 1000
|
|
if (secs > 0.3) { show(node, fmt(mbps(counted, secs))) }
|
|
pct(basePct + Math.min(1, (elapsed - WARMUP_MS) / WINDOW_MS) * spanPct)
|
|
},
|
|
result: function () {
|
|
if (measuredFrom === null) { return 0 }
|
|
return mbps(counted, (performance.now() - measuredFrom) / 1000)
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Download: STREAMS concurrent readers for a fixed window. */
|
|
async function measureDownload () {
|
|
var meter = makeMeter(el.down, 12, 44)
|
|
var abort = new AbortController()
|
|
|
|
async function stream () {
|
|
while (!meter.done()) {
|
|
var res
|
|
try {
|
|
res = await fetch(nocache(base + '/garbage?mib=100'), { cache: 'no-store', signal: abort.signal })
|
|
} catch (e) { return }
|
|
if (!res.ok || !res.body) { return }
|
|
var reader = res.body.getReader()
|
|
for (;;) {
|
|
var c
|
|
try { c = await reader.read() } catch (e) { return }
|
|
if (c.done) { break }
|
|
meter.add(c.value.length)
|
|
if (meter.done()) { try { await reader.cancel() } catch (e) {} return }
|
|
}
|
|
}
|
|
}
|
|
|
|
var runners = []
|
|
for (var i = 0; i < STREAMS; i++) { runners.push(stream()) }
|
|
await Promise.all(runners)
|
|
abort.abort()
|
|
return meter.result()
|
|
}
|
|
|
|
/** Upload: STREAMS concurrent POSTs for a fixed window. */
|
|
async function measureUpload () {
|
|
var meter = makeMeter(el.up, 56, 44)
|
|
var payload = randomBlob(UP_CHUNK_MIB)
|
|
|
|
async function stream () {
|
|
while (!meter.done()) {
|
|
var t0 = performance.now()
|
|
try {
|
|
var res = await fetch(nocache(base + '/empty'), {
|
|
method: 'POST',
|
|
body: payload,
|
|
cache: 'no-store',
|
|
headers: { requesttoken: OC.requestToken },
|
|
})
|
|
if (!res.ok) { return }
|
|
} catch (e) { return }
|
|
// the whole body is acknowledged when the response lands
|
|
meter.add(payload.size)
|
|
if (performance.now() - t0 < 1) { return } // pathological: bail rather than spin
|
|
}
|
|
}
|
|
|
|
var runners = []
|
|
for (var i = 0; i < STREAMS; i++) { runners.push(stream()) }
|
|
await Promise.all(runners)
|
|
return meter.result()
|
|
}
|
|
|
|
async function run () {
|
|
el.go.disabled = true
|
|
;[el.down, el.up, el.ping, el.jitter].forEach(function (n) { show(n, '—') })
|
|
pct(0)
|
|
try {
|
|
focus('ping'); say('Measuring latency…')
|
|
var lat = await measureLatency(10)
|
|
show(el.ping, fmt(lat.ping)); show(el.jitter, fmt(lat.jitter))
|
|
|
|
focus('down'); say('Measuring download… (' + STREAMS + ' streams)')
|
|
show(el.down, fmt(await measureDownload()))
|
|
|
|
focus('up'); say('Measuring upload… (' + STREAMS + ' streams)')
|
|
show(el.up, fmt(await measureUpload()))
|
|
|
|
pct(100); clearFocus(); say('Done.')
|
|
} catch (e) {
|
|
clearFocus(); say('Failed: ' + e.message)
|
|
console.error('[speedtest]', e)
|
|
} finally {
|
|
el.go.disabled = false
|
|
}
|
|
}
|
|
|
|
el.go.addEventListener('click', run)
|
|
})()
|