diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e6a50f..744bee7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) **MinAgent: 0.158.1** (unchanged): an agent before 0.165.0 never reports `waiting_for_quiet`, so nothing is held. Attended diff --git a/controller/cmd/controller/r1261_wiring_test.go b/controller/cmd/controller/r1261_wiring_test.go new file mode 100644 index 00000000..8eae7613 --- /dev/null +++ b/controller/cmd/controller/r1261_wiring_test.go @@ -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)") + } +} diff --git a/controller/cmd/controller/upwatch_wiring.go b/controller/cmd/controller/upwatch_wiring.go index 05cf2d47..dcbea2b7 100644 --- a/controller/cmd/controller/upwatch_wiring.go +++ b/controller/cmd/controller/upwatch_wiring.go @@ -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, 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. - 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{ Source: &upwatch.DockerAPI{}, Explainers: map[string]upwatch.Explainer{ diff --git a/controller/internal/upwatch/linehold.go b/controller/internal/upwatch/linehold.go index ac071cc3..03297205 100644 --- a/controller/internal/upwatch/linehold.go +++ b/controller/internal/upwatch/linehold.go @@ -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} } diff --git a/controller/internal/upwatch/linehold_r1261_test.go b/controller/internal/upwatch/linehold_r1261_test.go new file mode 100644 index 00000000..42754c5b --- /dev/null +++ b/controller/internal/upwatch/linehold_r1261_test.go @@ -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) + } +} diff --git a/controller/internal/upwatch/linehold_test.go b/controller/internal/upwatch/linehold_test.go index 9a32bac3..721889e5 100644 --- a/controller/internal/upwatch/linehold_test.go +++ b/controller/internal/upwatch/linehold_test.go @@ -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 { diff --git a/controller/internal/upwatch/service.go b/controller/internal/upwatch/service.go index 71957a94..e549908a 100644 --- a/controller/internal/upwatch/service.go +++ b/controller/internal/upwatch/service.go @@ -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")