upwatch: hold a seeding torrent app for one line test, then put its limit back exactly (R-1250)
gates / gates (push) Successful in 1m22s

When the agent waits for quiet and qBittorrent or Transmission runs: record the limit first,
hold at 1 KiB/s, ask the agent for a test on request, put the old limit back (deferred; a
household change during the test stays). Start-up puts back a limit a killed controller left;
the 50 % rule skips while a hold record exists. Commit only.
This commit is contained in:
2026-10-24 13:19:07 +02:00
parent bddc477e22
commit 046b808792
10 changed files with 789 additions and 33 deletions
+41 -15
View File
@@ -25,16 +25,19 @@ const baselineMinMinutes = 1200
// newUpwatch builds the upload and CPU watch (internal/upwatch; felhom.eu `08` §9). It does not start it.
func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore,
notifier *notify.Notifier, measured func(context.Context) (float64, time.Time, bool), logger *log.Logger) *upwatch.Service {
notifier *notify.Notifier, line agentLine, logger *log.Logger) *upwatch.Service {
box := mediawire.DockerBox{}
qb := &upwatch.QBittorrent{Box: box}
tr := &upwatch.Transmission{Box: box}
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
Measured: 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.
Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run}
w := &upwatch.Watcher{
Source: &upwatch.DockerAPI{},
Explainers: map[string]upwatch.Explainer{
"qbittorrent": qb,
"transmission": &upwatch.Transmission{Box: box},
"transmission": tr,
"plex": &upwatch.Plex{Token: func() string { return mediawire.PlexToken(box) }, URL: mediawire.PlexURL},
},
Rules: upwatch.DefaultRules(),
@@ -58,26 +61,46 @@ func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *ba
return svc
}
// agentLine is the host's line test as the watch uses it; every field nil without a local API.
type agentLine struct {
Measured func(context.Context) (float64, time.Time, bool)
Waiting func(context.Context) bool // waiting_for_quiet (agent ≥ 0.165.0, R-1250)
Run func(context.Context) (upwatch.LineRunResult, error) // POST /host/line-test/run
}
// agentLineTest reads the host's weekly line test through the agent's local API (agent ≥ 0.164.0; an older agent
// answers 404 → not measured). nil without a local API.
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) func(context.Context) (float64, time.Time, bool) {
// answers 404 → not measured, and never waiting). Empty without a local API.
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) agentLine {
if endpoint == "" || token == "" {
return nil
return agentLine{}
}
ac, err := agentapi.New(endpoint, token, fingerprint)
if err != nil {
logger.Printf("[WARN] [upwatch] agent client init failed (%v) — no line test; qBittorrent gets no limit", err)
return nil
return agentLine{}
}
return func(ctx context.Context) (float64, time.Time, bool) {
read := func(ctx context.Context) (agentapi.LineTestState, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
st, err := ac.LineTest(cctx)
if err != nil {
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
return 0, time.Time{}, false
}
return st.Mbps, st.MeasuredAtTime(), st.Present
return ac.LineTest(cctx)
}
return agentLine{
Measured: func(ctx context.Context) (float64, time.Time, bool) {
st, err := read(ctx)
if err != nil {
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
return 0, time.Time{}, false
}
return st.Mbps, st.MeasuredAtTime(), st.Present
},
Waiting: func(ctx context.Context) bool {
st, err := read(ctx)
return err == nil && st.WaitingForQuiet
},
Run: func(ctx context.Context) (upwatch.LineRunResult, error) {
r, err := ac.LineTestRun(ctx)
return upwatch.LineRunResult{OK: r.OK, Mbps: r.Mbps, Refused: r.Refused, Error: r.Error}, err
},
}
}
@@ -103,8 +126,10 @@ func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bo
}
}
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends.
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends. A torrent limit a
// stopped controller left at the line-test value is put back first (R-1250), before any rule reads it.
func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger) {
svc.RecoverHold(ctx)
svc.RefreshMeasured(ctx)
go svc.Watcher.Run(ctx, time.Minute)
go func() {
@@ -115,6 +140,7 @@ func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger)
case <-ctx.Done():
return
case <-t.C:
svc.SeedLineTestNow(ctx) // R-1250: holds the torrent app only while the agent waits for quiet
svc.QBLimitNow(ctx)
}
}