package linetest import ( "context" "encoding/binary" "encoding/json" "errors" "io" "net" "net/netip" "os" "path/filepath" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // ── a fake iperf3 server (the server half of the protocol, as `iperf3 --server` drives it) ───────────────────────── type fakeServer struct { ln net.Listener busy bool // answer ACCESS_DENIED stall bool // accept the cookie, then never answer recvSeconds float64 // what the server reports as its duration gotIDs []int // the stream ids the client reported gotParams map[string]any mu sync.Mutex received int64 } func newFakeServer(t *testing.T) *fakeServer { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } s := &fakeServer{ln: ln, recvSeconds: 1} t.Cleanup(func() { ln.Close() }) go s.serve() return s } func (s *fakeServer) addr() string { return s.ln.Addr().String() } func wjson(w io.Writer, v any) { b, _ := json.Marshal(v) var h [4]byte binary.BigEndian.PutUint32(h[:], uint32(len(b))) w.Write(append(h[:], b...)) } func (s *fakeServer) serve() { ctrl, err := s.ln.Accept() if err != nil { return } defer ctrl.Close() cookie := make([]byte, cookieSize) if _, err := io.ReadFull(ctrl, cookie); err != nil { return } if s.busy { ctrl.Write([]byte{0xff}) return } if s.stall { time.Sleep(5 * time.Second) return } ctrl.Write([]byte{stParamExchange}) var params map[string]any if readJSON(ctrl, ¶ms) != nil { return } s.gotParams = params n := int(params["parallel"].(float64)) ctrl.Write([]byte{stCreateStreams}) var wg sync.WaitGroup var conns []net.Conn for i := 0; i < n; i++ { d, err := s.ln.Accept() if err != nil { return } c := make([]byte, cookieSize) io.ReadFull(d, c) conns = append(conns, d) wg.Add(1) go func(d net.Conn) { defer wg.Done() k, _ := io.Copy(io.Discard, d) s.mu.Lock() s.received += k s.mu.Unlock() }(d) } ctrl.Write([]byte{stTestStart, stTestRunning}) var st [1]byte if _, err := io.ReadFull(ctrl, st[:]); err != nil || st[0] != stTestEnd { return } ctrl.Write([]byte{stExchangeResults}) var theirs results if readJSON(ctrl, &theirs) != nil { return } for _, x := range theirs.Streams { s.gotIDs = append(s.gotIDs, x.ID) } // The server counts what it RECEIVED; report a fixed figure so the test can tell it from the client's own count. wjson(ctrl, results{Streams: []streamResult{{ID: 1, Bytes: 12_500_000, EndTime: s.recvSeconds}}}) ctrl.Write([]byte{stDisplayResults}) io.ReadFull(ctrl, st[:]) for _, d := range conns { d.Close() } wg.Wait() } func TestClientSpeaksTheProtocolAndCountsTheServersBytes(t *testing.T) { s := newFakeServer(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() r, err := Client{Streams: 4, Time: 300 * time.Millisecond}.Run(ctx, s.addr()) if err != nil { t.Fatalf("Run: %v", err) } if r.ServerBytes != 12_500_000 || r.Mbps != 100 { t.Fatalf("result = %+v; want the SERVER's 12.5 MB in 1 s = 100 Mbit/s", r) } if want := []int{1, 3, 4, 5}; len(s.gotIDs) != 4 || s.gotIDs[0] != want[0] || s.gotIDs[1] != want[1] || s.gotIDs[3] != want[3] { t.Fatalf("stream ids sent = %v, want %v (iperf3 numbers its streams 1, 3, 4, 5)", s.gotIDs, want) } if s.gotParams["tcp"] != true || s.gotParams["parallel"].(float64) != 4 { t.Fatalf("params = %v", s.gotParams) } } func TestClientBusyServerIsErrBusy(t *testing.T) { s := newFakeServer(t) s.busy = true ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if _, err := (Client{Streams: 4, Time: time.Second}).Run(ctx, s.addr()); !errors.Is(err, ErrBusy) { t.Fatalf("err = %v, want ErrBusy", err) } } func TestClientHonoursTheDeadlineAgainstAStalledServer(t *testing.T) { s := newFakeServer(t) s.stall = true ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) defer cancel() start := time.Now() _, err := Client{Streams: 4, Time: time.Second}.Run(ctx, s.addr()) if err == nil { t.Fatal("a stalled server gave no error") } if took := time.Since(start); took > 1500*time.Millisecond { t.Fatalf("Run took %v against a stalled server; the deadline is 400 ms", took) } } func TestClientRefusesAContextWithoutDeadline(t *testing.T) { if _, err := (Client{}).Run(context.Background(), "127.0.0.1:1"); err == nil { t.Fatal("Run without a deadline must refuse (an unbounded test is the thing RunTimeout exists to prevent)") } } // ── the schedule ──────────────────────────────────────────────────────────────────────────────────────────────── func TestEligibleWindowNeverInTheNightChain(t *testing.T) { loc := time.FixedZone("CET", 3600) start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc) // a Monday for m := 0; m < 7*24*60; m++ { lt := start.Add(time.Duration(m) * time.Minute) for _, anchorDay := range []bool{true, false} { if !EligibleAt(lt, 0, anchorDay) { continue } if h := lt.Hour(); h < StartHour || h >= EndHour || (h >= 1 && h <= 6) { t.Fatalf("eligible at %s — outside 10:00-15:59 or inside the night chain (01:00-06:59)", lt.Format("Mon 15:04")) } } } } func TestSlotDelaysTheFirstStartOnTheDueDayOnly(t *testing.T) { loc := time.UTC tue := time.Date(2026, 10, 20, 10, 30, 0, 0, loc) if EligibleAt(tue, 45, true) { t.Fatal("10:30 with slot 45 must wait until 10:45 on the due day") } if !EligibleAt(tue.Add(15*time.Minute), 45, true) { t.Fatal("10:45 with slot 45 must be eligible") } if !EligibleAt(tue.Add(24*time.Hour), 45, false) { t.Fatal("a catch-up day does not wait for the slot again") } if a, b := SlotMinute("demo-hp-bb76ea"), SlotMinute("demo-felhom-8363b5"); a < 0 || a >= SlotSpreadMinutes || b < 0 || b >= SlotSpreadMinutes { t.Fatalf("slots out of range: %d %d", a, b) } } func TestDueWeeklyFromTuesday(t *testing.T) { loc := time.UTC mon := time.Date(2026, 10, 19, 12, 0, 0, 0, loc) tue := time.Date(2026, 10, 20, 12, 0, 0, 0, loc) if !Due(State{}, mon) { t.Fatal("never measured is due") } st := State{LastOKAt: time.Date(2026, 10, 14, 11, 0, 0, 0, loc)} // last Wednesday if Due(st, mon) { t.Fatal("measured after last Tuesday 10:00 is not due on Monday") } if !Due(st, tue) { t.Fatal("from Tuesday 10:00 a new measurement is due") } } // ── the job ───────────────────────────────────────────────────────────────────────────────────────────────────── type fakeRunner struct { mu sync.Mutex calls int out []runOut } type runOut struct { mbps float64 err error } func (f *fakeRunner) Run(ctx context.Context, addr string) (Result, error) { f.mu.Lock() defer f.mu.Unlock() i := f.calls f.calls++ if i >= len(f.out) { return Result{}, errors.New("no more scripted runs") } return Result{Mbps: f.out[i].mbps, ServerBytes: 1}, f.out[i].err } type fakeGate struct{ busy string } func (g *fakeGate) TryAcquire(what string) (func(), string, bool) { if g.busy != "" { return nil, g.busy, false } return func() {}, "", true } type rig struct { t *Tester run *fakeRunner gate *fakeGate now time.Time tx uint64 tunnel bool } // newRig: Tuesday 12:00 UTC (eligible, any slot), tunnel up, 11 quiet samples taken. func newRig(t *testing.T, outs ...runOut) *rig { r := &rig{run: &fakeRunner{out: outs}, gate: &fakeGate{}, now: time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC), tunnel: true} r.t = New(r.run, func() (netip.Addr, bool) { return netip.MustParseAddr("10.77.0.1"), r.tunnel }, r.gate, func() (uint64, error) { return r.tx, nil }, "host-x", filepath.Join(t.TempDir(), "line-test.json"), nil) r.t.loc = time.UTC r.t.now = func() time.Time { return r.now } r.t.sleep = func(context.Context, time.Duration) bool { return true } r.quietMinutes(QuietMinutes + 1) return r } func (r *rig) minute(bytes uint64) { r.now = r.now.Add(time.Minute) r.tx += bytes r.t.sample() } func (r *rig) quietMinutes(n int) { for i := 0; i < n; i++ { r.minute(1_000_000) // 1 MB/min = 0.13 Mbit/s } } func TestTickMeasuresTheHighestOfThreeRuns(t *testing.T) { r := newRig(t, runOut{mbps: 88}, runOut{mbps: 91.5}, runOut{mbps: 90}) r.t.Tick(context.Background()) mbps, _, ok := r.t.Value() if !ok || mbps != 91.5 || r.run.calls != RunsPerMeasurement { t.Fatalf("value %.1f ok=%v calls=%d; want the highest of 3 runs = 91.5", mbps, ok, r.run.calls) } r.t.Tick(context.Background()) if r.run.calls != RunsPerMeasurement { t.Fatal("a measured week must not test again") } } func TestBusyServerIsRetriedLaterTheSameDay(t *testing.T) { r := newRig(t, runOut{err: ErrBusy}, runOut{mbps: 90}, runOut{mbps: 89}, runOut{mbps: 92}) r.t.Tick(context.Background()) if _, _, ok := r.t.Value(); ok || r.run.calls != 1 { t.Fatalf("busy: ok=%v calls=%d; want no value and the measurement stopped after the busy answer", ok, r.run.calls) } if r.t.st.TriesToday != 1 || r.t.st.LastError == "" { t.Fatalf("busy attempt not recorded: %+v", r.t.st) } r.quietMinutes(1) r.t.Tick(context.Background()) if mbps, _, ok := r.t.Value(); !ok || mbps != 92 { t.Fatalf("retry later the same day: value %.1f ok=%v", mbps, ok) } } func TestTriesPerDayAreBounded(t *testing.T) { outs := make([]runOut, 20) for i := range outs { outs[i] = runOut{err: ErrBusy} } r := newRig(t, outs...) for i := 0; i < 10; i++ { r.quietMinutes(1) r.t.Tick(context.Background()) } if r.run.calls != MaxTriesPerDay { t.Fatalf("calls = %d in one day, want at most %d", r.run.calls, MaxTriesPerDay) } } func TestNoTunnelNoTest(t *testing.T) { r := newRig(t, runOut{mbps: 90}) r.tunnel = false r.t.Tick(context.Background()) if r.run.calls != 0 { t.Fatal("tested without an operational tunnel") } if _, _, ok := r.t.Value(); ok { t.Fatal("a value without a test") } } func TestBusyUplinkOrHeavyOpWaits(t *testing.T) { r := newRig(t, runOut{mbps: 90}, runOut{mbps: 90}, runOut{mbps: 90}) r.minute(60_000_000) // one minute at 8 Mbit/s: the family is uploading r.t.Tick(context.Background()) if r.run.calls != 0 { t.Fatal("tested while the uplink was busy in the last 10 minutes") } r.quietMinutes(QuietMinutes) r.gate.busy = "backup:felhom-pbs" r.t.Tick(context.Background()) if r.run.calls != 0 { t.Fatal("tested beside a heavy operation") } r.gate.busy = "" r.t.Tick(context.Background()) if r.run.calls == 0 { t.Fatal("never tested once the uplink was quiet and the gate free") } } func TestNeverTestsInTheNightOrBeforeTuesday(t *testing.T) { r := newRig(t, runOut{mbps: 90}, runOut{mbps: 90}, runOut{mbps: 90}) r.now = time.Date(2026, 10, 20, 3, 0, 0, 0, time.UTC) // Tuesday 03:00 — the night chain r.quietMinutes(QuietMinutes + 1) r.t.Tick(context.Background()) r.t.st.LastOKAt = time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) r.now = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) // Monday noon, measured last week r.quietMinutes(QuietMinutes + 1) r.t.Tick(context.Background()) if r.run.calls != 0 { t.Fatalf("tested %d times at night or when not due", r.run.calls) } } func TestStateSurvivesARestartAndReportsIt(t *testing.T) { r := newRig(t, runOut{mbps: 90.7}, runOut{err: errors.New("reset")}, runOut{mbps: 80}) r.t.Tick(context.Background()) again := New(r.run, nil, r.gate, nil, "host-x", r.t.statePath, nil) mbps, at, ok := again.Value() if !ok || mbps != 90.7 || at.IsZero() { t.Fatalf("after a restart: %.1f %v %v", mbps, at, ok) } b, _ := json.Marshal(again.LineTestStatus(context.Background())) var back hub.LineTestStatus json.Unmarshal(b, &back) if back.Mbps != 90.7 || back.LastOKAt == "" || len(back.RunsMbps) != 2 || back.Schedule == "" { t.Fatalf("report stanza = %s", b) } } // ── the uplink counter ────────────────────────────────────────────────────────────────────────────────────────── func TestUplinkCountsTheBridgesPhysicalPortsOnly(t *testing.T) { root := t.TempDir() w := func(p, s string) { os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755) os.WriteFile(filepath.Join(root, p), []byte(s), 0o644) } w("proc/net/route", "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n"+ "wg-felhom\t0000004D\t00000000\t0001\t0\t0\t0\tFFFFFFFF\n"+ "vmbr0\t00000000\t0100A8C0\t0003\t0\t0\t0\t00000000\n") w("sys/class/net/vmbr0/statistics/tx_bytes", "5\n") w("sys/class/net/vmbr0/brif/enp1s0/.keep", "") w("sys/class/net/vmbr0/brif/veth9201i0/.keep", "") w("sys/class/net/enp1s0/device/.keep", "") w("sys/class/net/enp1s0/statistics/tx_bytes", "1000\n") w("sys/class/net/veth9201i0/statistics/tx_bytes", "777777\n") got, err := SysUplinkTx(root)() if err != nil || got != 1000 { t.Fatalf("uplink tx = %d, %v; want 1000 (the physical port only — not the bridge, not the guest's veth)", got, err) } } func TestQuietNeedsTenFullMinutes(t *testing.T) { base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) var s []txSample for i := 0; i <= QuietMinutes; i++ { s = append(s, txSample{at: base.Add(time.Duration(i) * time.Minute), tx: uint64(i) * 1_000_000}) } if q, _ := quiet(s); !q { t.Fatal("ten quiet minutes must read quiet") } if q, _ := quiet(s[1:]); q { t.Fatal("nine minutes of samples must not read quiet") } gap := append([]txSample(nil), s...) gap[5].at = gap[5].at.Add(-10 * time.Minute) if q, _ := quiet(gap); q { t.Fatal("a gap in the samples must not read quiet") } } // ── a test on request (R-1250): a torrent app that seeds all day ─────────────────────────────────────────────────── // seedingRig: due and eligible, but every minute of the last ten sent at the line's speed — the weekly Tick never runs. func seedingRig(t *testing.T, outs ...runOut) *rig { r := newRig(t, outs...) for i := 0; i < QuietMinutes+1; i++ { r.minute(600_000_000) // 80 Mbit/s: a seed at the line's speed } return r } func TestSeedingBoxIsWaitingForQuietAndRunNowMeasuresIt(t *testing.T) { r := seedingRig(t, runOut{mbps: 88}, runOut{mbps: 92}, runOut{mbps: 90}) r.t.Tick(context.Background()) if r.run.calls != 0 { t.Fatal("the weekly Tick tested a seeding box") } if !r.t.WaitingForQuiet() { t.Fatal("a due, eligible, seeding box must report waiting_for_quiet") } var slept []time.Duration r.t.sleep = func(_ context.Context, d time.Duration) bool { slept = append(slept, d); return true } mbps, runs, err := r.t.RunNow(context.Background()) // the controller holds the app: the counter stands still if err != nil || mbps != 92 || len(runs) != RunsPerMeasurement || r.run.calls != RunsPerMeasurement { t.Fatalf("RunNow = %.1f %v %v, calls %d; want the best of 3 = 92", mbps, runs, err, r.run.calls) } want := []time.Duration{RequestSettle, RequestQuietWindow, RequestRunGap, RequestRunGap} if len(slept) != len(want) { t.Fatalf("slept %v, want %v", slept, want) } var total time.Duration for i := range want { if slept[i] != want[i] { t.Fatalf("slept %v, want %v", slept, want) } total += slept[i] } if total+RunsPerMeasurement*TestTime > 60*time.Second { t.Fatalf("a request holds the app %v + runs; it must end inside 60 s", total) } if v, _, ok := r.t.Value(); !ok || v != 92 { t.Fatalf("the requested measurement was not stored: %.1f %v", v, ok) } if r.t.WaitingForQuiet() { t.Fatal("measured this week: no longer waiting") } if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "not-due" { t.Fatalf("a second request this week: %v, want not-due", err) } } func refusalCode(err error) string { var ref *Refusal if errors.As(err, &ref) { return ref.Code } return "" } // The held app is not the only uploader: a request still refuses to measure a busy uplink. func TestRunNowRefusesAnUplinkThatIsStillBusy(t *testing.T) { r := seedingRig(t, runOut{mbps: 90}, runOut{mbps: 90}, runOut{mbps: 90}) r.t.uplink = func() (uint64, error) { r.tx += 20_000_000; return r.tx, nil } // 20 MB per 10 s = 16 Mbit/s if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "not-quiet" || r.run.calls != 0 { t.Fatalf("RunNow on a busy uplink: %v, calls %d; want not-quiet and no run", err, r.run.calls) } } // Every other rule of the weekly test holds for a request. func TestRunNowKeepsTheWeeklyRules(t *testing.T) { r := seedingRig(t) r.gate.busy = "backup:felhom-pbs" if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "heavy-op" { t.Fatalf("beside a backup: %v, want heavy-op", err) } r.gate.busy = "" r.tunnel = false if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "no-tunnel" { t.Fatalf("no tunnel: %v, want no-tunnel", err) } if r.t.WaitingForQuiet() { t.Fatal("no tunnel: a request could not run, so it must not be invited") } r.tunnel = true r.now = time.Date(2026, 10, 20, 3, 0, 0, 0, time.UTC) // Tuesday 03:00 — the night chain if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "outside-window" { t.Fatalf("at night: %v, want outside-window", err) } if r.run.calls != 0 { t.Fatalf("%d runs on refused requests", r.run.calls) } } // A failed request counts as a try, so a controller cannot request more than MaxTriesPerDay tests a day. func TestRunNowFailuresSpendTheDaysTries(t *testing.T) { var outs []runOut for i := 0; i < MaxTriesPerDay*RunsPerMeasurement; i++ { outs = append(outs, runOut{err: errors.New("connection refused")}) } r := seedingRig(t, outs...) for i := 0; i < MaxTriesPerDay; i++ { if _, _, err := r.t.RunNow(context.Background()); err == nil || refusalCode(err) != "" { t.Fatalf("try %d: %v, want a failed measurement", i+1, err) } } if _, _, err := r.t.RunNow(context.Background()); refusalCode(err) != "tries-used" { t.Fatalf("after %d failures: %v, want tries-used", MaxTriesPerDay, err) } } // A quiet box tests itself on the weekly Tick: it must not invite the controller to hold its app. func TestQuietBoxIsNotWaiting(t *testing.T) { r := newRig(t) if r.t.WaitingForQuiet() { t.Fatal("ten quiet minutes: the weekly Tick runs; waiting_for_quiet must be false") } } // Live proof 2026-10-24 (demo-hp): a freshly started tester answered waiting_for_quiet before it had watched a single // minute, so a quiet box's torrent app would be held for nothing. Waiting needs the full window, seen busy. func TestFreshTesterIsNotWaiting(t *testing.T) { r := &rig{run: &fakeRunner{}, gate: &fakeGate{}, now: time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC), tunnel: true} r.t = New(r.run, func() (netip.Addr, bool) { return netip.MustParseAddr("10.77.0.1"), r.tunnel }, r.gate, func() (uint64, error) { return r.tx, nil }, "host-x", filepath.Join(t.TempDir(), "line-test.json"), nil) r.t.loc = time.UTC r.t.now = func() time.Time { return r.now } if r.t.WaitingForQuiet() { t.Fatal("no minute watched yet: waiting_for_quiet must be false") } for i := 0; i < QuietMinutes; i++ { r.minute(600_000_000) } if r.t.WaitingForQuiet() { t.Fatal("10 samples are 9 minutes: still not a full window") } r.minute(600_000_000) if !r.t.WaitingForQuiet() { t.Fatal("a full busy window: waiting_for_quiet must be true") } }