046b808792
gates / gates (push) Successful in 1m22s
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.
302 lines
12 KiB
Go
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)
|
|
}
|
|
}
|