gates / gates (push) Successful in 51s
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.
141 lines
5.7 KiB
Go
141 lines
5.7 KiB
Go
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)
|
||
}
|
||
}
|