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

This commit is contained in:
admin committed 2026-10-25 20:38:28 +01:00
1 parent fe9837708a
commit a79a292d72
7 files changed
+450 -63

No files matched your search

+21
View File
@@ -1,3 +1,24 @@
## Unreleased (next: v0.344.0) — the line hold's three edges (R-1261) (2026-10-25 night) — committed, not released
**MinAgent: 0.158.1** (unchanged). The hold acts only on a box whose agent reports `waiting_for_quiet` (≥ 0.165.0).
### The line hold holds every seeder, forgets a removed app, and puts back the slot it held (R-1261, P3)
- **Every running seeder is held** (`internal/upwatch/linehold.go` `SeedLineTestNow`): qBittorrent AND Transmission —
one left seeding kept the uplink busy and the agent refused the test „not quiet" every 15 minutes. All records are
written first (settings `upwatch.line_test_holds`), then every app is held; one failed hold puts every app back; the
release puts each back. A record the 0.343.0 controller left (`line_test_hold`) is honoured and cleared.
- **An app removed while held drops its record** (`RecoverHold`, `Service.TorrentInstalled`, wired from the stack
manager): the record used to wait for an app that never runs again, and qBittorrent's 50 % rule stayed off for good.
A stopped but installed app keeps its record, as before.
- **qBittorrent's speed mode:** its `transfer/uploadLimit` / `setUploadLimit` act on the slot of the CURRENT mode
(measured on 5.2.4, felhom.eu `documentation/audits/night-2026-10-25/r1261-qbittorrent-api-measured.txt`). The hold
now names its slot (`HeldLimit.Alt`) and reads and writes it through `app/preferences` (`up_limit` /
`alt_up_limit`), so a household's switch to the alternative speed during the test can no longer leave 1 KiB/s in the
other slot.
- Tests: `linehold_r1261_test.go` (a fake qBittorrent that behaves as measured), `cmd/controller/r1261_wiring_test.go`.
Each seen failing with its fix switched off: felhom.eu `documentation/audits/night-2026-10-25/redproof-r1261.txt`.
## v0.343.0 (2026-10-25) — RELEASED on the operator's yes (attended release run; image sha256:bb3da72a…, from `e2a9761`, CI 2330; global floor 17:53:42 UTC, MinAgent 0.158.1) — a restore tells the truth, downloads first and never touches an earlier install's backup; the line test for a box that seeds all day (R-1250, R-1251–R-1253, R-1269–R-1271, R-1279; D47, D48, D49 = A) ## v0.343.0 (2026-10-25) — RELEASED on the operator's yes (attended release run; image sha256:bb3da72a…, from `e2a9761`, CI 2330; global floor 17:53:42 UTC, MinAgent 0.158.1) — a restore tells the truth, downloads first and never touches an earlier install's backup; the line test for a box that seeds all day (R-1250, R-1251–R-1253, R-1269–R-1271, R-1279; D47, D48, D49 = A)
**MinAgent: 0.158.1** (unchanged): an agent before 0.165.0 never reports `waiting_for_quiet`, so nothing is held. Attended **MinAgent: 0.158.1** (unchanged): an agent before 0.165.0 never reports `waiting_for_quiet`, so nothing is held. Attended
@@ -0,0 +1,19 @@
package main
import (
"os"
"strings"
"testing"
)
// R-1261 — a hold of an app removed while held is dropped only if main gives the watch the installed check; without
// it TorrentInstalled is nil, which means „assume installed", and the record waits for an app that never runs again.
func TestR1261_MainWiresTheInstalledCheck(t *testing.T) {
b, err := os.ReadFile("upwatch_wiring.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "TorrentInstalled: func(stack string) bool") {
t.Fatal("upwatch_wiring.go must wire TorrentInstalled (R-1261)")
}
}
+6 -1
View File
@@ -32,7 +32,12 @@ func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *ba
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare, svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
Measured: line.Measured, Log: logger, Measured: line.Measured, Log: logger,
// R-1250: a box that seeds all day is line-tested by holding its torrent app low for one test on request. // R-1250: a box that seeds all day is line-tested by holding its torrent app low for one test on request.
Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run} Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run,
// R-1261: a hold of an app removed while held is dropped (else the 50 % rule never runs again).
TorrentInstalled: func(stack string) bool {
st, ok := stackMgr.GetStack(stack)
return ok && st != nil && st.Deployed
}}
w := &upwatch.Watcher{ w := &upwatch.Watcher{
Source: &upwatch.DockerAPI{}, Source: &upwatch.DockerAPI{},
Explainers: map[string]upwatch.Explainer{ Explainers: map[string]upwatch.Explainer{
+176 -48
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx" "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. // HeldLimit is a torrent app's global upload limit as the app stored it.
type HeldLimit struct { type HeldLimit struct {
Bytes int64 `json:"bytes,omitempty"` // qBittorrent: B/s, 0 = none 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 Enabled bool `json:"enabled,omitempty"` // Transmission: speed-limit-up-enabled
KBps int64 `json:"kbps,omitempty"` // Transmission: speed-limit-up, kB/s 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 { type LineHold struct {
App string `json:"app"` App string `json:"app"`
Prev HeldLimit `json:"prev"` Prev HeldLimit `json:"prev"`
@@ -48,11 +51,15 @@ type LineHold struct {
// Holdable is one torrent app's global upload limit. // Holdable is one torrent app's global upload limit.
type Holdable interface { type Holdable interface {
Stack() string Stack() string
// ReadLimit reads the limit that applies NOW (qBittorrent: the slot of its current speed mode).
ReadLimit(ctx context.Context) (HeldLimit, error) 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) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, error)
// HoldValue is the test value: very low, never zero (zero is „no limit"). // HoldValue is the test value in prev's slot: very low, never zero (zero is „no limit").
HoldValue() HeldLimit HoldValue(prev HeldLimit) HeldLimit
} }
// LineRunResult is the agent's answer to a test on request. // 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. // 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 const holdTimeout = 30 * time.Second
func (s *Service) hold() *LineHold { // heldList is every hold record, the 0.343.0 single one included (R-1261: a record that version left is honoured).
return decode(s.Store.GetUpwatch()).Hold 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) { return s.Store.UpdateUpwatch(func(old json.RawMessage) (json.RawMessage, error) {
st := decode(old) st := decode(old)
st.Hold = h st.Hold = nil
st.Holds = hs
if len(hs) == 0 {
st.Holds = nil
}
return json.Marshal(st) 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 { func (s *Service) torrentApp(stack string) Holdable {
for _, t := range s.Torrents { for _, t := range s.Torrents {
if t.Stack() == stack { if t.Stack() == stack {
@@ -91,32 +129,43 @@ func (s *Service) torrentRunning(stack string) bool {
return s.TorrentRunning != nil && s.TorrentRunning(stack) 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 func (s *Service) torrentInstalled(stack string) bool {
return s.TorrentInstalled == nil || s.TorrentInstalled(stack)
}
// 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. // answers true when no record remains.
func (s *Service) RecoverHold(ctx context.Context) bool { func (s *Service) RecoverHold(ctx context.Context) bool {
h := s.hold() for _, h := range s.holds() {
if h == nil {
return true
}
app := s.torrentApp(h.App) app := s.torrentApp(h.App)
if app == nil { if app == nil {
logx.Warnf(s.Log, "[upwatch] a line-test hold names %q, which this controller cannot reach — record dropped", h.App) 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 _ = s.dropHold(h.App)
continue
} }
if !s.torrentRunning(h.App) { 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) 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 continue
} }
logx.Infof(s.Log, "[upwatch] %s was left at the line-test value (since %s) — putting its limit back", h.App, h.At) 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) s.release(ctx, app, h)
}
return len(s.holds()) == 0
} }
// release puts the old limit back when the app still holds the test value, and clears the record. It keeps the // 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 (answers false) only when the app cannot be read or written: the next tick tries again. // 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 { func (s *Service) release(ctx context.Context, app Holdable, h LineHold) bool {
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), holdTimeout) rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), holdTimeout)
defer cancel() defer cancel()
cur, err := app.ReadLimit(rctx) cur, err := app.ReadLimitLike(rctx, h.Held)
if err != nil { 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) logx.Warnf(s.Log, "[upwatch] %s: reading its limit to put it back: %v — tried again in 15 minutes", h.App, err)
return false 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) 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 { if err := s.dropHold(h.App); err != nil {
logx.Warnf(s.Log, "[upwatch] clearing the line-test hold record: %v", err) logx.Warnf(s.Log, "[upwatch] clearing the line-test hold record of %s: %v", h.App, err)
return false return false
} }
return true 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 // 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) { func (s *Service) SeedLineTestNow(ctx context.Context) {
if !s.RecoverHold(ctx) || s.LineWaiting == nil || s.LineRun == nil { if !s.RecoverHold(ctx) || s.LineWaiting == nil || s.LineRun == nil {
return return
} }
var app Holdable var apps []Holdable
for _, t := range s.Torrents { for _, t := range s.Torrents {
if s.torrentRunning(t.Stack()) { if s.torrentRunning(t.Stack()) {
app = t apps = append(apps, t)
break
} }
} }
if app == nil || !s.LineWaiting(ctx) { if len(apps) == 0 || !s.LineWaiting(ctx) {
return return
} }
now := time.Now().UTC().Format(time.RFC3339)
hs := make([]LineHold, 0, len(apps))
for _, app := range apps {
prev, err := app.ReadLimit(ctx) prev, err := app.ReadLimit(ctx)
if err != nil { if err != nil {
logx.Debugf(s.Log, "[upwatch] line test: reading %s's limit: %v — not held", app.Stack(), err) // 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 return
} }
h := LineHold{App: app.Stack(), Prev: prev, Held: app.HoldValue(), At: time.Now().UTC().Format(time.RFC3339)} hs = append(hs, LineHold{App: app.Stack(), Prev: prev, Held: app.HoldValue(prev), At: now})
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()) // 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 return
} }
stored, err := app.WriteLimit(ctx, h.Held) for i, app := range apps {
stored, err := app.WriteLimit(ctx, hs[i].Held)
if err != nil { if err != nil {
// The write may have taken although its read-back failed: release compares against the intended value. // 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) logx.Warnf(s.Log, "[upwatch] line test: holding %s: %v — putting every held app back", app.Stack(), err)
s.release(ctx, app, h) s.releaseAll(ctx, apps, hs)
return return
} }
if stored != h.Held { if stored != hs[i].Held {
h.Held = stored hs[i].Held = stored
if err := s.saveHold(&h); err != nil { if err := s.saveHolds(hs); err != nil {
logx.Warnf(s.Log, "[upwatch] line test: saving the hold record: %v — putting %s back", err, app.Stack()) logx.Warnf(s.Log, "[upwatch] line test: saving the hold records: %v — putting every held app back", err)
s.release(ctx, app, h) s.releaseAll(ctx, apps, hs)
return return
} }
} }
logx.Infof(s.Log, "[upwatch] line test: %s held at %+v (was %+v) — asking the agent for a test", app.Stack(), stored, prev) logx.Infof(s.Log, "[upwatch] line test: %s held at %+v (was %+v)", app.Stack(), stored, hs[i].Prev)
// Deferred: a panic in the call puts the limit back too. A killed process does not — that is RecoverHold's. }
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) { res, err := func() (LineRunResult, error) {
defer s.release(ctx, app, h) defer s.releaseAll(ctx, apps, hs)
return s.LineRun(ctx) return s.LineRun(ctx)
}() }()
switch { switch {
@@ -207,18 +272,76 @@ func (s *Service) SeedLineTestNow(ctx context.Context) {
// ── the two torrent apps ───────────────────────────────────────────────────────────────────────────────────────── // ── 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) { func (q *QBittorrent) ReadLimit(ctx context.Context) (HeldLimit, error) {
b, err := q.UploadLimit(ctx, nil) h, err := q.session(ctx, true)
return HeldLimit{Bytes: b}, err 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) { func (q *QBittorrent) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, error) {
b, err := q.SetUploadLimit(ctx, l.Bytes) h, err := q.session(ctx, true)
return HeldLimit{Bytes: b}, err 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. // HoldValue: 1 KiB/s in the slot that applied when the hold began.
func (q *QBittorrent) HoldValue() HeldLimit { return HeldLimit{Bytes: 1024} } func (q *QBittorrent) HoldValue(prev HeldLimit) HeldLimit {
return HeldLimit{Bytes: 1024, Alt: prev.Alt}
}
func (t *Transmission) ReadLimit(ctx context.Context) (HeldLimit, error) { func (t *Transmission) ReadLimit(ctx context.Context) (HeldLimit, error) {
var st struct { var st struct {
@@ -245,5 +368,10 @@ func (t *Transmission) WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit,
return t.ReadLimit(ctx) 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. // 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)
}
}
+29 -9
View File
@@ -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 // 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. // took while its read-back failed); failRead fails reads.
type fakeTorrent struct { type fakeTorrent struct {
name string // "" = qbittorrent
limit HeldLimit limit HeldLimit
writes []HeldLimit writes []HeldLimit
failWrite bool failWrite bool
failRead bool failRead bool
} }
func (f *fakeTorrent) Stack() string { return "qbittorrent" } func (f *fakeTorrent) Stack() string {
func (f *fakeTorrent) HoldValue() HeldLimit { return HeldLimit{Bytes: 1024} } 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) { func (f *fakeTorrent) ReadLimit(context.Context) (HeldLimit, error) {
if f.failRead { if f.failRead {
return HeldLimit{}, errors.New("connection refused") return HeldLimit{}, errors.New("connection refused")
} }
return f.limit, nil 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) { func (f *fakeTorrent) WriteLimit(_ context.Context, l HeldLimit) (HeldLimit, error) {
f.limit = l f.limit = l
f.writes = append(f.writes, l) f.writes = append(f.writes, l)
@@ -68,7 +77,14 @@ func newHoldRig(t *testing.T) *holdRig {
return r 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, // 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. // 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 // 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"). // handshake. The test value is never zero (zero is „no limit").
func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) { func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) {
logins, expire, limit := 0, false, int64(2_499_584) fq := &fakeQBPrefs{up: 2_499_584, alt: 10_240}
srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit) srv := fq.server(t, "pw-canary")
defer srv.Close() defer srv.Close()
qb := &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL} 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) 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 { 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, limit) 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 { tr := struct {
@@ -289,7 +309,7 @@ func TestHold_TheTwoAppsReadAndWriteTheirLimit(t *testing.T) {
if err != nil || prev != (HeldLimit{KBps: 500}) { if err != nil || prev != (HeldLimit{KBps: 500}) {
t.Fatalf("Transmission read %+v %v, want 500 kB/s switched off", prev, err) 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) 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 { if l, err := tm.WriteLimit(context.Background(), prev); err != nil || l != prev || tr.enabled || tr.up != 500 {
+9 -5
View File
@@ -14,8 +14,10 @@ import (
type State struct { type State struct {
Line []LineRecord `json:"line,omitempty"` Line []LineRecord `json:"line,omitempty"`
QBLimit *QBLimitState `json:"qb_limit,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"` 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). // Store is the settings half the service needs (settings.Settings satisfies it).
@@ -46,11 +48,13 @@ type Service struct {
// to ep0 inside the tunnel. nil or ok=false = not measured. Read every 15 minutes (RefreshMeasured), never per // to ep0 inside the tunnel. nil or ok=false = not measured. Read every 15 minutes (RefreshMeasured), never per
// minute. // minute.
Measured func(ctx context.Context) (mbps float64, at time.Time, ok bool) 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 // The line test for a box that seeds all day (R-1250, linehold.go). Torrents are the apps that can be held;
// order of preference; TorrentRunning says whether one runs now; LineWaiting is the agent's waiting_for_quiet; // TorrentRunning says whether one runs now; TorrentInstalled whether it is installed at all (a hold of an app
// LineRun is its test on request. Any nil = no hold. // 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 Torrents []Holdable
TorrentRunning func(stack string) bool TorrentRunning func(stack string) bool
TorrentInstalled func(stack string) bool
LineWaiting func(ctx context.Context) bool LineWaiting func(ctx context.Context) bool
LineRun func(ctx context.Context) (LineRunResult, error) LineRun func(ctx context.Context) (LineRunResult, error)
Log *log.Logger Log *log.Logger
@@ -146,7 +150,7 @@ func (s *Service) QBLimitNow(ctx context.Context) {
s.RefreshMeasured(ctx) s.RefreshMeasured(ctx)
line, known := s.MeasuredLine() line, known := s.MeasuredLine()
cur := decode(s.Store.GetUpwatch()) 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 // 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). // 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") logx.Debugf(s.Log, "[upwatch] qBittorrent limit: a line-test hold record exists — rule skipped until it is cleared")