From 78c61605fdcceb2d635bbe9c482fdcf133ccf65e Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 8 Jul 2026 17:35:33 +0300 Subject: [PATCH] =?UTF-8?q?Retry/stall:=20=D0=B8=D0=B3=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20last=5Factivity=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B1=D1=83=D0=B4=D1=83=D1=89=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=20(=D0=BA=D0=BB=D1=8D=D0=BC=D0=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Наблюдение из ревью кластера B: если qBittorrent отдаёт last_activity впереди now (перекос часов или sentinel «никогда не был активен»), stallDuration уходил в минус и реально застрявший торрент никогда не помечался stuck. Теперь значение из будущего трактуется как непригодное и простой считается от базиса добавления (addedBasis), как при отсутствующем last_activity. Поведение спеки не меняется — оборонительная деталь реализации. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/worker/recovery_test.go | 21 +++++++++++++++++++++ internal/worker/worker.go | 12 ++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/worker/recovery_test.go b/internal/worker/recovery_test.go index d185de9..42df921 100644 --- a/internal/worker/recovery_test.go +++ b/internal/worker/recovery_test.go @@ -239,3 +239,24 @@ func TestStallMeasuredFromLastActivity(t *testing.T) { t.Errorf("простой 2ч > stuck_after 1ч должен дать stuck, got %q", got) } } + +// Оборонительный клэмп: last_activity из будущего (перекос часов или sentinel +// «никогда не был активен») трактуется как непригодное значение. Иначе простой +// вышел бы отрицательным и реально застрявший торрент никогда не пометился бы +// stuck. Значение игнорируется, простой считается от базиса добавления (5ч). +func TestStallIgnoresFutureLastActivity(t *testing.T) { + const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + lastActivityFuture := time.Date(2026, 6, 14, 11, 0, 0, 0, time.UTC).Unix() // 1ч в будущем + st := oneDownloading(ih, timeOld) + qb := &fakeQbt{torrents: []qbt.Torrent{{ + Hash: ih, State: "stalledDL", + AddedOn: addedLongAgo, LastActivity: lastActivityFuture, + }}} + w := newTestWorker(st, qb) + if err := w.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if got := st.downloads["1"].State; got != store.StateStuck { + t.Errorf("last_activity из будущего игнорируется (фолбэк на возраст 5ч > stuck_after 1ч → stuck), got %q", got) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 5447fac..a7b1b2b 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -626,12 +626,16 @@ func (w *Worker) torrentAge(d store.Download, t qbt.Torrent) (time.Duration, boo // stallDuration — длительность простоя торрента для stuck_after: от // last_activity qBittorrent (момент последнего движения данных), с фолбэком на -// базис добавления, если qBit не отдал last_activity. Базис приподнят до -// retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен. +// базис добавления, если qBit не отдал пригодного last_activity. Базис приподнят +// до retried_at (ручной retry даёт свежее окно). ok=false — базис неизвестен. +// +// last_activity в будущем (перекос часов, sentinel «никогда не был активен») +// трактуем как непригодное значение и падаем на addedBasis: иначе простой вышел +// бы отрицательным и реально застрявший торрент никогда бы не пометился stuck. func (w *Worker) stallDuration(d store.Download, t qbt.Torrent) (time.Duration, bool) { var basis time.Time - if t.LastActivity > 0 { - basis = time.Unix(t.LastActivity, 0).UTC() + if la := time.Unix(t.LastActivity, 0).UTC(); t.LastActivity > 0 && !la.After(w.now()) { + basis = la } else { var ok bool if basis, ok = w.addedBasis(d, t); !ok {