29bcf4403e
gates / gates (push) Successful in 1m37s
GET /host/line-test reports waiting_for_quiet (due, in the window, tunnel up, only the quiet missing); POST /host/line-test/run runs one measurement with a 5 s settle and a 10 s quiet check instead of 10 quiet minutes, runs 5 s apart (~55 s). Every other weekly rule holds. Commit only; the release rides the next train.
552 lines
18 KiB
Go
552 lines
18 KiB
Go
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")
|
|
}
|
|
}
|