R-1261: the line hold holds every running seeder, drops the record of an app removed while held, and puts back the qBittorrent slot it held (alternative speed). Red-proved. Commit only.
gates / gates (push) Successful in 1m29s
gates / gates (push) Successful in 1m29s
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||
@@ -33,11 +35,12 @@ import (
|
||||
// HeldLimit is a torrent app's global upload limit as the app stored it.
|
||||
type HeldLimit struct {
|
||||
Bytes int64 `json:"bytes,omitempty"` // qBittorrent: B/s, 0 = none
|
||||
Alt bool `json:"alt,omitempty"` // qBittorrent: the ALTERNATIVE speed limit's slot (`alt_up_limit`), else `up_limit`
|
||||
Enabled bool `json:"enabled,omitempty"` // Transmission: speed-limit-up-enabled
|
||||
KBps int64 `json:"kbps,omitempty"` // Transmission: speed-limit-up, kB/s
|
||||
}
|
||||
|
||||
// LineHold is the record of a held app (settings.json `upwatch.line_test_hold`).
|
||||
// LineHold is the record of one held app (settings.json `upwatch.line_test_holds`).
|
||||
type LineHold struct {
|
||||
App string `json:"app"`
|
||||
Prev HeldLimit `json:"prev"`
|
||||
@@ -48,11 +51,15 @@ type LineHold struct {
|
||||
// Holdable is one torrent app's global upload limit.
|
||||
type Holdable interface {
|
||||
Stack() string
|
||||
// ReadLimit reads the limit that applies NOW (qBittorrent: the slot of its current speed mode).
|
||||
ReadLimit(ctx context.Context) (HeldLimit, error)
|
||||
// WriteLimit sets the limit and returns what the app stored.
|
||||
// ReadLimitLike reads the same slot as like, whatever applies now — the release compares and writes the slot it
|
||||
// held, so a household's mode switch during the test never leaves the test value behind (R-1261).
|
||||
ReadLimitLike(ctx context.Context, like HeldLimit) (HeldLimit, error)
|
||||
// WriteLimit sets the limit in l's slot and returns what the app stored there.
|
||||
WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, error)
|
||||
// HoldValue is the test value: very low, never zero (zero is „no limit").
|
||||
HoldValue() HeldLimit
|
||||
// HoldValue is the test value in prev's slot: very low, never zero (zero is „no limit").
|
||||
HoldValue(prev HeldLimit) HeldLimit
|
||||
}
|
||||
|
||||
// LineRunResult is the agent's answer to a test on request.
|
||||
@@ -66,18 +73,49 @@ type LineRunResult struct {
|
||||
// holdTimeout bounds each app call made to put a limit back; it is not the caller's context, which may be ending.
|
||||
const holdTimeout = 30 * time.Second
|
||||
|
||||
func (s *Service) hold() *LineHold {
|
||||
return decode(s.Store.GetUpwatch()).Hold
|
||||
// heldList is every hold record, the 0.343.0 single one included (R-1261: a record that version left is honoured).
|
||||
func heldList(st State) []LineHold {
|
||||
out := append([]LineHold(nil), st.Holds...)
|
||||
if st.Hold != nil {
|
||||
dup := false
|
||||
for _, h := range out {
|
||||
dup = dup || h.App == st.Hold.App
|
||||
}
|
||||
if !dup {
|
||||
out = append(out, *st.Hold)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) saveHold(h *LineHold) error {
|
||||
func (s *Service) holds() []LineHold {
|
||||
return heldList(decode(s.Store.GetUpwatch()))
|
||||
}
|
||||
|
||||
// saveHolds stores the full list (nil clears it) and drops the 0.343.0 single record.
|
||||
func (s *Service) saveHolds(hs []LineHold) error {
|
||||
return s.Store.UpdateUpwatch(func(old json.RawMessage) (json.RawMessage, error) {
|
||||
st := decode(old)
|
||||
st.Hold = h
|
||||
st.Hold = nil
|
||||
st.Holds = hs
|
||||
if len(hs) == 0 {
|
||||
st.Holds = nil
|
||||
}
|
||||
return json.Marshal(st)
|
||||
})
|
||||
}
|
||||
|
||||
// dropHold removes one app's record, keeping the others.
|
||||
func (s *Service) dropHold(app string) error {
|
||||
var keep []LineHold
|
||||
for _, h := range s.holds() {
|
||||
if h.App != app {
|
||||
keep = append(keep, h)
|
||||
}
|
||||
}
|
||||
return s.saveHolds(keep)
|
||||
}
|
||||
|
||||
func (s *Service) torrentApp(stack string) Holdable {
|
||||
for _, t := range s.Torrents {
|
||||
if t.Stack() == stack {
|
||||
@@ -91,32 +129,43 @@ func (s *Service) torrentRunning(stack string) bool {
|
||||
return s.TorrentRunning != nil && s.TorrentRunning(stack)
|
||||
}
|
||||
|
||||
// RecoverHold puts back a limit a hold left behind (main calls it at start; SeedLineTestNow calls it first). It
|
||||
// answers true when no record remains.
|
||||
func (s *Service) RecoverHold(ctx context.Context) bool {
|
||||
h := s.hold()
|
||||
if h == nil {
|
||||
return true
|
||||
}
|
||||
app := s.torrentApp(h.App)
|
||||
if app == nil {
|
||||
logx.Warnf(s.Log, "[upwatch] a line-test hold names %q, which this controller cannot reach — record dropped", h.App)
|
||||
return s.saveHold(nil) == nil
|
||||
}
|
||||
if !s.torrentRunning(h.App) {
|
||||
logx.Infof(s.Log, "[upwatch] %s is held at the line-test value but not running — put back when it runs", h.App)
|
||||
return false
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] %s was left at the line-test value (since %s) — putting its limit back", h.App, h.At)
|
||||
return s.release(ctx, app, *h)
|
||||
func (s *Service) torrentInstalled(stack string) bool {
|
||||
return s.TorrentInstalled == nil || s.TorrentInstalled(stack)
|
||||
}
|
||||
|
||||
// release puts the old limit back when the app still holds the test value, and clears the record. It keeps the
|
||||
// record (answers false) only when the app cannot be read or written: the next tick tries again.
|
||||
// RecoverHold puts back every limit a hold left behind (main calls it at start; SeedLineTestNow calls it first). It
|
||||
// answers true when no record remains.
|
||||
func (s *Service) RecoverHold(ctx context.Context) bool {
|
||||
for _, h := range s.holds() {
|
||||
app := s.torrentApp(h.App)
|
||||
if app == nil {
|
||||
logx.Warnf(s.Log, "[upwatch] a line-test hold names %q, which this controller cannot reach — record dropped", h.App)
|
||||
_ = s.dropHold(h.App)
|
||||
continue
|
||||
}
|
||||
if !s.torrentRunning(h.App) {
|
||||
if !s.torrentInstalled(h.App) {
|
||||
// R-1261: removed while held — there is no limit to put back, and a record kept for it would stop
|
||||
// qBittorrent's 50 % rule for good.
|
||||
logx.Infof(s.Log, "[upwatch] %s was removed while held for the line test — its record is dropped (nothing to put back)", h.App)
|
||||
_ = s.dropHold(h.App)
|
||||
continue
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] %s is held at the line-test value but not running — put back when it runs", h.App)
|
||||
continue
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] %s was left at the line-test value (since %s) — putting its limit back", h.App, h.At)
|
||||
s.release(ctx, app, h)
|
||||
}
|
||||
return len(s.holds()) == 0
|
||||
}
|
||||
|
||||
// release puts the old limit back when the app still holds the test value (in the slot it held), and drops this app's
|
||||
// record. It keeps the record (answers false) only when the app cannot be read or written: the next tick tries again.
|
||||
func (s *Service) release(ctx context.Context, app Holdable, h LineHold) bool {
|
||||
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), holdTimeout)
|
||||
defer cancel()
|
||||
cur, err := app.ReadLimit(rctx)
|
||||
cur, err := app.ReadLimitLike(rctx, h.Held)
|
||||
if err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] %s: reading its limit to put it back: %v — tried again in 15 minutes", h.App, err)
|
||||
return false
|
||||
@@ -138,58 +187,74 @@ func (s *Service) release(ctx context.Context, app Holdable, h LineHold) bool {
|
||||
logx.Infof(s.Log, "[upwatch] %s's upload limit is back as it was (%+v)", h.App, got)
|
||||
}
|
||||
}
|
||||
if err := s.saveHold(nil); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] clearing the line-test hold record: %v", err)
|
||||
if err := s.dropHold(h.App); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] clearing the line-test hold record of %s: %v", h.App, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// releaseAll puts back every app of this hold.
|
||||
func (s *Service) releaseAll(ctx context.Context, held []Holdable, hs []LineHold) {
|
||||
for i := range hs {
|
||||
s.release(ctx, held[i], hs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// SeedLineTestNow runs the hold once when the agent waits for quiet and a torrent app is running (main calls it every
|
||||
// 15 minutes, before QBLimitNow). A no-op otherwise.
|
||||
// 15 minutes, before QBLimitNow). EVERY running seeder is held — one left seeding keeps the uplink busy and the agent
|
||||
// refuses the test „not quiet" every time (R-1261). A no-op otherwise.
|
||||
func (s *Service) SeedLineTestNow(ctx context.Context) {
|
||||
if !s.RecoverHold(ctx) || s.LineWaiting == nil || s.LineRun == nil {
|
||||
return
|
||||
}
|
||||
var app Holdable
|
||||
var apps []Holdable
|
||||
for _, t := range s.Torrents {
|
||||
if s.torrentRunning(t.Stack()) {
|
||||
app = t
|
||||
break
|
||||
apps = append(apps, t)
|
||||
}
|
||||
}
|
||||
if app == nil || !s.LineWaiting(ctx) {
|
||||
if len(apps) == 0 || !s.LineWaiting(ctx) {
|
||||
return
|
||||
}
|
||||
prev, err := app.ReadLimit(ctx)
|
||||
if err != nil {
|
||||
logx.Debugf(s.Log, "[upwatch] line test: reading %s's limit: %v — not held", app.Stack(), err)
|
||||
return
|
||||
}
|
||||
h := LineHold{App: app.Stack(), Prev: prev, Held: app.HoldValue(), At: time.Now().UTC().Format(time.RFC3339)}
|
||||
if err := s.saveHold(&h); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] line test: saving the hold record: %v — %s not held", err, app.Stack())
|
||||
return
|
||||
}
|
||||
stored, err := app.WriteLimit(ctx, h.Held)
|
||||
if err != nil {
|
||||
// The write may have taken although its read-back failed: release compares against the intended value.
|
||||
logx.Warnf(s.Log, "[upwatch] line test: holding %s: %v — putting it back", app.Stack(), err)
|
||||
s.release(ctx, app, h)
|
||||
return
|
||||
}
|
||||
if stored != h.Held {
|
||||
h.Held = stored
|
||||
if err := s.saveHold(&h); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] line test: saving the hold record: %v — putting %s back", err, app.Stack())
|
||||
s.release(ctx, app, h)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
hs := make([]LineHold, 0, len(apps))
|
||||
for _, app := range apps {
|
||||
prev, err := app.ReadLimit(ctx)
|
||||
if err != nil {
|
||||
// One seeder that cannot be read cannot be held, and the test would be refused anyway: hold none.
|
||||
logx.Debugf(s.Log, "[upwatch] line test: reading %s's limit: %v — nothing held", app.Stack(), err)
|
||||
return
|
||||
}
|
||||
hs = append(hs, LineHold{App: app.Stack(), Prev: prev, Held: app.HoldValue(prev), At: now})
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] line test: %s held at %+v (was %+v) — asking the agent for a test", app.Stack(), stored, prev)
|
||||
// Deferred: a panic in the call puts the limit back too. A killed process does not — that is RecoverHold's.
|
||||
// Every record FIRST, then the apps: a stop between the two finds the record and puts the limit back.
|
||||
if err := s.saveHolds(hs); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] line test: saving the hold records: %v — nothing held", err)
|
||||
return
|
||||
}
|
||||
for i, app := range apps {
|
||||
stored, err := app.WriteLimit(ctx, hs[i].Held)
|
||||
if err != nil {
|
||||
// The write may have taken although its read-back failed: release compares against the intended value.
|
||||
logx.Warnf(s.Log, "[upwatch] line test: holding %s: %v — putting every held app back", app.Stack(), err)
|
||||
s.releaseAll(ctx, apps, hs)
|
||||
return
|
||||
}
|
||||
if stored != hs[i].Held {
|
||||
hs[i].Held = stored
|
||||
if err := s.saveHolds(hs); err != nil {
|
||||
logx.Warnf(s.Log, "[upwatch] line test: saving the hold records: %v — putting every held app back", err)
|
||||
s.releaseAll(ctx, apps, hs)
|
||||
return
|
||||
}
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] line test: %s held at %+v (was %+v)", app.Stack(), stored, hs[i].Prev)
|
||||
}
|
||||
logx.Infof(s.Log, "[upwatch] line test: %d app(s) held — asking the agent for a test", len(apps))
|
||||
// Deferred: a panic in the call puts the limits back too. A killed process does not — that is RecoverHold's.
|
||||
res, err := func() (LineRunResult, error) {
|
||||
defer s.release(ctx, app, h)
|
||||
defer s.releaseAll(ctx, apps, hs)
|
||||
return s.LineRun(ctx)
|
||||
}()
|
||||
switch {
|
||||
@@ -207,18 +272,76 @@ func (s *Service) SeedLineTestNow(ctx context.Context) {
|
||||
|
||||
// ── the two torrent apps ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// qBittorrent's limit calls (transfer/uploadLimit, setUploadLimit) act on the slot of its CURRENT speed mode — measured
|
||||
// on 5.2.4 (felhom.eu `audits/night-2026-10-25/r1261-qbittorrent-api-measured.txt`): in alternative mode they read and
|
||||
// write `alt_up_limit`. The hold therefore names its slot and reads/writes it through app/preferences (`up_limit`,
|
||||
// `alt_up_limit`, both B/s, either writable in either mode), so a mode switch during the test cannot leave 1 KiB/s.
|
||||
|
||||
func (q *QBittorrent) ReadLimit(ctx context.Context) (HeldLimit, error) {
|
||||
b, err := q.UploadLimit(ctx, nil)
|
||||
return HeldLimit{Bytes: b}, err
|
||||
h, err := q.session(ctx, true)
|
||||
if err != nil {
|
||||
return HeldLimit{}, err
|
||||
}
|
||||
code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/speedLimitsMode", h, nil, "")
|
||||
if err != nil || code != 200 {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent speedLimitsMode: HTTP %d %v", code, err)
|
||||
}
|
||||
return q.readSlot(ctx, h, strings.TrimSpace(string(b)) == "1")
|
||||
}
|
||||
|
||||
func (q *QBittorrent) ReadLimitLike(ctx context.Context, like HeldLimit) (HeldLimit, error) {
|
||||
h, err := q.session(ctx, true)
|
||||
if err != nil {
|
||||
return HeldLimit{}, err
|
||||
}
|
||||
return q.readSlot(ctx, h, like.Alt)
|
||||
}
|
||||
|
||||
func qbSlotKey(alt bool) string {
|
||||
if alt {
|
||||
return "alt_up_limit"
|
||||
}
|
||||
return "up_limit"
|
||||
}
|
||||
|
||||
func (q *QBittorrent) readSlot(ctx context.Context, h map[string]string, alt bool) (HeldLimit, error) {
|
||||
code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/app/preferences", h, nil, "")
|
||||
if err != nil || code != 200 {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent preferences: HTTP %d %v", code, err)
|
||||
}
|
||||
var prefs map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &prefs); err != nil {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent preferences: %v", err)
|
||||
}
|
||||
raw, ok := prefs[qbSlotKey(alt)]
|
||||
if !ok {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent preferences: no %s", qbSlotKey(alt))
|
||||
}
|
||||
var v int64
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent preferences %s: %q", qbSlotKey(alt), raw)
|
||||
}
|
||||
return HeldLimit{Bytes: v, Alt: alt}, nil
|
||||
}
|
||||
|
||||
func (q *QBittorrent) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, error) {
|
||||
b, err := q.SetUploadLimit(ctx, l.Bytes)
|
||||
return HeldLimit{Bytes: b}, err
|
||||
h, err := q.session(ctx, true)
|
||||
if err != nil {
|
||||
return HeldLimit{}, err
|
||||
}
|
||||
js, _ := json.Marshal(map[string]int64{qbSlotKey(l.Alt): l.Bytes})
|
||||
form := url.Values{"json": {string(js)}}.Encode()
|
||||
code, _, _, err := httpDo(ctx, q.client(), "POST", q.base()+"/api/v2/app/setPreferences", h, []byte(form), "application/x-www-form-urlencoded")
|
||||
if err != nil || code != 200 {
|
||||
return HeldLimit{}, fmt.Errorf("qBittorrent setPreferences %s: HTTP %d %v", qbSlotKey(l.Alt), code, err)
|
||||
}
|
||||
return q.readSlot(ctx, h, l.Alt)
|
||||
}
|
||||
|
||||
// HoldValue: 1 KiB/s, qBittorrent's smallest stored step.
|
||||
func (q *QBittorrent) HoldValue() HeldLimit { return HeldLimit{Bytes: 1024} }
|
||||
// HoldValue: 1 KiB/s in the slot that applied when the hold began.
|
||||
func (q *QBittorrent) HoldValue(prev HeldLimit) HeldLimit {
|
||||
return HeldLimit{Bytes: 1024, Alt: prev.Alt}
|
||||
}
|
||||
|
||||
func (t *Transmission) ReadLimit(ctx context.Context) (HeldLimit, error) {
|
||||
var st struct {
|
||||
@@ -245,5 +368,10 @@ func (t *Transmission) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit,
|
||||
return t.ReadLimit(ctx)
|
||||
}
|
||||
|
||||
// ReadLimitLike: Transmission's speed-limit-up is one slot (its alternative speed has its own fields, never touched).
|
||||
func (t *Transmission) ReadLimitLike(ctx context.Context, _ HeldLimit) (HeldLimit, error) {
|
||||
return t.ReadLimit(ctx)
|
||||
}
|
||||
|
||||
// HoldValue: 1 kB/s with the limit switched on.
|
||||
func (t *Transmission) HoldValue() HeldLimit { return HeldLimit{Enabled: true, KBps: 1} }
|
||||
func (t *Transmission) HoldValue(HeldLimit) HeldLimit { return HeldLimit{Enabled: true, KBps: 1} }
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package upwatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeQBPrefs is qBittorrent 5.2.4's limit API as measured on 2026-10-25 (felhom.eu
|
||||
// `audits/night-2026-10-25/r1261-qbittorrent-api-measured.txt`): transfer/uploadLimit and setUploadLimit act on the
|
||||
// slot of the CURRENT speed mode (setUploadLimit rounds to whole KiB); app/preferences shows up_limit and alt_up_limit
|
||||
// in B/s; app/setPreferences writes either slot exactly, in either mode.
|
||||
type fakeQBPrefs struct {
|
||||
up, alt int64
|
||||
altMode bool
|
||||
}
|
||||
|
||||
func (f *fakeQBPrefs) server(t *testing.T, pw string) *httptest.Server {
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/login" {
|
||||
r.ParseForm()
|
||||
if r.Header.Get("Referer") != srv.URL || r.Form.Get("password") != pw {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "QBT_SID_8080", Value: "s1"})
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
active := &f.up
|
||||
if f.altMode {
|
||||
active = &f.alt
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/transfer/speedLimitsMode":
|
||||
if f.altMode {
|
||||
fmt.Fprint(w, "1")
|
||||
} else {
|
||||
fmt.Fprint(w, "0")
|
||||
}
|
||||
case "/api/v2/transfer/toggleSpeedLimitsMode":
|
||||
f.altMode = !f.altMode
|
||||
case "/api/v2/transfer/uploadLimit":
|
||||
fmt.Fprint(w, *active)
|
||||
case "/api/v2/transfer/setUploadLimit":
|
||||
r.ParseForm()
|
||||
var v int64
|
||||
fmt.Sscan(r.Form.Get("limit"), &v)
|
||||
*active = v / 1024 * 1024
|
||||
case "/api/v2/app/preferences":
|
||||
json.NewEncoder(w).Encode(map[string]any{"up_limit": f.up, "alt_up_limit": f.alt, "scheduler_enabled": false})
|
||||
case "/api/v2/app/setPreferences":
|
||||
r.ParseForm()
|
||||
var m map[string]int64
|
||||
if err := json.Unmarshal([]byte(r.Form.Get("json")), &m); err != nil {
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
if v, ok := m["up_limit"]; ok {
|
||||
f.up = v
|
||||
}
|
||||
if v, ok := m["alt_up_limit"]; ok {
|
||||
f.alt = v
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
return srv
|
||||
}
|
||||
|
||||
// R-1261 edge 1: two seeders behind one uplink. Holding only the first left the second seeding, so the agent refused
|
||||
// the test „not quiet" every 15 minutes and it was never counted.
|
||||
// RED-PROOF: hold only the first running app (the 0.343.0 loop's `break`) → the second is never held → fails.
|
||||
func TestR1261_EverySeederIsHeldAndEveryLimitComesBack(t *testing.T) {
|
||||
qb := &fakeTorrent{limit: HeldLimit{Bytes: 2_499_584}}
|
||||
tr := &fakeTorrent{name: "transmission", limit: HeldLimit{Enabled: true, KBps: 500}}
|
||||
store := &memStore{}
|
||||
runs := 0
|
||||
svc := &Service{Store: store, Torrents: []Holdable{qb, tr},
|
||||
TorrentRunning: func(string) bool { return true },
|
||||
LineWaiting: func(context.Context) bool { return true },
|
||||
LineRun: func(context.Context) (LineRunResult, error) {
|
||||
runs++
|
||||
if qb.limit != (HeldLimit{Bytes: 1024}) || tr.limit != (HeldLimit{Bytes: 1024}) {
|
||||
t.Fatalf("during the test qbittorrent=%+v transmission=%+v — every seeder must be at the test value", qb.limit, tr.limit)
|
||||
}
|
||||
if n := len(heldList(decode(store.GetUpwatch()))); n != 2 {
|
||||
t.Fatalf("during the test %d record(s), want 2 — a restart now could not put both back", n)
|
||||
}
|
||||
return LineRunResult{OK: true, Mbps: 92}, nil
|
||||
}}
|
||||
svc.SeedLineTestNow(context.Background())
|
||||
if runs != 1 {
|
||||
t.Fatalf("agent asked %d times, want 1", runs)
|
||||
}
|
||||
if qb.limit != (HeldLimit{Bytes: 2_499_584}) || tr.limit != (HeldLimit{Enabled: true, KBps: 500}) {
|
||||
t.Fatalf("after the test qbittorrent=%+v transmission=%+v, want both limits back", qb.limit, tr.limit)
|
||||
}
|
||||
if n := len(heldList(decode(store.GetUpwatch()))); n != 0 {
|
||||
t.Fatalf("%d record(s) left after the test", n)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1261 edge 2: an app removed while held. The record was kept „until it runs" — forever — and qBittorrent's 50 %
|
||||
// rule never ran again. The consequence asserted: the record goes, and the next hold can run.
|
||||
// RED-PROOF: drop the TorrentInstalled check in RecoverHold → the record stays, no test runs → fails.
|
||||
func TestR1261_ARecordOfAnAppRemovedWhileHeldIsDropped(t *testing.T) {
|
||||
r := newHoldRig(t)
|
||||
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
|
||||
return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}})
|
||||
})
|
||||
r.running = false
|
||||
installed := false
|
||||
r.svc.TorrentInstalled = func(string) bool { return installed }
|
||||
if !r.svc.RecoverHold(context.Background()) || r.record() != nil {
|
||||
t.Fatalf("the record of a removed app must be dropped, got %+v", r.record())
|
||||
}
|
||||
// Still installed but stopped: the record is kept for when it runs again (the old rule, unchanged).
|
||||
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) {
|
||||
return json.Marshal(State{Holds: []LineHold{{App: "qbittorrent", Prev: HeldLimit{Bytes: 2_499_584}, Held: HeldLimit{Bytes: 1024}}}})
|
||||
})
|
||||
installed = true
|
||||
if r.svc.RecoverHold(context.Background()) || r.record() == nil {
|
||||
t.Fatal("a stopped but installed app keeps its record")
|
||||
}
|
||||
// The app is reinstalled and runs: the hold runs again (the next weekly test is no longer blocked).
|
||||
r.store.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{}) })
|
||||
r.running = true
|
||||
r.svc.SeedLineTestNow(context.Background())
|
||||
if r.runs != 1 {
|
||||
t.Fatalf("after the record is gone the line test runs again; runs=%d", r.runs)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1261 edge 3: the household switches qBittorrent to its alternative speed during the test. transfer/uploadLimit
|
||||
// then reads the ALTERNATIVE slot, the release took it for „the household's change", and the normal slot kept 1 KiB/s.
|
||||
// Now the hold names its slot and the release reads and writes that slot.
|
||||
// RED-PROOF: read the current mode's slot in ReadLimitLike (the old behaviour) → up_limit stays 1024 → fails.
|
||||
func TestR1261_ASpeedModeSwitchDuringTheTestNeverLeavesTheTestValue(t *testing.T) {
|
||||
for _, startAlt := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("hold in alt mode=%v", startAlt), func(t *testing.T) {
|
||||
fq := &fakeQBPrefs{up: 2_499_584, alt: 3_072_000, altMode: startAlt}
|
||||
srv := fq.server(t, "pw-canary")
|
||||
defer srv.Close()
|
||||
qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}
|
||||
store := &memStore{}
|
||||
svc := &Service{Store: store, Torrents: []Holdable{qb},
|
||||
TorrentRunning: func(string) bool { return true },
|
||||
LineWaiting: func(context.Context) bool { return true },
|
||||
LineRun: func(context.Context) (LineRunResult, error) {
|
||||
held := fq.up
|
||||
if startAlt {
|
||||
held = fq.alt
|
||||
}
|
||||
if held != 1024 {
|
||||
t.Fatalf("during the test the active slot holds %d, want 1024", held)
|
||||
}
|
||||
fq.altMode = !fq.altMode // the household presses the turtle
|
||||
return LineRunResult{OK: true, Mbps: 92}, nil
|
||||
}}
|
||||
svc.SeedLineTestNow(context.Background())
|
||||
if fq.up != 2_499_584 || fq.alt != 3_072_000 {
|
||||
t.Fatalf("after the test up_limit=%d alt_up_limit=%d, want 2499584 and 3072000 — never the 1 KiB/s test value", fq.up, fq.alt)
|
||||
}
|
||||
if n := len(heldList(decode(store.GetUpwatch()))); n != 0 {
|
||||
t.Fatalf("%d record(s) left", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A record that controller 0.343.0 left (the single `line_test_hold`) is honoured and cleared.
|
||||
func TestR1261_ARecordThe0343ControllerLeftIsPutBack(t *testing.T) {
|
||||
r := newHoldRig(t)
|
||||
r.app.limit = HeldLimit{Bytes: 1024}
|
||||
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}}})
|
||||
})
|
||||
if !r.svc.RecoverHold(context.Background()) || r.app.limit != (HeldLimit{Bytes: 2_499_584}) {
|
||||
t.Fatalf("limit %+v, want the 0.343.0 record's limit back", r.app.limit)
|
||||
}
|
||||
if st := decode(r.store.GetUpwatch()); st.Hold != nil || len(st.Holds) != 0 {
|
||||
t.Fatalf("the old record must be cleared, got %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -15,20 +15,29 @@ import (
|
||||
// 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 {
|
||||
name string // "" = qbittorrent
|
||||
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) Stack() string {
|
||||
if f.name != "" {
|
||||
return f.name
|
||||
}
|
||||
return "qbittorrent"
|
||||
}
|
||||
func (f *fakeTorrent) HoldValue(HeldLimit) 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) ReadLimitLike(ctx context.Context, _ HeldLimit) (HeldLimit, error) {
|
||||
return f.ReadLimit(ctx)
|
||||
}
|
||||
func (f *fakeTorrent) WriteLimit(_ context.Context, l HeldLimit) (HeldLimit, error) {
|
||||
f.limit = l
|
||||
f.writes = append(f.writes, l)
|
||||
@@ -68,7 +77,14 @@ func newHoldRig(t *testing.T) *holdRig {
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *holdRig) record() *LineHold { return decode(r.store.GetUpwatch()).Hold }
|
||||
// record is the single held app's record (nil when none) — the rigs below hold one app unless they say otherwise.
|
||||
func (r *holdRig) record() *LineHold {
|
||||
hs := heldList(decode(r.store.GetUpwatch()))
|
||||
if len(hs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &hs[0]
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -237,15 +253,19 @@ func TestHold_QBLimitRuleSkipsAHeldLimit(t *testing.T) {
|
||||
// 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)
|
||||
fq := &fakeQBPrefs{up: 2_499_584, alt: 10_240}
|
||||
srv := fq.server(t, "pw-canary")
|
||||
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 {
|
||||
if l, err := qb.ReadLimit(context.Background()); err != nil || l != (HeldLimit{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)
|
||||
if l, err := qb.WriteLimit(context.Background(), qb.HoldValue(HeldLimit{})); err != nil || l != (HeldLimit{Bytes: 1024}) || fq.up != 1024 {
|
||||
t.Fatalf("qBittorrent hold: %+v %v, stored %d", l, err, fq.up)
|
||||
}
|
||||
fq.altMode = true
|
||||
if l, err := qb.ReadLimit(context.Background()); err != nil || l != (HeldLimit{Bytes: 10_240, Alt: true}) {
|
||||
t.Fatalf("qBittorrent read in alternative mode %+v %v, want the alt slot", l, err)
|
||||
}
|
||||
|
||||
tr := struct {
|
||||
@@ -289,7 +309,7 @@ func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) {
|
||||
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}) {
|
||||
if l, err := tm.WriteLimit(context.Background(), tm.HoldValue(HeldLimit{})); 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 {
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
type State struct {
|
||||
Line []LineRecord `json:"line,omitempty"`
|
||||
QBLimit *QBLimitState `json:"qb_limit,omitempty"`
|
||||
// Hold is a torrent app held low for a line test on request (R-1250, linehold.go); nil when none.
|
||||
// Hold is the ONE held app of controller 0.343.0 (R-1250); read for a record that version left, never written.
|
||||
Hold *LineHold `json:"line_test_hold,omitempty"`
|
||||
// Holds are the torrent apps held low for a line test on request — every running seeder (R-1261, linehold.go).
|
||||
Holds []LineHold `json:"line_test_holds,omitempty"`
|
||||
}
|
||||
|
||||
// Store is the settings half the service needs (settings.Settings satisfies it).
|
||||
@@ -46,14 +48,16 @@ type Service struct {
|
||||
// to ep0 inside the tunnel. nil or ok=false = not measured. Read every 15 minutes (RefreshMeasured), never per
|
||||
// minute.
|
||||
Measured func(ctx context.Context) (mbps float64, at time.Time, ok bool)
|
||||
// The line test for a box that seeds all day (R-1250, linehold.go). Torrents are the apps that can be held, in
|
||||
// order of preference; TorrentRunning says whether one runs now; LineWaiting is the agent's waiting_for_quiet;
|
||||
// LineRun is its test on request. Any nil = no hold.
|
||||
Torrents []Holdable
|
||||
TorrentRunning func(stack string) bool
|
||||
LineWaiting func(ctx context.Context) bool
|
||||
LineRun func(ctx context.Context) (LineRunResult, error)
|
||||
Log *log.Logger
|
||||
// The line test for a box that seeds all day (R-1250, linehold.go). Torrents are the apps that can be held;
|
||||
// TorrentRunning says whether one runs now; TorrentInstalled whether it is installed at all (a hold of an app
|
||||
// removed while held is dropped — R-1261; nil = assume installed); LineWaiting is the agent's waiting_for_quiet;
|
||||
// LineRun is its test on request. Any of Torrents/TorrentRunning/LineWaiting/LineRun nil = no hold.
|
||||
Torrents []Holdable
|
||||
TorrentRunning func(stack string) bool
|
||||
TorrentInstalled func(stack string) bool
|
||||
LineWaiting func(ctx context.Context) bool
|
||||
LineRun func(ctx context.Context) (LineRunResult, error)
|
||||
Log *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
measured float64
|
||||
@@ -146,7 +150,7 @@ func (s *Service) QBLimitNow(ctx context.Context) {
|
||||
s.RefreshMeasured(ctx)
|
||||
line, known := s.MeasuredLine()
|
||||
cur := decode(s.Store.GetUpwatch())
|
||||
if cur.Hold != nil {
|
||||
if len(heldList(cur)) > 0 {
|
||||
// A line-test hold is in place or was left behind: qBittorrent holds the test value, which the rule would
|
||||
// read as the household's change and then never touch the limit again (linehold.go).
|
||||
logx.Debugf(s.Log, "[upwatch] qBittorrent limit: a line-test hold record exists — rule skipped until it is cleared")
|
||||
|
||||
Reference in New Issue
Block a user