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>
318 lines
10 KiB
Go
318 lines
10 KiB
Go
package network
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHexIP(t *testing.T) {
|
|
// /proc/net/route prints each address as a host-order word: on a little-endian machine
|
|
// 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours.
|
|
for hex, want := range map[string]string{
|
|
"0100000A": "10.0.0.1",
|
|
"00000000": "0.0.0.0",
|
|
"0101A8C0": "192.168.1.1",
|
|
} {
|
|
got, err := hexIP(hex)
|
|
if err != nil || got != want {
|
|
t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want)
|
|
}
|
|
}
|
|
if _, err := hexIP("nonsense"); err == nil {
|
|
t.Error("hexIP accepted nonsense")
|
|
}
|
|
}
|
|
|
|
func TestServerTimeIsNotLatency(t *testing.T) {
|
|
h := http.Header{}
|
|
h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")
|
|
h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`)
|
|
if got := serverMs(h); got != 43 {
|
|
t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got)
|
|
}
|
|
if got := serverMs(http.Header{}); got != 0 {
|
|
t.Errorf("serverMs with no header = %v, want 0", got)
|
|
}
|
|
h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}}
|
|
if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" {
|
|
t.Errorf("readMeta = %+v, want the edge SEA and the address", m)
|
|
}
|
|
}
|
|
|
|
func TestRates(t *testing.T) {
|
|
// a gibibyte in eight seconds is 1073.7 Mbps
|
|
if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 {
|
|
t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got)
|
|
}
|
|
if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 {
|
|
t.Errorf("best = %v, want 9.2 (the least of them)", got)
|
|
}
|
|
if got := jitter([]float64{10, 10, 10}); got != 0 {
|
|
t.Errorf("jitter of a steady line = %v, want 0", got)
|
|
}
|
|
if got := jitter([]float64{10}); got != 0 {
|
|
t.Errorf("jitter of one sample = %v, want 0", got)
|
|
}
|
|
if got := jitter([]float64{5, 50, 5, 50}); got != 45 {
|
|
t.Errorf("jitter of a line alternating by 45 = %v, want 45", got)
|
|
}
|
|
// one slow probe among steady ones moves it a little, not enormously
|
|
if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 {
|
|
t.Errorf("jitter with a single outlier = %v, want it not to run away", got)
|
|
}
|
|
}
|
|
|
|
// The body an upload sends is a fixed size, counts itself, and costs nothing to make.
|
|
func TestZerosBodyCountsItself(t *testing.T) {
|
|
var n atomic.Int64
|
|
z := &zeros{left: 100, n: &n}
|
|
buf := make([]byte, 30)
|
|
total := 0
|
|
for {
|
|
got, err := z.Read(buf)
|
|
total += got
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
if total != 100 || n.Load() != 100 {
|
|
t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load())
|
|
}
|
|
}
|
|
|
|
// Interfaces has to work on whatever machine this runs on, and always name a loopback.
|
|
func TestInterfacesHasLoopback(t *testing.T) {
|
|
ifs := Interfaces()
|
|
if len(ifs) == 0 {
|
|
t.Fatal("no interfaces at all")
|
|
}
|
|
lo := false
|
|
for _, f := range ifs {
|
|
if f.Kind == "loopback" {
|
|
lo = true
|
|
if !f.Up || len(f.Addrs) == 0 {
|
|
t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs)
|
|
}
|
|
}
|
|
if f.Name == "" {
|
|
t.Error("an interface with no name")
|
|
}
|
|
}
|
|
if !lo {
|
|
t.Error("no loopback among the interfaces")
|
|
}
|
|
}
|
|
|
|
// The real thing, against the real endpoint: skipped by default, because `make test` must
|
|
// not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v
|
|
func TestLiveSpeedTest(t *testing.T) {
|
|
if os.Getenv("HAMLAB_NET_TEST") == "" {
|
|
t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line")
|
|
}
|
|
tr := NewTester(nil)
|
|
if _, err := tr.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tr.Start(); err == nil {
|
|
t.Error("a second test started while the first was running")
|
|
}
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for tr.Running() && time.Now().Before(deadline) {
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
st := tr.State()
|
|
if st.Result == nil {
|
|
t.Fatal("no result")
|
|
}
|
|
r := *st.Result
|
|
t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs",
|
|
Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP,
|
|
float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec)
|
|
if r.Error != "" {
|
|
t.Fatalf("test failed: %s", r.Error)
|
|
}
|
|
if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 {
|
|
t.Errorf("a figure came back zero: %+v", r)
|
|
}
|
|
if r.IP == "" {
|
|
t.Error("no public address reported")
|
|
}
|
|
if len(tr.History()) != 1 {
|
|
t.Errorf("history has %d results, want 1", len(tr.History()))
|
|
}
|
|
}
|
|
|
|
// A phase measured against a local server, at a rate the test controls. This is the part of
|
|
// the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it
|
|
// early, and the rate that comes out — and none of it needs the internet to check.
|
|
func TestPhaseMeasuresTheWindow(t *testing.T) {
|
|
const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/up" {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
w.WriteHeader(204)
|
|
return
|
|
}
|
|
buf := make([]byte, perTick)
|
|
fl, _ := w.(http.Flusher)
|
|
for {
|
|
if _, err := w.Write(buf); err != nil {
|
|
return
|
|
}
|
|
if fl != nil {
|
|
fl.Flush()
|
|
}
|
|
select {
|
|
case <-time.After(5 * time.Millisecond):
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
defer func(up string, w, d, mw time.Duration, m int64) {
|
|
upURL, warmUp, downFor, minWindow, measureBytes = up, w, d, mw, m
|
|
}(upURL, warmUp, downFor, minWindow, measureBytes)
|
|
upURL = srv.URL + "/up"
|
|
warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20
|
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
|
|
|
tr := NewTester(nil)
|
|
began := time.Now()
|
|
mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1, here)
|
|
took := time.Since(began)
|
|
if err != nil {
|
|
t.Fatalf("phase: %v", err)
|
|
}
|
|
if note != "" {
|
|
t.Errorf("note = %q, want none — nothing went wrong", note)
|
|
}
|
|
// The cap must have ended it early rather than the clock: the phase was allowed three
|
|
// seconds and should be done in well under one, having moved at least the cap.
|
|
if moved < measureBytes {
|
|
t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes)
|
|
}
|
|
if took > time.Second {
|
|
t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor)
|
|
}
|
|
// The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of
|
|
// Mbps. The point is that it is a real quotient of what moved over how long, not that it
|
|
// hits a particular figure on a loaded test machine.
|
|
if mbps < 50 || mbps > 20000 {
|
|
t.Errorf("mbps = %v, which is not a believable quotient", mbps)
|
|
}
|
|
t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20))
|
|
|
|
// A phase whose server refuses outright fails, and says what it was told.
|
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
|
defer bad.Close()
|
|
refuser := Source{"refuser", func(int) string { return bad.URL + "/down" }}
|
|
_, _, _, err = tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1, refuser)
|
|
if err == nil {
|
|
t.Error("a phase against a server that refuses everything should fail")
|
|
} else if !refused(err) {
|
|
t.Errorf("error = %q, want the refusal that makes the fallback kick in", err)
|
|
}
|
|
}
|
|
|
|
// Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on
|
|
// their way out.
|
|
func TestCancelIsReportedAsCancelled(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
buf := make([]byte, 1<<16)
|
|
for {
|
|
if _, err := w.Write(buf); err != nil {
|
|
return
|
|
}
|
|
select {
|
|
case <-time.After(time.Millisecond):
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
defer func(w, d time.Duration) { warmUp, downFor = w, d }(warmUp, downFor)
|
|
warmUp, downFor = 10*time.Millisecond, 10*time.Second
|
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
|
|
|
tr := NewTester(nil)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() { time.Sleep(200 * time.Millisecond); cancel() }()
|
|
_, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1, here)
|
|
// Cancelled after bytes moved: the figure stands, and the phase does not claim an error.
|
|
if err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Errorf("err = %v, want nil or a cancellation", err)
|
|
}
|
|
}
|
|
|
|
// When the first source refuses, the download is taken from the next one and the result says
|
|
// so. This is the whole reason there is more than one.
|
|
func TestDownloadFallsBackWhenRefused(t *testing.T) {
|
|
serving := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/up" {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
w.WriteHeader(204)
|
|
return
|
|
}
|
|
// paced, so the phase is governed by its window and not by a loopback moving a
|
|
// gibibyte before the first tick
|
|
buf := make([]byte, 1<<20)
|
|
fl, _ := w.(http.Flusher)
|
|
for i := 0; i < 64; i++ {
|
|
if _, err := w.Write(buf); err != nil {
|
|
return
|
|
}
|
|
if fl != nil {
|
|
fl.Flush()
|
|
}
|
|
select {
|
|
case <-time.After(2 * time.Millisecond):
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer serving.Close()
|
|
refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
|
defer refusing.Close()
|
|
|
|
defer func(srcs []Source, up string, w, d, mw time.Duration, m int64) {
|
|
downSources, upURL, warmUp, downFor, minWindow, measureBytes = srcs, up, w, d, mw, m
|
|
}(downSources, upURL, warmUp, downFor, minWindow, measureBytes)
|
|
downSources = []Source{
|
|
{"refuser", func(int) string { return refusing.URL + "/down" }},
|
|
{"the-other-one", func(int) string { return serving.URL + "/down" }},
|
|
}
|
|
upURL = serving.URL + "/up"
|
|
warmUp, downFor, upFor, minWindow, measureBytes = 50*time.Millisecond, time.Second, 300*time.Millisecond, 100*time.Millisecond, 4<<20
|
|
|
|
tr := NewTester(nil)
|
|
if _, err := tr.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for tr.Running() && time.Now().Before(deadline) {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
res := tr.State().Result
|
|
if res == nil {
|
|
t.Fatal("no result")
|
|
}
|
|
if res.Source != "the-other-one" {
|
|
t.Errorf("source = %q, want the second one — the first refused", res.Source)
|
|
}
|
|
if res.DownMbps <= 0 {
|
|
t.Errorf("download = %v, want a figure from the source that did serve: %+v", res.DownMbps, res)
|
|
}
|
|
}
|