linetest: a test on request for a box that seeds all day (R-1250)
gates / gates (push) Successful in 1m37s
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.
This commit is contained in:
1 parent
08ca67861f
commit
29bcf4403e
7 files changed
+377
-14
No files matched your search
+110
-11
@@ -19,6 +19,10 @@
|
||||
// - No tunnel (no operational wg-felhom with a server address) → no test and no value; the controller then keeps its
|
||||
// passive floor and sets no qBittorrent limit.
|
||||
// - The last result is persisted, so a restart neither loses it nor re-tests.
|
||||
// - A box whose torrent app seeds all day is never quiet for 10 minutes (R-1250). For it the controller holds the
|
||||
// app's upload limit very low and asks for a test on request (RunNow): every rule above except the 10 quiet
|
||||
// minutes still holds, the quiet is checked over RequestQuietWindow instead, and the runs are RequestRunGap
|
||||
// apart, so the whole request ends in about 55 s.
|
||||
package linetest
|
||||
|
||||
import (
|
||||
@@ -56,6 +60,11 @@ const (
|
||||
TickInterval = time.Minute
|
||||
FirstTickDelay = 5 * time.Minute
|
||||
GateName = "line-test"
|
||||
|
||||
// A test on request (R-1250): the controller holds the app's limit while it waits, so it is short.
|
||||
RequestSettle = 5 * time.Second // the held app's last packets leave before the quiet is read
|
||||
RequestQuietWindow = 10 * time.Second // the uplink must send below QuietMbps over this window
|
||||
RequestRunGap = 5 * time.Second
|
||||
)
|
||||
|
||||
// ScheduleText is carried on the host report.
|
||||
@@ -258,30 +267,52 @@ func (t *Tester) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tick is one look.
|
||||
func (t *Tester) Tick(ctx context.Context) {
|
||||
// Refusal is why a test on request did not run (RunNow). Code is stable for the controller.
|
||||
type Refusal struct {
|
||||
Code string // not-due | outside-window | tries-used | no-tunnel | heavy-op | not-quiet
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (r *Refusal) Error() string { return "linetest: " + r.Code + ": " + r.Msg }
|
||||
|
||||
// ready applies every rule except the quiet one: due this week, inside the window (the slot on the due day), tries
|
||||
// left today, the tunnel up. It answers the server address and the local day.
|
||||
func (t *Tester) ready() (addr, day string, refusal *Refusal) {
|
||||
lt := t.now().In(t.loc)
|
||||
t.mu.Lock()
|
||||
st := t.st
|
||||
samples := append([]txSample(nil), t.samples...)
|
||||
t.mu.Unlock()
|
||||
if !Due(st, lt) {
|
||||
return
|
||||
return "", "", &Refusal{"not-due", "measured this week"}
|
||||
}
|
||||
anchor := weekAnchor(lt)
|
||||
anchorDay := lt.YearDay() == anchor.YearDay() && lt.Year() == anchor.Year()
|
||||
if !EligibleAt(lt, t.slot, anchorDay) {
|
||||
return
|
||||
return "", "", &Refusal{"outside-window", "a test starts 10:00-15:59 local, from this host's slot on the due day"}
|
||||
}
|
||||
day := lt.Format("2006-01-02")
|
||||
day = lt.Format("2006-01-02")
|
||||
if st.TriesDay == day && st.TriesToday >= MaxTriesPerDay {
|
||||
return
|
||||
return "", "", &Refusal{"tries-used", fmt.Sprintf("%d tries today", st.TriesToday)}
|
||||
}
|
||||
ip, ok := t.target()
|
||||
if !ok {
|
||||
t.logger.Debug("linetest: no operational tunnel — no test")
|
||||
return "", "", &Refusal{"no-tunnel", "no operational tunnel"}
|
||||
}
|
||||
return netip.AddrPortFrom(ip, ServerPort).String(), day, nil
|
||||
}
|
||||
|
||||
// Tick is one look.
|
||||
func (t *Tester) Tick(ctx context.Context) {
|
||||
addr, day, refusal := t.ready()
|
||||
if refusal != nil {
|
||||
if refusal.Code == "no-tunnel" {
|
||||
t.logger.Debug("linetest: no operational tunnel — no test")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
samples := append([]txSample(nil), t.samples...)
|
||||
t.mu.Unlock()
|
||||
if q, worst := quiet(samples); !q {
|
||||
t.logger.Debug("linetest: uplink not quiet — waiting", "worst_minute_mbps", fmt.Sprintf("%.1f", worst), "samples", len(samples))
|
||||
return
|
||||
@@ -292,16 +323,77 @@ func (t *Tester) Tick(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
t.measure(ctx, netip.AddrPortFrom(ip, ServerPort).String(), day)
|
||||
t.measure(ctx, addr, day, RunGap)
|
||||
}
|
||||
|
||||
func (t *Tester) measure(ctx context.Context, addr, day string) {
|
||||
// WaitingForQuiet reports that a measurement is due and could start now but the uplink has not been quiet for
|
||||
// QuietMinutes — the case a seeding torrent app makes permanent (R-1250). The controller reads it from
|
||||
// GET /host/line-test and only then holds its app's limit and calls RunNow.
|
||||
func (t *Tester) WaitingForQuiet() bool {
|
||||
if _, _, refusal := t.ready(); refusal != nil {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
samples := append([]txSample(nil), t.samples...)
|
||||
t.mu.Unlock()
|
||||
q, _ := quiet(samples)
|
||||
return !q
|
||||
}
|
||||
|
||||
// RunNow is a test on request (POST /host/line-test/run, R-1250): every rule of the weekly test except the 10 quiet
|
||||
// minutes, which the caller replaces by holding its own upload down. The uplink must still be quiet over
|
||||
// RequestQuietWindow (after RequestSettle), so a request never measures the family's upload against itself. A
|
||||
// *Refusal says why nothing ran; any other error is a failed measurement (the same as a failed weekly attempt).
|
||||
func (t *Tester) RunNow(ctx context.Context) (mbps float64, runs []float64, err error) {
|
||||
addr, day, refusal := t.ready()
|
||||
if refusal != nil {
|
||||
return 0, nil, refusal
|
||||
}
|
||||
release, busy, ok := t.gate.TryAcquire(GateName)
|
||||
if !ok {
|
||||
return 0, nil, &Refusal{"heavy-op", busy}
|
||||
}
|
||||
defer release()
|
||||
if t.uplink == nil {
|
||||
return 0, nil, &Refusal{"not-quiet", "no uplink counter"}
|
||||
}
|
||||
if !t.sleep(ctx, RequestSettle) {
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
tx0, err0 := t.uplink()
|
||||
at0 := t.now()
|
||||
if !t.sleep(ctx, RequestQuietWindow) {
|
||||
return 0, nil, ctx.Err()
|
||||
}
|
||||
tx1, err1 := t.uplink()
|
||||
if err0 != nil || err1 != nil || tx1 < tx0 {
|
||||
return 0, nil, &Refusal{"not-quiet", "uplink counter unreadable or reset"}
|
||||
}
|
||||
secs := t.now().Sub(at0).Seconds()
|
||||
if secs <= 0 {
|
||||
secs = RequestQuietWindow.Seconds()
|
||||
}
|
||||
if m := float64(tx1-tx0) * 8 / secs / 1e6; m >= QuietMbps {
|
||||
t.logger.Info("linetest: test on request refused — the uplink is not quiet", "mbps", fmt.Sprintf("%.1f", m))
|
||||
return 0, nil, &Refusal{"not-quiet", fmt.Sprintf("the uplink sent %.1f Mbit/s while held", m)}
|
||||
}
|
||||
t.logger.Info("linetest: test on request (a held torrent app, R-1250)", "server", addr)
|
||||
if err := t.measure(ctx, addr, day, RequestRunGap); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.st.Mbps, append([]float64(nil), t.st.RunsMbps...), nil
|
||||
}
|
||||
|
||||
// measure runs the runs, gap apart, and stores the result. It returns nil only when a value was stored.
|
||||
func (t *Tester) measure(ctx context.Context, addr, day string, gap time.Duration) error {
|
||||
start := t.now()
|
||||
var runs []float64
|
||||
var lastErr error
|
||||
busy := false
|
||||
for i := 0; i < RunsPerMeasurement; i++ {
|
||||
if i > 0 && !t.sleep(ctx, RunGap) {
|
||||
if i > 0 && !t.sleep(ctx, gap) {
|
||||
break
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, RunTimeout)
|
||||
@@ -356,6 +448,13 @@ func (t *Tester) measure(ctx context.Context, addr, day string) {
|
||||
if err := t.saveLocked(); err != nil {
|
||||
t.logger.Warn("linetest: state save failed — the result will not survive a restart", "path", t.statePath, "err", err)
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
return nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("linetest: no run succeeded")
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Value is the measured line (ok=false: never measured).
|
||||
|
||||
@@ -431,3 +431,121 @@ func TestQuietNeedsTenFullMinutes(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user