Files
felhom-controller/controller/internal/upwatch/linehold_r1250_drain_test.go
T
admin 710ceace12
gates / gates (push) Successful in 51s
fix(R-1250): the line-test hold waits until the held app has really slowed down before asking the agent
Measured on demo-hp 2026-10-27: qBittorrent 5.2.4 kept sending at full speed for ~14 s after its limit fell to
1 KiB/s; the agent reads the uplink 5-15 s after the call, so every test on request was refused "not quiet"
(four tries in an hour, 4.0-4.2 Mbit/s). waitDrained polls each held app's own upload (qBittorrent up_info_speed,
Transmission uploadSpeed) until <= 16 KiB/s twice, 2 s apart, at most 90 s; an unreadable speed waits 20 s.
Tests TestR1250_* (five), red-proved by four mutations.
2026-10-27 13:23:00 +01:00

141 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package upwatch
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// R-1250, measured on demo-hp 2026-10-27: qBittorrent 5.2.4 kept sending at full speed for ~14 s after the hold set its
// limit to 1 KiB/s, while the agent read the uplink 5–15 s after the call — every test on request was refused „not
// quiet" (four tries in one hour). The controller now asks the agent only after the held apps report their own upload
// near zero.
// slept counts the drain waits instead of doing them (every test in this package; none waits for real).
var slept time.Duration
func init() {
drainSleep = func(ctx context.Context, d time.Duration) bool {
slept += d
return ctx.Err() == nil
}
}
// The consequence: the agent is asked only once the held app reported two quiet readings in a row.
// RED-PROOF: drop the waitDrained call in SeedLineTestNow → the agent is asked with 0 readings taken → fails.
func TestR1250_TheAgentIsAskedOnlyAfterTheHeldAppSlowedDown(t *testing.T) {
r := newHoldRig(t)
// 5 Mbit/s for three readings (the drain), then quiet; one noisy reading between two quiet ones restarts the count.
r.app.rates = []int64{640_000, 640_000, 610_000, 0, 300_000, 0, 0}
readsAtCall := -1
r.during = func() { readsAtCall = r.app.rateReads }
slept = 0
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 {
t.Fatalf("agent asked %d times, want 1", r.runs)
}
if readsAtCall != 7 {
t.Fatalf("the agent was asked after %d speed readings, want 7 — only after two quiet readings in a row", readsAtCall)
}
if slept != 6*drainPoll {
t.Fatalf("waited %s before asking, want %s", slept, 6*drainPoll)
}
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil {
t.Fatalf("after the test the app holds %+v (record %+v), want the household's limit back", r.app.limit, r.record())
}
}
// Every held app must be quiet, not just the first.
// RED-PROOF: read only apps[0] in waitDrained → asked while the second still sends → fails.
func TestR1250_EveryHeldAppMustSlowDown(t *testing.T) {
qb := &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}}
tr := &fakeTorrent{name: "transmission", limit: HeldLimit{Enabled: true, KBps: 500}, rates: []int64{400_000, 400_000, 400_000, 0}}
runs, trAtCall := 0, -1
svc := &Service{Store: &memStore{}, Torrents: []Holdable{qb, tr},
TorrentRunning: func(string) bool { return true },
LineWaiting: func(context.Context) bool { return true },
LineRun: func(context.Context) (LineRunResult, error) {
runs++
trAtCall = tr.rateReads
return LineRunResult{OK: true, Mbps: 92}, nil
}}
svc.SeedLineTestNow(context.Background())
if runs != 1 || trAtCall < 5 {
t.Fatalf("asked %d time(s) with %d transmission reading(s), want once and after its two quiet readings (5)", runs, trAtCall)
}
}
// An app that never slows down: the agent is asked anyway after drainMax (it judges the line), and the limit comes back.
// RED-PROOF: drop the drainMax check → the loop never ends → the test times out.
func TestR1250_AnAppThatNeverSlowsIsAskedAboutAfterTheMaximum(t *testing.T) {
r := newHoldRig(t)
r.app.rates = []int64{640_000}
slept = 0
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 || slept != drainMax {
t.Fatalf("asked %d time(s) after %s, want once after %s", r.runs, slept, drainMax)
}
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) {
t.Fatalf("after the test the app holds %+v, want the household's limit back", r.app.limit)
}
}
// An app whose speed cannot be read: a plain drainFallback wait, then the agent.
// RED-PROOF: return at once on a read error → slept 0 → fails.
func TestR1250_AnUnreadableSpeedGetsTheFallbackWait(t *testing.T) {
r := newHoldRig(t)
r.app.failRate = true
slept = 0
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 || slept != drainFallback {
t.Fatalf("asked %d time(s) after %s, want once after %s", r.runs, slept, drainFallback)
}
}
// The two real readers, against the field names the apps serve (qBittorrent 5.2.4 transfer/info, Transmission 4.x
// session-stats); a missing field is an error, never a quiet 0.
func TestR1250_UploadRateReadsTheAppsOwnSpeed(t *testing.T) {
qbBody := `{"up_info_speed":655360,"up_info_data":1}`
qs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v2/auth/login":
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "s"})
w.WriteHeader(204)
case "/api/v2/transfer/info":
_, _ = w.Write([]byte(qbBody))
default:
w.WriteHeader(404)
}
}))
defer qs.Close()
qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: qs.URL}
if v, err := qb.UploadRate(context.Background()); err != nil || v != 655360 {
t.Fatalf("qBittorrent UploadRate = %d, %v; want 655360", v, err)
}
qbBody = `{"up_info_data":1}`
if v, err := qb.UploadRate(context.Background()); err == nil {
t.Fatalf("qBittorrent without up_info_speed read %d, want an error", v)
}
trBody := `{"arguments":{"uploadSpeed":12345,"downloadSpeed":0},"result":"success"}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get("X-Transmission-Session-Id") == "" {
w.Header().Set("X-Transmission-Session-Id", "sid")
w.WriteHeader(409)
return
}
_, _ = w.Write([]byte(trBody))
}))
defer ts.Close()
tr := &Transmission{Box: envBox{"transmission/PASS": "tr-canary"}, URL: ts.URL}
if v, err := tr.UploadRate(context.Background()); err != nil || v != 12345 {
t.Fatalf("Transmission UploadRate = %d, %v; want 12345", v, err)
}
trBody = `{"arguments":{"downloadSpeed":0},"result":"success"}`
if v, err := tr.UploadRate(context.Background()); err == nil {
t.Fatalf("Transmission without uploadSpeed read %d, want an error", v)
}
}