fix(R-1250): the line-test hold waits until the held app has really slowed down before asking the agent
gates / gates (push) Successful in 51s

Measured on demo-hp 2026-10-27: qBittorrent 5.2.4 kept sending at full speed for ~14 s after its limit fell to
1 KiB/s; the agent reads the uplink 5-15 s after the call, so every test on request was refused "not quiet"
(four tries in an hour, 4.0-4.2 Mbit/s). waitDrained polls each held app's own upload (qBittorrent up_info_speed,
Transmission uploadSpeed) until <= 16 KiB/s twice, 2 s apart, at most 90 s; an unreadable speed waits 20 s.
Tests TestR1250_* (five), red-proved by four mutations.
This commit is contained in:
admin committed 2026-10-27 13:23:00 +01:00
1 parent 9b5bb0a9a6
commit 710ceace12
5 files changed
+289 -3

No files matched your search

+120 -2
View File
@@ -21,9 +21,12 @@ import (
// 1. reads the app's global upload limit exactly as the app stored it, and writes it to settings.json
// (`upwatch.line_test_hold`) BEFORE it touches the app;
// 2. sets the app's upload to HoldValue (1 KiB/s);
// 3. asks the agent for a test on request (POST /host/line-test/run: 5 s settle, 10 s quiet check, three 10-second
// 3. waits until every held app REPORTS its own upload near zero (waitDrained) — qBittorrent 5.2.4 kept sending at
// full speed for ~14 s after its limit fell to 1 KiB/s (measured on demo-hp 2026-10-27, felhom.eu
// `audits/linetest-2026-10-27/`), so the agent's quiet check, 5–15 s after the hold, refused every test;
// 4. asks the agent for a test on request (POST /host/line-test/run: 5 s settle, 10 s quiet check, three 10-second
// runs 5 s apart — about 55 s);
// 4. puts the old limit back — the household's own limit included, exactly as it was — and clears the record.
// 5. puts the old limit back — the household's own limit included, exactly as it was — and clears the record.
//
// Whatever fails after step 1, the limit is put back first. A controller that stops between 1 and 4 finds the record
// at start (RecoverHold) and puts it back then. If the app holds anything but the test value when the limit goes
@@ -60,6 +63,8 @@ type Holdable interface {
WriteLimit(ctx context.Context, l HeldLimit) (HeldLimit, error)
// HoldValue is the test value in prev's slot: very low, never zero (zero is „no limit").
HoldValue(prev HeldLimit) HeldLimit
// UploadRate is the app's own upload speed now, B/s — read while held, to know when it has really slowed.
UploadRate(ctx context.Context) (int64, error)
}
// LineRunResult is the agent's answer to a test on request.
@@ -201,6 +206,76 @@ func (s *Service) releaseAll(ctx context.Context, held []Holdable, hs []LineHold
}
}
// The drain wait (step 3). The agent refuses a test when the uplink sends 2 Mbit/s or more 5–15 s after the call, so the
// controller asks only when every held app reports its own upload at or below drainQuietBps (0.13 Mbit/s) on
// drainQuietReadings readings in a row, drainPoll apart. It never waits longer than drainMax, and asks then anyway (the
// agent judges the line). An app whose speed cannot be read gets a plain drainFallback wait instead — the measured
// drain was ~14 s. Pinned by linehold_r1250_drain_test.go.
const (
drainPoll = 2 * time.Second
drainMax = 90 * time.Second
drainFallback = 20 * time.Second
drainQuietBps = 16 * 1024
drainQuietReadings = 2
)
// drainSleep waits d; false when ctx ends first. A variable so the tests can count the waiting instead of doing it.
var drainSleep = func(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// waitDrained returns when every held app has slowed down to the test value, or when it cannot tell (see above).
func (s *Service) waitDrained(ctx context.Context, apps []Holdable) {
var waited time.Duration
quiet := 0
for {
var worst int64
var readErr error
for _, app := range apps {
r, err := app.UploadRate(ctx)
if err != nil {
readErr = fmt.Errorf("%s: %v", app.Stack(), err)
break
}
if r > worst {
worst = r
}
}
switch {
case readErr != nil:
logx.Infof(s.Log, "[upwatch] line test: cannot read the upload speed (%v) — waiting %s before asking the agent", readErr, drainFallback)
if rest := drainFallback - waited; rest > 0 {
drainSleep(ctx, rest)
}
return
case worst <= drainQuietBps:
quiet++
if quiet >= drainQuietReadings {
logx.Infof(s.Log, "[upwatch] line test: the held app(s) slowed down after %s — asking the agent", waited)
return
}
default:
quiet = 0
logx.Debugf(s.Log, "[upwatch] line test: held, still sending %d B/s after %s", worst, waited)
}
if waited >= drainMax {
logx.Infof(s.Log, "[upwatch] line test: the held app(s) still send %d B/s after %s — asking the agent anyway", worst, waited)
return
}
if !drainSleep(ctx, drainPoll) {
return
}
waited += drainPoll
}
}
// 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). 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.
@@ -255,6 +330,7 @@ func (s *Service) SeedLineTestNow(ctx context.Context) {
// 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.releaseAll(ctx, apps, hs)
s.waitDrained(ctx, apps)
return s.LineRun(ctx)
}()
switch {
@@ -354,6 +430,28 @@ func (q *QBittorrent) HoldValue(prev HeldLimit) HeldLimit {
return HeldLimit{Bytes: 1024, Alt: prev.Alt}
}
// UploadRate: transfer/info `up_info_speed` (B/s, payload and overhead together).
func (q *QBittorrent) UploadRate(ctx context.Context) (int64, error) {
h, err := q.session(ctx, true)
if err != nil {
return 0, err
}
code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/info", h, nil, "")
if err != nil || code != 200 {
return 0, fmt.Errorf("qBittorrent transfer/info: HTTP %d %v", code, err)
}
var ti struct {
UpSpeed *int64 `json:"up_info_speed"`
}
if err := json.Unmarshal(b, &ti); err != nil {
return 0, fmt.Errorf("qBittorrent transfer/info: %v", err)
}
if ti.UpSpeed == nil {
return 0, fmt.Errorf("qBittorrent transfer/info: no up_info_speed")
}
return *ti.UpSpeed, nil
}
func (t *Transmission) ReadLimit(ctx context.Context) (HeldLimit, error) {
var st struct {
Arguments struct {
@@ -386,3 +484,23 @@ func (t *Transmission) ReadLimitLike(ctx context.Context, _ HeldLimit) (HeldLimi
// HoldValue: 1 kB/s with the limit switched on.
func (t *Transmission) HoldValue(HeldLimit) HeldLimit { return HeldLimit{Enabled: true, KBps: 1} }
// UploadRate: session-stats `uploadSpeed` (B/s).
func (t *Transmission) UploadRate(ctx context.Context) (int64, error) {
b, err := t.rpc(ctx, `{"method":"session-stats"}`)
if err != nil {
return 0, err
}
var st struct {
Arguments struct {
UploadSpeed *int64 `json:"uploadSpeed"`
} `json:"arguments"`
}
if err := json.Unmarshal(b, &st); err != nil {
return 0, err
}
if st.Arguments.UploadSpeed == nil {
return 0, fmt.Errorf("transmission session-stats: no uploadSpeed")
}
return *st.Arguments.UploadSpeed, nil
}