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.
gates / gates (push) Successful in 1m32s

This commit is contained in:
2026-10-26 13:01:31 +01:00
parent 3d711ee611
commit 26191911c4
7 changed files with 107 additions and 8 deletions
+11
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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)
}
+2 -2
View File
@@ -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
+11
View File
@@ -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}
+8 -5
View File
@@ -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
}
@@ -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)
}
})
}
}