From 26191911c4ff2da05a12b8aa1dbd7d9532f30f9e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 26 Oct 2026 13:01:31 +0100 Subject: [PATCH] R-1292: qBittorrent's 50 % rule owns the NORMAL limit only (up_limit through app/preferences), never the current speed mode's slot. Test with the alternative mode on, red-proved. Commit only. --- CHANGELOG.md | 11 ++++ controller/README.md | 3 +- controller/internal/upwatch/explain_test.go | 15 +++++ controller/internal/upwatch/line_test.go | 4 +- controller/internal/upwatch/linehold.go | 11 ++++ controller/internal/upwatch/qblimit.go | 13 +++-- .../internal/upwatch/qblimit_r1292_test.go | 58 +++++++++++++++++++ 7 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 controller/internal/upwatch/qblimit_r1292_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6080c580..9bd58d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased (next: v0.345.0) — the 50 % rule owns qBittorrent's NORMAL limit only (R-1292) (2026-10-26) — committed, not released + +**MinAgent: 0.158.1** (unchanged). + +- `internal/upwatch/qblimit.go`: `QBLimiter` is `NormalLimit`/`SetNormalLimit` — the `up_limit` slot through + `app/preferences` (the R-1261 helpers in `linehold.go`), never `transfer/uploadLimit`/`setUploadLimit`, which act on the + CURRENT speed mode's slot (measured on 5.2.4). Before: with the household's alternative speed on, the rule read the + household's alternative limit as „theirs" and set nothing, or wrote the alternative slot and stopped for good at the + next switch. Test `TestR1292_TheRuleSetsTheNormalSlotWhileTheAlternativeIsOn` (alternative 500 KiB/s and none), red- + proved against the current-mode calls. The old test fake answers `app/preferences` too. + ## v0.344.0 (2026-10-26) — the line hold holds every seeder (R-1261); FileBrowser off the app network (R-1281); a set-aside restore is quiet (R-1288); the „later" media restart survives a restart (R-1282); a held app says why (R-1286); the install page offers a kept backup (R-1278) — RELEASED on the operator's yes (attended release run; image sha256:9435165d…, from `0a59044`, CI 2355; global floor 09:07:47 UTC, MinAgent 0.158.1; golden 0.344.0 baked and vouched the same day) **Upgrade note:** FileBrowser's rendered compose changes (R-1281), so the file manager restarts once at the update — a diff --git a/controller/README.md b/controller/README.md index 86b59a8a..5895bf18 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3585,7 +3585,8 @@ host's **weekly iperf3 line test** (the agent's `GET /host/line-test`, agent ≥ the tunnel, R-1247) and a floor learnt from the nightly off-site push (one-second samples of the controller's own counter; best 10 s). The watch judges against the higher; the system settings page names the source. qBittorrent gets a default upload limit of 50 % of the TESTED line only (D41; no test yet → no limit; never over a household's own limit), and its -page says so in one line. State: settings.json `upwatch`. Spike: felhom.eu `audits/SPIKE-upload-watch-2026-10-23.md`. +page says so in one line. The rule owns the NORMAL limit only (`up_limit` through app/preferences, unreleased, R-1292): the household's +„alternative speed" limit is never read or written by it. State: settings.json `upwatch`. Spike: felhom.eu `audits/SPIKE-upload-watch-2026-10-23.md`. **A box whose torrent app seeds all day** (v0.343.0, R-1250; `internal/upwatch/linehold.go`): the agent's test needs 10 quiet minutes, which such a box never gives. When the agent reports `waiting_for_quiet` (agent ≥ 0.165.0) and diff --git a/controller/internal/upwatch/explain_test.go b/controller/internal/upwatch/explain_test.go index 21fe0a19..5ab7dca3 100644 --- a/controller/internal/upwatch/explain_test.go +++ b/controller/internal/upwatch/explain_test.go @@ -3,6 +3,7 @@ package upwatch import ( "context" "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -54,6 +55,20 @@ func fakeQBServer(t *testing.T, pw string, logins *int, expireNext *bool, limit fmt.Sscan(r.Form.Get("limit"), &v) *limit = v / 1024 * 1024 w.WriteHeader(200) + // R-1292: the 50 % rule reads and writes the NORMAL slot through app/preferences (normal mode here: the same value). + case "/api/v2/app/preferences": + fmt.Fprintf(w, `{"up_limit":%d,"alt_up_limit":0}`, *limit) + 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 { + *limit = v / 1024 * 1024 + } + w.WriteHeader(200) default: w.WriteHeader(404) } diff --git a/controller/internal/upwatch/line_test.go b/controller/internal/upwatch/line_test.go index c0127671..db9adb79 100644 --- a/controller/internal/upwatch/line_test.go +++ b/controller/internal/upwatch/line_test.go @@ -85,8 +85,8 @@ type fakeQB struct { sets []int64 } -func (f *fakeQB) UploadLimit(context.Context, map[string]string) (int64, error) { return f.cur, nil } -func (f *fakeQB) SetUploadLimit(_ context.Context, b int64) (int64, error) { +func (f *fakeQB) NormalLimit(context.Context) (int64, error) { return f.cur, nil } +func (f *fakeQB) SetNormalLimit(_ context.Context, b int64) (int64, error) { f.sets = append(f.sets, b) f.cur = b / 1024 * 1024 // qBittorrent keeps KiB/s return f.cur, nil diff --git a/controller/internal/upwatch/linehold.go b/controller/internal/upwatch/linehold.go index 03297205..8b9ccf45 100644 --- a/controller/internal/upwatch/linehold.go +++ b/controller/internal/upwatch/linehold.go @@ -338,6 +338,17 @@ func (q *QBittorrent) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, e return q.readSlot(ctx, h, l.Alt) } +// NormalLimit / SetNormalLimit are the 50 % rule's side (R-1292): the NORMAL slot (`up_limit`) whatever the mode. +func (q *QBittorrent) NormalLimit(ctx context.Context) (int64, error) { + l, err := q.ReadLimitLike(ctx, HeldLimit{Alt: false}) + return l.Bytes, err +} + +func (q *QBittorrent) SetNormalLimit(ctx context.Context, bytesPerSec int64) (int64, error) { + l, err := q.WriteLimit(ctx, HeldLimit{Bytes: bytesPerSec, Alt: false}) + return l.Bytes, err +} + // 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} diff --git a/controller/internal/upwatch/qblimit.go b/controller/internal/upwatch/qblimit.go index 07c8361d..3dd78ffa 100644 --- a/controller/internal/upwatch/qblimit.go +++ b/controller/internal/upwatch/qblimit.go @@ -34,10 +34,13 @@ type QBLimitState struct { HouseholdAt string `json:"household_at,omitempty"` } -// QBLimiter is the qBittorrent side the rule needs (the QBittorrent explainer satisfies it). +// QBLimiter is the qBittorrent side the rule needs (the QBittorrent explainer satisfies it). R-1292: the rule owns the +// NORMAL slot only (`up_limit` through app/preferences) — `transfer/uploadLimit`/`setUploadLimit` act on the CURRENT +// speed mode's slot (measured on 5.2.4), so with the household's alternative speed on the rule used to set the +// alternative limit, and the next switch read as „the household changed it". Never the alternative slot. type QBLimiter interface { - UploadLimit(ctx context.Context, h map[string]string) (int64, error) - SetUploadLimit(ctx context.Context, bytesPerSec int64) (int64, error) + NormalLimit(ctx context.Context) (int64, error) + SetNormalLimit(ctx context.Context, bytesPerSec int64) (int64, error) } // QBLimitTarget is share × line, in bytes/s. @@ -51,7 +54,7 @@ func ApplyQBLimit(ctx context.Context, q QBLimiter, st QBLimitState, lineMbps fl if st.Household { return st, "household", nil } - cur, err := q.UploadLimit(ctx, nil) + cur, err := q.NormalLimit(ctx) if err != nil { return st, "error", err } @@ -69,7 +72,7 @@ func ApplyQBLimit(ctx context.Context, q QBLimiter, st QBLimitState, lineMbps fl if !lineKnown { return st, "no-line", nil } - stored, err := q.SetUploadLimit(ctx, QBLimitTarget(lineMbps, share)) + stored, err := q.SetNormalLimit(ctx, QBLimitTarget(lineMbps, share)) if err != nil { return st, "error", err } diff --git a/controller/internal/upwatch/qblimit_r1292_test.go b/controller/internal/upwatch/qblimit_r1292_test.go new file mode 100644 index 00000000..739aab9e --- /dev/null +++ b/controller/internal/upwatch/qblimit_r1292_test.go @@ -0,0 +1,58 @@ +package upwatch + +import ( + "context" + "testing" + "time" +) + +// R-1292: qBittorrent's 50 % rule owns the NORMAL slot only. With the household's alternative speed on, the rule used +// to read and write the CURRENT mode's slot (`transfer/uploadLimit`, measured on 5.2.4): it set the alternative limit, +// and the next switch read as „the household changed it" — the rule stopped for good. The consequences asserted, on the +// R-1261 fake that behaves like 5.2.4 (fakeQBPrefs): the normal limit is set, the alternative is untouched, and a later +// switch either way still reads as the box's own limit. +// RED-PROOF: ApplyQBLimit through UploadLimit/SetUploadLimit (the current mode's slot) → case 1 reads the household's +// alternative 500 KiB/s as „the household's" and sets nothing; case 2 writes the alternative slot, the normal stays 0. + +func r1292Apply(t *testing.T, q *QBittorrent, st QBLimitState) (QBLimitState, string) { + t.Helper() + ns, act, err := ApplyQBLimit(context.Background(), q, st, 100, true, 0.5, time.Date(2026, 10, 26, 3, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + return ns, act +} + +func TestR1292_TheRuleSetsTheNormalSlotWhileTheAlternativeIsOn(t *testing.T) { + target := QBLimitTarget(100, 0.5) + for _, c := range []struct { + name string + alt int64 + }{{"the household's alternative speed 500 KiB/s", 512000}, {"no alternative limit set", 0}} { + t.Run(c.name, func(t *testing.T) { + f := &fakeQBPrefs{up: 0, alt: c.alt, altMode: true} + srv := f.server(t, "pw-1292") + defer srv.Close() + q := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-1292"}, URL: srv.URL} + + st, act := r1292Apply(t, q, QBLimitState{}) + if act != "set" || f.up < target-1024 || f.up > target || st.BoxSetBytes != f.up { + t.Fatalf("alternative mode on: the rule must set the NORMAL limit to ~%d (as stored): act %q, normal %d, recorded %d", + target, act, f.up, st.BoxSetBytes) + } + if f.alt != c.alt { + t.Fatalf("the alternative limit is the household's: %d → %d", c.alt, f.alt) + } + + f.altMode = false // the household switches back to normal speed + st, act = r1292Apply(t, q, st) + if act != "kept" || st.Household { + t.Fatalf("a switch to normal must still read as the box's own limit: act %q, household %v", act, st.Household) + } + f.altMode = true // and back to the alternative + if _, act = r1292Apply(t, q, st); act != "kept" { + t.Fatalf("a switch back to the alternative must still read as the box's own limit: act %q", act) + } + }) + } +}