Жизненный цикл: уборка торрента при отмене во время добавления (F3/NIT-13)

Отмена задачи (catched→cancelled) в окно, пока worker вне блокировки выводит
имя (LLM) и делает qbt.Add, оставляла добавленный торрент в qBittorrent без
задачи-владельца: PromoteCatched корректно пропускал переход, но источник уже
качался/сидировал вечно, а усыновить его назад нельзя (хеши принадлежат
отменённой задаче). Спека покрывала переход состояния, но не побочный эффект.

Комбинированная защита в processCatched:
- re-read состояния под w.mu прямо перед qbt.Add — при отмене источник не
  добавляется вовсе (сужает окно гонки);
- свежий листинг перед add подтверждает отсутствие infohash — признак «своего»
  торрента; при сбое листинга/присутствии add не делаем (усыновит следующий тик);
- при отмене в окне после add (промах PromoteCatched, подтверждённый re-read'ом
  state != catched) — уборка добавленного нами торрента qbt.Delete(_, true);
- WARN/ERROR-логи по этому пути с корреляцией по download_id, без секретов.

Гарантия «удаляем только своё»: удаление-с-данными достижимо ТОЛЬКО после
подтверждённого отсутствия infohash перед add, поэтому пред-существующий/чужой
торрент с тем же хешем никогда не сносится (негативный инвариант). Обоснование
по инварианту «источник неприкосновенен» — в design.md изменения.

Дельта — download-tracking (требование «Добавление пойманной загрузки в
qBittorrent»): re-read перед add, подтверждение отсутствия, уборка при отмене,
негативный сценарий. Тесты покрывают все ветки (skip-before-add, cleanup после
add, пред-существующий не удаляется, сбой БД не удаляет, сбой листинга не
добавляет).

Change archived: 2026-07-17-cancel-during-add-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-17 22:10:21 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 3a00fde058
11 changed files with 792 additions and 30 deletions
+118 -6
View File
@@ -214,9 +214,10 @@ func TestProcessCatchedTimeoutFails(t *testing.T) {
}
}
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
// переход в downloading не применяется — состояние остаётся cancelled.
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
// F3: отмена во время (медленного) вывода имени видна re-read'ом состояния ПЕРЕД
// Add — источник в qBittorrent не добавляется вовсе (раньше Add успевал пройти,
// оставляя неуправляемый торрент без владельца).
func TestProcessCatchedCancelledDuringNamerSkipsAdd(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
@@ -226,11 +227,122 @@ func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться после отмены (re-read перед add), calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("торрент не добавляли — удалять нечего, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
}
// F3, scoped cleanup: отмена приходит в окне ПОСЛЕ успешного Add, но до записи
// перехода (onAdd имитирует Cancel ровно между Add и PromoteCatched). Добавленный
// НАМИ торрент удаляется из qBittorrent С ДАННЫМИ (уборка своего артефакта).
func TestProcessCatchedCancelledAfterAddRemovesTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
qb.onAdd = func() { st.downloads["1"].State = store.StateCancelled }
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 1 {
t.Fatalf("добавленный нами торрент должен быть удалён, Delete calls = %d", len(qb.deleted))
}
if !qb.deletedData[0] {
t.Error("уборка своего артефакта должна идти С ДАННЫМИ (deleteFiles=true)")
}
if len(qb.deleted[0]) == 0 || qb.deleted[0][0] != catchedIH {
t.Errorf("удаление не по infohash загрузки: %v", qb.deleted[0])
}
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled (уборка не трогает состояние)", st.downloads["1"].State)
}
}
// F3, негативный инвариант «удаляем только своё»: тот же infohash появился в
// qBittorrent во время namer (внешний клиент, окно гонки). Свежий листинг перед
// Add видит присутствие → Add не делаем И чужой торрент С ДАННЫМИ не удаляем.
func TestProcessCatchedPreexistingTorrentNotDeleted(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{} // снимок тика пуст → идём обычным путём добавления
w := newTestWorker(st, qb)
nm := &fakeNamer{name: "X", onCall: func() {
// внешний клиент добавил тот же торрент, пока выводилось имя
qb.torrents = []qbt.Torrent{{Hash: catchedIH, Name: "external"}}
}}
w.SetNamer(nm)
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться: infohash уже присутствует перед add, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("пред-существующий (чужой) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (усыновление на следующем тике)", st.downloads["1"].State)
}
}
// F3, safety-critical: свежий листинг присутствия ПЕРЕД add не удался (сеть
// отвалилась между тик-снимком и проверкой). Отсутствие infohash не подтверждено
// → Add не делаем (иначе delete-с-данными стал бы небезопасен), остаёмся в
// catched. onTorrents роняет ВТОРОЙ вызов Torrents (первый — тик-снимок).
func TestProcessCatchedPresenceRecheckFailKeepsCatched(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
qb := &fakeQbt{}
calls := 0
qb.onTorrents = func() {
calls++
if calls == 2 { // тик-снимок (1) ок, свежий листинг перед add (2) падает
qb.torrentsErr = errors.New("connection refused")
}
}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 0 {
t.Errorf("Add не должен вызываться при неподтверждённом отсутствии, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("Delete не должен вызываться, calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор на следующем тике)", st.downloads["1"].State)
}
}
// F3, различение «отмена vs сбой БД»: PromoteCatched упал транзиентно, но задача
// ЖИВА (state остался catched). Наш торрент НЕ удаляем — переход доведётся на
// следующем тике усыновлением присутствующей раздачи.
func TestProcessCatchedPromoteDBErrorKeepsTorrent(t *testing.T) {
st := catchedStore("1", catchedIH, nowStr, "ctx")
st.promoteErr = errors.New("db is locked") // сбой записи перехода, state = catched
qb := &fakeQbt{}
w := newTestWorker(st, qb)
w.SetNamer(&fakeNamer{name: "X"})
w.processCatched(context.Background())
if len(qb.added) != 1 {
t.Fatalf("Add должен был вызваться, calls = %d", len(qb.added))
}
if len(qb.deleted) != 0 {
t.Errorf("при транзиентном сбое БД (задача жива) торрент удалять нельзя, Delete calls = %d", len(qb.deleted))
}
if st.downloads["1"].State != store.StateCatched {
t.Errorf("state = %q, want catched (повтор промоушена на следующем тике)", st.downloads["1"].State)
}
}