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
gates / gates (push) Successful in 1m32s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user