Жизненный цикл: уборка торрента при отмене во время добавления (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:
@@ -24,6 +24,7 @@ type fakeStore struct {
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
@@ -182,6 +183,9 @@ func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) er
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if f.promoteErr != nil {
|
||||
return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
|
||||
}
|
||||
if d.State != store.StateCatched {
|
||||
return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
|
||||
}
|
||||
@@ -281,8 +285,10 @@ type fakeQbt struct {
|
||||
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
|
||||
files []qbt.File
|
||||
deleted [][]string // хеши каждого вызова Delete
|
||||
deletedData []bool // deleteFiles каждого вызова Delete (параллельно deleted)
|
||||
deleteErr error
|
||||
renamed []renameCall // каждый вызов RenameTorrent (hash, name)
|
||||
renameErr error
|
||||
@@ -317,6 +323,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
if f.onAdd != nil {
|
||||
f.onAdd()
|
||||
}
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
@@ -328,11 +337,12 @@ func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
|
||||
return f.files, nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Delete(_ context.Context, hashes []string, _ bool) error {
|
||||
func (f *fakeQbt) Delete(_ context.Context, hashes []string, deleteFiles bool) error {
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
f.deleted = append(f.deleted, hashes)
|
||||
f.deletedData = append(f.deletedData, deleteFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user