Files
felhom-controller/controller/internal/upwatch/linehold_test.go
T
admin 046b808792
gates / gates (push) Successful in 1m22s
upwatch: hold a seeding torrent app for one line test, then put its limit back exactly (R-1250)
When the agent waits for quiet and qBittorrent or Transmission runs: record the limit first,
hold at 1 KiB/s, ask the agent for a test on request, put the old limit back (deferred; a
household change during the test stays). Start-up puts back a limit a killed controller left;
the 50 % rule skips while a hold record exists. Commit only.
2026-10-24 13:19:07 +02:00

302 lines
12 KiB
Go

package upwatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeTorrent is a torrent app's limit in memory. failWrite fails the next write AFTER storing it (a write that
// took while its read-back failed); failRead fails reads.
type fakeTorrent struct {
limit HeldLimit
writes []HeldLimit
failWrite bool
failRead bool
}
func (f *fakeTorrent) Stack() string { return "qbittorrent" }
func (f *fakeTorrent) HoldValue() HeldLimit { return HeldLimit{Bytes: 1024} }
func (f *fakeTorrent) ReadLimit(context.Context) (HeldLimit, error) {
if f.failRead {
return HeldLimit{}, errors.New("connection refused")
}
return f.limit, nil
}
func (f *fakeTorrent) WriteLimit(_ context.Context, l HeldLimit) (HeldLimit, error) {
f.limit = l
f.writes = append(f.writes, l)
if f.failWrite {
f.failWrite = false
return HeldLimit{}, errors.New("read-back: EOF")
}
return l, nil
}
type holdRig struct {
svc *Service
app *fakeTorrent
store *memStore
runs int
waiting bool
running bool
result LineRunResult
runErr error
during func() // runs inside the agent's test
}
// newHoldRig: qBittorrent running with the household's own 2.5 MB/s limit, the agent waiting for quiet.
func newHoldRig(t *testing.T) *holdRig {
r := &holdRig{app: &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}}, store: &memStore{}, waiting: true, running: true,
result: LineRunResult{OK: true, Mbps: 93.5}}
r.svc = &Service{Store: r.store, Torrents: []Holdable{r.app},
TorrentRunning: func(string) bool { return r.running },
LineWaiting: func(context.Context) bool { return r.waiting },
LineRun: func(context.Context) (LineRunResult, error) {
r.runs++
if r.during != nil {
r.during()
}
return r.result, r.runErr
}}
return r
}
func (r *holdRig) record() *LineHold { return decode(r.store.GetUpwatch()).Hold }
// R-1250, the brief's three cases: the old limit — the household's own — comes back exactly after a measured test,
// after a failed one, and after a refused one; during the test the app holds the test value and the record exists.
// RED-PROOF: drop the release after LineRun → every case keeps 1024; write the record after the hold → the in-test
// check sees no record.
func TestHold_PutsTheHouseholdsLimitBackExactly(t *testing.T) {
for _, tc := range []struct {
name string
result LineRunResult
err error
}{
{"measured", LineRunResult{OK: true, Mbps: 93.5}, nil},
{"failed test", LineRunResult{Error: "dial tcp 10.77.0.1:5201: i/o timeout"}, nil},
{"refused", LineRunResult{Refused: "not-quiet", Error: "the uplink sent 16.0 Mbit/s while held"}, nil},
{"agent unreachable", LineRunResult{}, errors.New("agentapi: POST /host/line-test/run: context deadline exceeded")},
} {
t.Run(tc.name, func(t *testing.T) {
r := newHoldRig(t)
r.result, r.runErr = tc.result, tc.err
r.during = func() {
if r.app.limit != (HeldLimit{Bytes: 1024}) {
t.Fatalf("during the test the app holds %+v, want 1 KiB/s", r.app.limit)
}
h := r.record()
if h == nil || h.Prev != (HeldLimit{Bytes: 2_499_584}) || h.Held != (HeldLimit{Bytes: 1024}) {
t.Fatalf("during the test the record is %+v — a restart now could not put the limit back", h)
}
}
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 {
t.Fatalf("agent asked %d times, want 1", r.runs)
}
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) {
t.Fatalf("after the test the app holds %+v, want the household's 2499584 back", r.app.limit)
}
if r.record() != nil {
t.Fatal("the hold record was not cleared")
}
})
}
}
// „no limit" (0) is a limit too: it comes back as 0, never as the test value or the 50 % default.
func TestHold_PutsBackNoLimit(t *testing.T) {
r := newHoldRig(t)
r.app.limit = HeldLimit{}
r.svc.SeedLineTestNow(context.Background())
if r.app.limit != (HeldLimit{}) || len(r.app.writes) != 2 {
t.Fatalf("limit %+v after writes %+v, want 0 after hold+release", r.app.limit, r.app.writes)
}
}
// A controller restart in the middle: the record is on disk and the app still holds the test value. The start-up
// check puts the old limit back.
// RED-PROOF: make RecoverHold return true without releasing → the app stays at 1024.
func TestHold_StartupPutsBackALimitLeftHeld(t *testing.T) {
r := newHoldRig(t)
// What a killed controller leaves: the record written, the app at the test value (no defer runs on SIGKILL).
_ = r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
return json.Marshal(State{Hold: &LineHold{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}})
})
r.app.limit = HeldLimit{Bytes: 1024}
restarted := &Service{Store: r.store, Torrents: []Holdable{r.app}, TorrentRunning: func(string) bool { return true }}
if !restarted.RecoverHold(context.Background()) {
t.Fatal("RecoverHold left a record")
}
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil {
t.Fatalf("after the restart the app holds %+v (record %+v), want the household's limit back", r.app.limit, r.record())
}
}
// A panic inside the agent call still puts the limit back before it travels on.
func TestHold_APanicInTheTestStillPutsTheLimitBack(t *testing.T) {
r := newHoldRig(t)
r.during = func() { panic("agentapi bug") }
func() {
defer func() { _ = recover() }()
r.svc.SeedLineTestNow(context.Background())
}()
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil {
t.Fatalf("after a panic: limit %+v record %+v", r.app.limit, r.record())
}
}
// The household changed the limit while it was held: theirs stays.
func TestHold_AHouseholdChangeDuringTheTestStays(t *testing.T) {
r := newHoldRig(t)
r.during = func() { r.app.limit = HeldLimit{Bytes: 5_120_000} }
r.svc.SeedLineTestNow(context.Background())
if r.app.limit != (HeldLimit{Bytes: 5_120_000}) || r.record() != nil {
t.Fatalf("limit %+v record %+v, want the household's new 5120000 kept and the record cleared", r.app.limit, r.record())
}
}
// If anything fails, the limit is put back FIRST: a hold write that took but failed its read-back is undone, and
// the agent is never asked.
func TestHold_AFailedHoldIsUndoneAndNoTestRuns(t *testing.T) {
r := newHoldRig(t)
r.app.failWrite = true
r.svc.SeedLineTestNow(context.Background())
if r.runs != 0 {
t.Fatal("the agent was asked after the hold failed")
}
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil {
t.Fatalf("limit %+v record %+v, want the old limit back and no record", r.app.limit, r.record())
}
}
// The app cannot be reached when the limit should go back: the record stays, and the next tick puts it back. While a
// record exists no new hold starts.
func TestHold_AnUnreachableAppKeepsTheRecordUntilItIsBack(t *testing.T) {
r := newHoldRig(t)
r.during = func() { r.app.failRead = true }
r.svc.SeedLineTestNow(context.Background())
if r.record() == nil || r.app.limit != (HeldLimit{Bytes: 1024}) {
t.Fatalf("limit %+v record %+v, want the record kept while the app is unreachable", r.app.limit, r.record())
}
r.svc.SeedLineTestNow(context.Background())
if r.runs != 1 {
t.Fatal("a second hold started while the first was not put back")
}
r.app.failRead = false
r.running = false
if r.svc.RecoverHold(context.Background()) || r.app.limit != (HeldLimit{Bytes: 1024}) {
t.Fatal("a stopped app cannot be put back; the record must wait")
}
r.running, r.during = true, nil
r.svc.SeedLineTestNow(context.Background()) // recovers first; the agent is still waiting, so a new test follows
if r.app.limit != (HeldLimit{Bytes: 2_499_584}) || r.record() != nil || r.runs != 2 {
t.Fatalf("limit %+v record %+v runs %d", r.app.limit, r.record(), r.runs)
}
}
// No hold without a reason: the agent not waiting, or no torrent app running.
func TestHold_OnlyWhenTheAgentWaitsAndAnAppRuns(t *testing.T) {
r := newHoldRig(t)
r.waiting = false
r.svc.SeedLineTestNow(context.Background())
r.waiting, r.running = true, false
r.svc.SeedLineTestNow(context.Background())
if r.runs != 0 || len(r.app.writes) != 0 {
t.Fatalf("runs %d writes %+v, want none", r.runs, r.app.writes)
}
}
// qBittorrent's 50 % rule must not run on a held limit: it would read 1 KiB/s as the household's change and stop for
// good (QBLimitState.Household).
// RED-PROOF: remove the Hold check in QBLimitNow → Household turns true.
func TestHold_QBLimitRuleSkipsAHeldLimit(t *testing.T) {
logins, expire, limit := 0, false, int64(1024)
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit)
defer srv.Close()
st := &memStore{}
_ = st.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
return json.Marshal(State{QBLimit: &QBLimitState{BoxSetBytes: 5_654 * 1024, LineMbps: 92.6},
Hold: &LineHold{App: "qbittorrent", Prev: HeldLimit{Bytes: 5_654 * 1024}, Held: HeldLimit{Bytes: 1024}}})
})
svc := &Service{Store: st, QB: &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL},
QBShare: 0.5}
svc.QBLimitNow(context.Background())
if q := decode(st.GetUpwatch()).QBLimit; q == nil || q.Household || limit != 1024 {
t.Fatalf("QBLimitNow on a held limit: record %+v, limit %d — the box's limit became the household's", q, limit)
}
}
// The two apps' real APIs: qBittorrent's global limit, Transmission's session-get/session-set with the session-id
// handshake. The test value is never zero (zero is „no limit").
func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) {
logins, expire, limit := 0, false, int64(2_499_584)
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit)
defer srv.Close()
qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}
if l, err := qb.ReadLimit(context.Background()); err != nil || l.Bytes != 2_499_584 {
t.Fatalf("qBittorrent read %+v %v", l, err)
}
if l, err := qb.WriteLimit(context.Background(), qb.HoldValue()); err != nil || l != (HeldLimit{Bytes: 1024}) || limit != 1024 {
t.Fatalf("qBittorrent hold: %+v %v, stored %d", l, err, limit)
}
tr := struct {
up int64
enabled bool
}{up: 500, enabled: false}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Transmission-Session-Id") != "sid-1" {
w.Header().Set("X-Transmission-Session-Id", "sid-1")
w.WriteHeader(409)
return
}
if u, p, ok := r.BasicAuth(); !ok || u != "admin" || p != "tr-canary" {
w.WriteHeader(401)
return
}
b, _ := io.ReadAll(r.Body)
var req struct {
Method string `json:"method"`
Arguments map[string]any `json:"arguments"`
}
_ = json.Unmarshal(b, &req)
switch req.Method {
case "session-get":
fmt.Fprintf(w, `{"arguments":{"speed-limit-up":%d,"speed-limit-up-enabled":%t},"result":"success"}`, tr.up, tr.enabled)
case "session-set":
if v, ok := req.Arguments["speed-limit-up"].(float64); ok {
tr.up = int64(v)
}
if v, ok := req.Arguments["speed-limit-up-enabled"].(bool); ok {
tr.enabled = v
}
fmt.Fprint(w, `{"arguments":{},"result":"success"}`)
default:
fmt.Fprint(w, `{"result":"method name not recognized"}`)
}
}))
defer ts.Close()
tm := &Transmission{Box: envBox{"transmission/PASS": "tr-canary"}, URL: ts.URL}
prev, err := tm.ReadLimit(context.Background())
if err != nil || prev != (HeldLimit{KBps: 500}) {
t.Fatalf("Transmission read %+v %v, want 500 kB/s switched off", prev, err)
}
if l, err := tm.WriteLimit(context.Background(), tm.HoldValue()); err != nil || l != (HeldLimit{Enabled: true, KBps: 1}) {
t.Fatalf("Transmission hold: %+v %v", l, err)
}
if l, err := tm.WriteLimit(context.Background(), prev); err != nil || l != prev || tr.enabled || tr.up != 500 {
t.Fatalf("Transmission put back: %+v %v (server %d %v), want 500 switched off", l, err, tr.up, tr.enabled)
}
if _, err := tm.rpc(context.Background(), `{"method":"no-such"}`); err == nil || !strings.Contains(err.Error(), "not recognized") {
t.Fatalf("an RPC result other than success must be an error, got %v", err)
}
}