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>
688 lines
22 KiB
Go
688 lines
22 KiB
Go
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" }
|