From 29bcf4403eec2b706c5c8e06cce7b8598a48ccf3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 24 Oct 2026 13:18:59 +0200 Subject: [PATCH] linetest: a test on request for a box that seeds all day (R-1250) 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. --- CHANGELOG.md | 20 ++++ cmd/felhom-agent/linetest_wiring_test.go | 8 ++ internal/linetest/linetest.go | 121 ++++++++++++++++++++--- internal/linetest/linetest_test.go | 118 ++++++++++++++++++++++ internal/localapi/linetest.go | 58 ++++++++++- internal/localapi/linetest_test.go | 65 ++++++++++++ internal/localapi/server.go | 1 + 7 files changed, 377 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e52100..b0995b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## Unreleased (next: v0.165.0) — the line test for a box that seeds all day (R-1250) (2026-10-24) — committed, not released + +Attended brief 2026-10-24, Part 4 (commit only; the release rides the next normal train). The weekly test (v0.164.0) +starts only after 10 quiet minutes; a torrent app that seeds all day never gives them, so that box never got a tested +line and qBittorrent never got its 50 % limit. Now: + +- **`linetest.Tester.WaitingForQuiet`** — due, inside the window, tries left, tunnel up, and only the quiet missing. + `GET /host/line-test` carries it as `waiting_for_quiet`; a controller with a seeding torrent app acts only on it. +- **`linetest.Tester.RunNow`** and **`POST /host/line-test/run`** (synchronous, about 55 s) — a test on request while the + controller holds its app's upload low: every weekly rule holds (due, window, `MaxTriesPerDay`, tunnel, the one heavy-op + gate) except the 10 quiet minutes, which become a 5 s settle and a 10 s quiet check (below 2 Mbit/s, so a request + never measures the family's own upload); the three runs are 5 s apart instead of 30. A refusal answers its code + (`not-due`, `outside-window`, `tries-used`, `no-tunnel`, `heavy-op`, `not-quiet`, `server-busy`); a failed request + spends one of the day's tries. `Tick` and `RunNow` now share one `ready()` check. +- Tests (each seen failing): `TestSeedingBoxIsWaitingForQuietAndRunNowMeasuresIt` (the sleeps, the 60 s bound), + `TestRunNowRefusesAnUplinkThatIsStillBusy`, `TestRunNowKeepsTheWeeklyRules`, `TestRunNowFailuresSpendTheDaysTries`, + `TestQuietBoxIsNotWaiting`, `TestLineTestRunRoute`; a compile-time assertion that the Tester serves the request half. + +**No sudoers or config-bundle change. MinController: none** — an older controller never calls the route. + ## v0.164.0 — the weekly upload line test (R-1247) (2026-10-24) — RELEASED on the operator's ruling (attended release run); vouched by the operator 09:44:58 CEST Binary sha256 `1b803d8b70f8afbe1546f6989f35b6b0e29c9ed63f6acb3843d3c04ef0d24b2b`; config bundle sha256 diff --git a/cmd/felhom-agent/linetest_wiring_test.go b/cmd/felhom-agent/linetest_wiring_test.go index 997e46f..4d8eacd 100644 --- a/cmd/felhom-agent/linetest_wiring_test.go +++ b/cmd/felhom-agent/linetest_wiring_test.go @@ -5,8 +5,16 @@ import ( "go/parser" "go/token" "testing" + + "gitea.dooplex.hu/admin/felhom-agent/internal/linetest" + "gitea.dooplex.hu/admin/felhom-agent/internal/localapi" ) +// R-1250: SetLineTest takes a LineTestReader and finds the request half by a type assertion, so a Tester that lost +// WaitingForQuiet or RunNow would compile and silently answer 503 to every seeding box. This line makes it a build +// failure instead. +var _ localapi.LineTestRequester = (*linetest.Tester)(nil) + // R-1247: the weekly line test has the fstrim shape (component + reporter seam + goroutine) plus a local-API reader, // so its wiring is asserted from the AST. It must share the ONE heavy-op gate (heavyOps) — or it could run beside a // backup — and take its server from the tunnel manager, never a literal address. diff --git a/internal/linetest/linetest.go b/internal/linetest/linetest.go index 464ca77..9b8ec13 100644 --- a/internal/linetest/linetest.go +++ b/internal/linetest/linetest.go @@ -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). diff --git a/internal/linetest/linetest_test.go b/internal/linetest/linetest_test.go index 8eea692..fa28f59 100644 --- a/internal/linetest/linetest_test.go +++ b/internal/linetest/linetest_test.go @@ -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") + } +} diff --git a/internal/localapi/linetest.go b/internal/localapi/linetest.go index f057cc4..a5614cf 100644 --- a/internal/localapi/linetest.go +++ b/internal/localapi/linetest.go @@ -1,8 +1,12 @@ package localapi import ( + "context" + "errors" "net/http" "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/linetest" ) // LineTestReader is the R-1247 weekly upload line test (internal/linetest.Tester) as the local API reads it. @@ -10,6 +14,13 @@ type LineTestReader interface { Value() (mbps float64, at time.Time, ok bool) } +// LineTestRequester is the R-1250 half: a due test the uplink never lets start, and a test on request. The Tester +// satisfies it; a reader without it answers waiting_for_quiet:false and 503 on the POST. +type LineTestRequester interface { + WaitingForQuiet() bool + RunNow(ctx context.Context) (mbps float64, runs []float64, err error) +} + // LineTestResponse is GET /host/line-test. Present=false: never measured, or the job is not wired (no tunnel, disabled, // or an agent before the route answers 404 — the controller reads all three as „unknown"). type LineTestResponse struct { @@ -17,6 +28,19 @@ type LineTestResponse struct { Mbps float64 `json:"mbps,omitempty"` MeasuredAt string `json:"measured_at,omitempty"` // RFC3339 UTC Method string `json:"method,omitempty"` // "iperf3-tunnel": a 10-second upload to ep0 inside the tunnel + // WaitingForQuiet: a measurement is due and could start now, but the uplink has not been quiet for 10 minutes + // (R-1250). The controller with a seeding torrent app then holds its limit and POSTs /host/line-test/run. + WaitingForQuiet bool `json:"waiting_for_quiet,omitempty"` +} + +// LineTestRunResponse is POST /host/line-test/run. OK=false with Refused: nothing ran (Refused is linetest.Refusal's +// code, or "server-busy"); OK=false without it: the runs failed. +type LineTestRunResponse struct { + OK bool `json:"ok"` + Mbps float64 `json:"mbps,omitempty"` + RunsMbps []float64 `json:"runs_mbps,omitempty"` + Refused string `json:"refused,omitempty"` + Error string `json:"error,omitempty"` } // SetLineTest wires the weekly line test (main.go builds it after the local API server). @@ -27,11 +51,39 @@ func (s *Server) handleLineTest(w http.ResponseWriter, r *http.Request, vmid int writeOK(w, LineTestResponse{Present: false}) return } + var waiting bool + if q, ok := s.lineTest.(LineTestRequester); ok { + waiting = q.WaitingForQuiet() + } mbps, at, ok := s.lineTest.Value() if !ok { - writeOK(w, LineTestResponse{Present: false}) + writeOK(w, LineTestResponse{Present: false, WaitingForQuiet: waiting}) return } - s.logger.Debug("local-api: /host/line-test served", "vmid", vmid, "mbps", mbps, "measured_at", at.UTC().Format(time.RFC3339)) - writeOK(w, LineTestResponse{Present: true, Mbps: mbps, MeasuredAt: at.UTC().Format(time.RFC3339), Method: "iperf3-tunnel"}) + s.logger.Debug("local-api: /host/line-test served", "vmid", vmid, "mbps", mbps, "measured_at", at.UTC().Format(time.RFC3339), "waiting_for_quiet", waiting) + writeOK(w, LineTestResponse{Present: true, Mbps: mbps, MeasuredAt: at.UTC().Format(time.RFC3339), Method: "iperf3-tunnel", WaitingForQuiet: waiting}) +} + +// handleLineTestRun is POST /host/line-test/run (R-1250): a test on request, synchronous (about 55 s). The answer is +// always 200 with the outcome in the body, except 503 when no line test is wired. +func (s *Server) handleLineTestRun(w http.ResponseWriter, r *http.Request, vmid int) { + q, ok := s.lineTest.(LineTestRequester) + if s.lineTest == nil || !ok { + writeErr(w, http.StatusServiceUnavailable, "line test not configured") + return + } + s.logger.Info("local-api: line test requested", "vmid", vmid) + mbps, runs, err := q.RunNow(r.Context()) + var ref *linetest.Refusal + switch { + case err == nil: + writeOK(w, LineTestRunResponse{OK: true, Mbps: mbps, RunsMbps: runs}) + case errors.As(err, &ref): + s.logger.Info("local-api: line test request refused", "vmid", vmid, "reason", ref.Code, "detail", ref.Msg) + writeOK(w, LineTestRunResponse{Refused: ref.Code, Error: ref.Msg}) + case errors.Is(err, linetest.ErrBusy): + writeOK(w, LineTestRunResponse{Refused: "server-busy", Error: err.Error()}) + default: + writeOK(w, LineTestRunResponse{Error: err.Error()}) + } } diff --git a/internal/localapi/linetest_test.go b/internal/localapi/linetest_test.go index c3fd237..b4b751b 100644 --- a/internal/localapi/linetest_test.go +++ b/internal/localapi/linetest_test.go @@ -1,12 +1,16 @@ package localapi import ( + "context" "encoding/json" + "errors" "log/slog" "net/http/httptest" "strings" "testing" "time" + + "gitea.dooplex.hu/admin/felhom-agent/internal/linetest" ) type fixedLine struct { @@ -49,3 +53,64 @@ func TestLineTestRoute(t *testing.T) { t.Fatalf("measured: %+v", got) } } + +type requestableLine struct { + fixedLine + waiting bool + mbps float64 + err error + calls int +} + +func (r *requestableLine) WaitingForQuiet() bool { return r.waiting } +func (r *requestableLine) RunNow(context.Context) (float64, []float64, error) { + r.calls++ + if r.err != nil { + return 0, nil, r.err + } + return r.mbps, []float64{r.mbps - 1, r.mbps}, nil +} + +func lineTestRun(t *testing.T, s *Server) (int, LineTestRunResponse) { + t.Helper() + rec := httptest.NewRecorder() + s.handleLineTestRun(rec, httptest.NewRequest("POST", "/host/line-test/run", nil), 9201) + var env struct { + OK bool `json:"ok"` + Data LineTestRunResponse `json:"data"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &env) + return rec.Code, env.Data +} + +// R-1250: the controller acts only on waiting_for_quiet, and reads a refusal by its code — never as a measurement. +func TestLineTestRunRoute(t *testing.T) { + s := &Server{logger: slog.Default()} + if code, _ := lineTestRun(t, s); code != 503 { + t.Fatalf("not wired: HTTP %d, want 503", code) + } + s.SetLineTest(fixedLine{ok: true, mbps: 90}) + if code, _ := lineTestRun(t, s); code != 503 { + t.Fatalf("a reader without the request half: HTTP %d, want 503", code) + } + l := &requestableLine{fixedLine: fixedLine{ok: false}, waiting: true, mbps: 93.5} + s.SetLineTest(l) + if got := lineTestBody(t, s); got.Present || !got.WaitingForQuiet { + t.Fatalf("GET while waiting: %+v, want present:false waiting_for_quiet:true", got) + } + if code, got := lineTestRun(t, s); code != 200 || !got.OK || got.Mbps != 93.5 || len(got.RunsMbps) != 2 { + t.Fatalf("a measured request: HTTP %d %+v", code, got) + } + l.err = &linetest.Refusal{Code: "not-quiet", Msg: "the uplink sent 16.0 Mbit/s while held"} + if _, got := lineTestRun(t, s); got.OK || got.Refused != "not-quiet" || got.Mbps != 0 { + t.Fatalf("a refusal: %+v, want ok:false refused:not-quiet", got) + } + l.err = linetest.ErrBusy + if _, got := lineTestRun(t, s); got.OK || got.Refused != "server-busy" { + t.Fatalf("server busy: %+v, want refused:server-busy", got) + } + l.err = errors.New("dial tcp 10.77.0.1:5201: i/o timeout") + if _, got := lineTestRun(t, s); got.OK || got.Refused != "" || got.Error == "" { + t.Fatalf("a failed run: %+v, want ok:false, no refusal, the error", got) + } +} diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 3f974fa..c957b4e 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -577,6 +577,7 @@ func (s *Server) Handler() http.Handler { // R-1247: the weekly upload line test's last measurement — the controller's qBittorrent limit and upload watch // read it. Read-only; never measured (or the job not wired) = present:false. mux.HandleFunc("GET /host/line-test", s.withGuest(s.handleLineTest)) + mux.HandleFunc("POST /host/line-test/run", s.withGuest(s.handleLineTestRun)) // R-1250: a test on request // Disk management (slice 8C) — self-scoped; format routes through the data-bearing classifier+gate. mux.HandleFunc("GET /disks", s.withGuest(s.handleDisks)) mux.HandleFunc("GET /disks/candidates", s.withGuest(s.handleDiskCandidates))