Files
hamlab/internal/network/speedtest.go
T
Mitchell-JohnsonandClaude Opus 5 48d3887514 A second download source, for when the first one refuses
Cloudflare rate-limits by volume, and the block it handed out while this was
being built lasted over twenty minutes — too fragile to be the only thing behind
a button. So the download phase tries its sources in order: the anycast endpoint
first, then a plain 100 MB file on Linode's Seattle mirror, which has no opinion
about how much has been pulled today.

Only a refusal moves to the next one; anything else would fail the same way
twice. Every result names the source it used, and the page says "from
linode-seattle" rather than "via SEA" when it was the fallback — that mirror is
in one fixed place, so somewhere else it reads low, and a figure that quietly
came from a different continent would be a lie.

Upload stays on Cloudflare, which has never been the one to refuse.

Also: the too-short-window guard was refusing to divide 512 MiB by ten
milliseconds on a link fast enough to hit the runaway budget before the first
tick. It now guards against dividing by zero and nothing more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:15:33 -07:00

688 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package network
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
// close to every one of these machines.
//
// The method, since a number without one means little — it is stated in the UI too:
//
// - Latency is the round trip of a zero-byte response on a connection that is already open,
// less the time the far end says it spent on the request (its Server-Timing header). That
// subtraction is the difference between measuring the line and measuring Cloudflare's
// thinking time: a zero-byte response is almost entirely the latter — tens of
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
// several probes; jitter is the average change from one to the next.
// - Download and upload each run several streams at once for a fixed few seconds. One
// stream measures one TCP connection's congestion window as much as the line, which is
// why every speed test ever made uses more than one.
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
// number nobody's connection ever actually did.
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
// with the meter.
//
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
// cooldown between tests and why a phase that gets refused says so instead of reporting the
// decayed figure it would otherwise compute.
//
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
// congestion window, which measures the window, not the line.
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
// can point them at a local server and shrink the windows; nothing else rebinds them.
// Source is somewhere to pull bytes from. Cloudflare's endpoint is the first choice because
// it is anycast — near wherever Ham Lab is running — and because the same host answers for
// upload and for the trace. The fallback exists because that endpoint rate-limits by volume
// and will refuse a connection that has been tested hard: a plain 100 MB file on a public
// mirror has no such opinion. It is in Seattle, so on a machine far from there it will read
// low, which is exactly why every result names the source it used.
type Source struct {
Name string
// URL takes the number of bytes wanted, where the source can be asked; a fixed file
// ignores it and is fetched again each time round.
URL func(bytes int) string
}
var downSources = []Source{
{"cloudflare", func(b int) string { return fmt.Sprintf(cfDownURL, b) }},
{"linode-seattle", func(int) string { return "https://speedtest.seattle.linode.com/100MB-seattle.bin" }},
}
var (
cfDownURL = "https://speed.cloudflare.com/__down?bytes=%d"
upURL = "https://speed.cloudflare.com/__up"
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
// sees, the edge that answered and the country. A few hundred bytes.
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
warmUp = time.Second
downFor = 4 * time.Second
upFor = 4 * time.Second
// measureBytes ends a phase early once this much has moved inside the window. A fast line
// says what it is in well under the time allowed, and stopping there is what keeps a test
// down to a couple of hundred megabytes — which is the difference between the far end
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
// by downFor/upFor instead, which is the honest way round.
measureBytes = int64(64) << 20
// minWindow is how long the window must last whatever the byte cap says. Without it a very
// fast line fills the cap in a few hundredths of a second and the phase would be over
// before there was anything to divide by.
minWindow = time.Second
)
const (
latencyProbes = 8
downStreams = 4
upStreams = 3
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
upChunk = 25 << 20 // and per upload request
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
phaseBudget = int64(512) << 20
httpTimeout = 90 * time.Second
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
// volume, and two back-to-back tests are the surest way to be refused by it.
Cooldown = 20 * time.Second
// Method is what the UI tells the reader about the numbers it is showing.
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
)
// Result is one finished speed test.
type Result struct {
At time.Time `json:"at"`
DownMbps float64 `json:"down_mbps"`
UpMbps float64 `json:"up_mbps"`
LatencyMs float64 `json:"latency_ms"`
JitterMs float64 `json:"jitter_ms"`
DownBytes int64 `json:"down_bytes"`
UpBytes int64 `json:"up_bytes"`
TookSec float64 `json:"took_sec"`
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
// address as that edge saw it.
Colo string `json:"colo,omitempty"`
IP string `json:"ip,omitempty"`
// Source is where the download came from, which matters because the fallback is not in the
// same place as the anycast one. Empty means the usual.
Source string `json:"source,omitempty"`
// Note is set when the figures are real but something went wrong along the way — a stream
// that was refused part-way, most often. The number is what was actually seen; the note is
// why it may be low.
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
}
// Run is a test in progress, or the last one to have finished.
type Run struct {
Started time.Time `json:"started"`
Running bool `json:"running"`
// Phase is where it has got to: latency, download, upload, done.
Phase string `json:"phase"`
// Done is how far through the whole test, 0–1, so one bar can show all of it.
Done float64 `json:"done"`
// Mbps is the live figure for the phase under way — what the needle shows.
Mbps float64 `json:"mbps"`
Bytes int64 `json:"bytes"`
Result *Result `json:"result,omitempty"`
}
// Tester runs one speed test at a time and remembers what the last ones said.
type Tester struct {
mu sync.RWMutex
run Run
hist []Result
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
// test that was refused still used the line, and hammering retries at an endpoint that has
// just rate-limited us is the one thing guaranteed not to help.
lastTry time.Time
cancel context.CancelFunc
onDone func(Result)
maxHist int
}
func NewTester(onDone func(Result)) *Tester {
return &Tester{onDone: onDone, maxHist: 50}
}
// Load takes the results kept from before a restart, oldest first.
func (t *Tester) Load(rs []Result) {
t.mu.Lock()
defer t.mu.Unlock()
t.hist = append(t.hist[:0], rs...)
if n := len(t.hist); n > 0 {
last := t.hist[n-1]
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
t.lastTry = last.At
}
}
func (t *Tester) State() Run {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run
}
func (t *Tester) History() []Result {
t.mu.RLock()
defer t.mu.RUnlock()
return append([]Result{}, t.hist...)
}
func (t *Tester) Running() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run.Running
}
// Cancel stops a test in progress. Reports whether there was one.
func (t *Tester) Cancel() bool {
t.mu.Lock()
c := t.cancel
t.mu.Unlock()
if c == nil {
return false
}
c()
return true
}
var ErrBusy = errors.New("a speed test is already running")
// Start begins a test in the background and returns its opening state.
func (t *Tester) Start() (Run, error) {
t.mu.Lock()
if t.run.Running {
r := t.run
t.mu.Unlock()
return r, ErrBusy
}
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
r := t.run
t.mu.Unlock()
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.cancel = cancel
t.lastTry = time.Now()
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
r := t.run
t.mu.Unlock()
go t.measure(ctx, cancel)
return r, nil
}
func (t *Tester) set(fn func(*Run)) {
t.mu.Lock()
fn(&t.run)
t.mu.Unlock()
}
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
started := time.Now()
res := Result{At: started}
tr := newTransport(downStreams)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: httpTimeout}
// latency, and the meta the same responses carry
lat, meta, err := t.latency(ctx, client)
if err == nil {
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
res.Colo, res.IP = meta.Colo, meta.IP
}
var note string
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
// Each source in turn: a refusal from the first is exactly what the second is for, and
// anything else (no route, no DNS) would fail the same way twice, so it stops there.
for i, src := range downSources {
res.Source = src.Name
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6, src)
if err == nil || !refused(err) || i == len(downSources)-1 {
break
}
log.Printf("speed test: %s refused the download, trying %s", src.Name, downSources[i+1].Name)
}
res.Note = join(res.Note, note)
}
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0, Source{Name: "cloudflare"})
res.Note = join(res.Note, note)
}
res.TookSec = round(time.Since(started).Seconds(), 1)
if err != nil {
if ctx.Err() != nil {
res.Error = "cancelled"
} else {
res.Error = err.Error()
}
}
t.mu.Lock()
t.cancel = nil
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
t.hist = append(t.hist, res)
if len(t.hist) > t.maxHist {
t.hist = t.hist[len(t.hist)-t.maxHist:]
}
}
done, out := t.onDone, res
t.mu.Unlock()
if done != nil {
done(out)
}
}
// meta is what the edge says about the connection. The address comes from a header of the
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
// response carries ("a41f6720f966a36c-SEA").
type meta struct{ IP, Colo string }
func readMeta(h http.Header) meta {
m := meta{IP: h.Get("cf-meta-ip")}
if ray := h.Get("cf-ray"); ray != "" {
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
m.Colo = ray[i+1:]
}
}
return m
}
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
// description of the connection, not time spent.
func serverMs(h http.Header) float64 {
var total float64
for _, v := range h.Values("Server-Timing") {
for _, part := range strings.Split(v, ",") {
_, after, ok := strings.Cut(part, "dur=")
if !ok {
continue
}
end := 0
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
end++
}
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
total += f
}
}
}
return total
}
// latency times several zero-byte responses on an already-open connection.
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
var m meta
var out []float64
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(cfDownURL, 0), nil)
if err != nil {
return nil, m, err
}
resp, err := client.Do(req)
if err != nil {
return nil, m, err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
}
if i == 0 {
m = readMeta(resp.Header)
continue
}
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
if rtt < 0.01 { // the far end claimed more time than the whole request took
rtt = 0.01
}
out = append(out, rtt)
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
}
if len(out) == 0 {
return nil, m, errors.New("no reply from the speed endpoint")
}
return out, m, nil
}
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
// window after the ramp. from and to are where this phase sits in the whole test's progress.
//
// The window ends when the traffic does, not when the clock runs out: if the far end starts
// refusing part-way, the figure is what was actually moved while it was moving, and the note
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
// number a third of the truth.
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64, src Source) (float64, int64, string, error) {
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
// this phase's, which *this function* cancels to shut the streams down — so the two are
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
// every failure would be reported as "context canceled" whatever really happened.
ctx, stop := context.WithCancel(parent)
defer stop()
var moved atomic.Int64
var firstErr error
var errOnce sync.Once
var alive atomic.Int32
alive.Store(int32(streams))
dead := make(chan struct{})
var deadOnce sync.Once
for i := 0; i < streams; i++ {
go func() {
defer func() {
if alive.Add(-1) == 0 {
deadOnce.Do(func() { close(dead) })
}
}()
tries := 0
for ctx.Err() == nil && moved.Load() < phaseBudget {
var err error
if dir == "download" {
err = stream(ctx, client, src, &moved)
} else {
err = upstream(ctx, client, &moved)
}
if err == nil {
tries = 0
continue
}
if ctx.Err() != nil {
return
}
errOnce.Do(func() { firstErr = err })
if tries++; tries > 2 { // refused three times: this stream is finished
return
}
select {
case <-time.After(400 * time.Millisecond):
case <-ctx.Done():
return
}
}
}()
}
// the ramp, measured and thrown away
select {
case <-time.After(warmUp):
case <-dead:
case <-ctx.Done():
}
mark, at := moved.Load(), time.Now()
last, lastAt := mark, at // the last time bytes were still arriving
deadline := at.Add(forHow)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for time.Now().Before(deadline) {
select {
case <-tick.C:
case <-dead:
case <-ctx.Done():
}
if parent.Err() != nil {
break
}
now := moved.Load()
if now > last {
last, lastAt = now, time.Now()
}
// enough has moved to know, and it has been long enough to mean it
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
break
}
if secs := time.Since(at).Seconds(); secs > 0 {
live := mbps(now-mark, secs)
part := math.Min(1, secs/forHow.Seconds())
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
}
// every stream has given up and nothing more is coming
if alive.Load() == 0 {
break
}
}
total, secs := last-mark, lastAt.Sub(at).Seconds()
stop()
if total <= 0 {
switch {
case firstErr != nil: // what the far end said explains it better than anything else
return 0, moved.Load(), "", firstErr
case parent.Err() != nil:
return 0, moved.Load(), "", parent.Err()
}
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
}
if secs < 0.02 { // too short a window to divide by; the guard is against dividing by zero,
// not a judgement about how long a good measurement takes
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
}
note := ""
if firstErr != nil {
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
}
return mbps(total, secs), moved.Load(), note, nil
}
// join puts two notes together without leaving an empty one dangling.
func join(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}
// stream fetches one chunk from src, counting bytes as they arrive.
func stream(ctx context.Context, client *http.Client, src Source, n *atomic.Int64) error {
req, err := http.NewRequestWithContext(ctx, "GET", src.URL(downChunk), nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return answered(resp.StatusCode, resp.Status)
}
buf := make([]byte, 64<<10)
for {
got, err := resp.Body.Read(buf)
if got > 0 {
n.Add(int64(got))
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
body := &zeros{left: upChunk, n: n}
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
if err != nil {
return err
}
req.ContentLength = upChunk
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 204 {
return answered(resp.StatusCode, resp.Status)
}
return nil
}
// answered turns the far end's refusal into something worth reading. It rate-limits by
// volume: enough testing in a short while and it stops serving the big transfers, which is
// the only failure of this test anyone here is likely to meet.
func answered(code int, status string) error {
if code == 429 || code == 403 {
return errRefused
}
return fmt.Errorf("the speed endpoint answered %s", status)
}
// errRefused is the far end rate-limiting this connection, which is the only failure of this
// test anyone here is likely to meet: it limits by volume, and a test moves a few hundred
// megabytes. It is recognisable so the download phase can try its other source.
var errRefused = errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
func refused(err error) bool { return errors.Is(err, errRefused) }
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
// it is read out onto the wire.
type zeros struct {
left int
n *atomic.Int64
}
func (z *zeros) Read(p []byte) (int, error) {
if z.left <= 0 {
return 0, io.EOF
}
if len(p) > z.left {
p = p[:z.left]
}
for i := range p {
p[i] = 0
}
z.left -= len(p)
z.n.Add(int64(len(p)))
return len(p), nil
}
func newTransport(streams int) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
MaxIdleConns: streams * 2,
MaxIdleConnsPerHost: streams * 2,
// An empty map turns HTTP/2 off: see the note at the top of this file.
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
}
}
// probeOutside asks the far end who it thinks we are. One small request.
func probeOutside() Outside {
tr := newTransport(1)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
if err != nil {
return Outside{At: time.Now(), Error: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Outside{At: time.Now(), Error: shortErr(err)}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != 200 {
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
}
o := Outside{At: time.Now()}
for _, line := range strings.Split(string(body), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "ip":
o.IP = v
case "colo":
o.Colo = v
case "loc":
o.Country = v
}
}
if o.IP == "" {
o.Error = "the trace endpoint said nothing about this connection"
}
return o
}
// shortErr keeps the part of a network error a person can act on.
func shortErr(err error) string {
s := err.Error()
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
return s[i+2:]
}
return s
}
func mbps(bytes int64, secs float64) float64 {
return round(float64(bytes)*8/secs/1e6, 1)
}
func best(v []float64) float64 {
m := v[0]
for _, x := range v {
if x < m {
m = x
}
}
return round(m, 1)
}
// jitter is how much the line wobbles: the average change from one round trip to the next,
// which is the usual definition and, unlike a standard deviation, is not run away with by a
// single slow probe.
func jitter(v []float64) float64 {
if len(v) < 2 {
return 0
}
var sum float64
for i := 1; i < len(v); i++ {
sum += math.Abs(v[i] - v[i-1])
}
return round(sum/float64(len(v)-1), 1)
}
func round(v float64, places int) float64 {
p := math.Pow(10, float64(places))
return math.Round(v*p) / p
}
// Mbits renders a rate the way the line is sold, for a log line.
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }