Files
avandClaude Opus 4.8 3a00fde058 Жизненный цикл: уборка торрента при отмене во время добавления (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>
2026-07-17 22:10:21 +03:00

14 KiB
Raw Permalink Blame History

Context

processCatched (internal/worker/worker.go) добавляет пойманные загрузки в qBittorrent. Медленные вызовы (namer/LLM, qbt.Add) идут вне блокировки переходов w.mu, под замком — только короткие DB-переходы. Последовательность на «обычном» пути добавления (торрента в снимке тика нет):

  1. под w.mu re-read записи → проверка state == catched, актуализация source_type (текущий код: worker.go:447453);
  2. вне замка: namer.Derive (секунды) → qbt.Add;
  3. под w.mu: PromoteCatched (гард state='catched') → catched → downloading.

Гонка F3: отмена (catched → cancelled) приходит между шагом 1 и шагом 3. Гард PromoteCatched честно отклоняет переход (state уже cancelled), но qbt.Add на шаге 2 уже отработал — торрент добавлен под нашей категорией и остался в qBittorrent без задачи-владельца. adopt его назад не подхватит: ExistsByInfohash истинно (хеши принадлежат отменённой записи). Диск занят, владельца нет.

Goals / Non-Goals

Goals

  • Не добавлять источник, если отмена видна ещё до add.
  • Убрать добавленный нами торрент (с данными), если отмена случилась в окне после add.
  • Никогда не удалять с данными торрент, которого мы не создавали этим add.

Non-Goals

  • Не меняем FSM-граф и семантику PromoteCatched/cancel.
  • Не вводим новых состояний, полей БД, методов qbt/store.
  • Не закрываем окно гонки полностью (у qBittorrent нет транзакции «add+own»); сужаем его и гарантированно убираем последствия.
  • Не в scope: апгрейд source_type во время namer. source_type/addReq worker перечитывает перед namer (текущий код), а не в D1-re-read перед add. Пред-существующий зазор «magnet→torrent апгрейд случился во время вывода имени» этой задачей не закрывается и не ухудшается (D1 читает только состояние). D1 специально держим дешёвым (только state) и не тянем чтение байтов торрента под w.mu; закрытие зазора — отдельная задача.

Decisions

D1. Re-read состояния прямо перед qbt.Add

После namer.Derive (медленный шаг) и непосредственно перед qbt.Add worker берёт w.mu, перечитывает запись и проверяет state == catched. Если уже не catched (отменена во время namer) — add не делаем, задачу пропускаем. Это переносит основную защиту на самый частый сценарий: окно namer (секунды) куда шире окна между add и записью перехода (один сетевой вызов).

Re-read под w.mu перечитывает только состояние (дёшево, локальный SQLite). Актуализацию source_type/addReq он НЕ повторяет — см. «Границы блокировки» и «Не в scope» ниже.

D2. Проверка отсутствия infohash перед add — признак «своего» торрента

Сразу перед add (после D1, но вне w.mu — это сетевой вызов) worker свежим листингом qBittorrent подтверждает, что раздачи с любым из infohash загрузки ещё нет. Реализуется переиспользованием qbt.Torrents(ctx, "") (тот же механизм, что снимок тика) — нового API не нужно.

  • Если листинг не удался (сеть отвалилась) — worker add НЕ делает и задачу в этот тик пропускает (повтор на следующем). Это критично: без подтверждённого отсутствия «своё/чужое» неразличимо, и delete-with-data стал бы небезопасен. Поведение то же, что уже принято для листинга тика (сбой → не трогаем).
  • Если infohash уже присутствует (внешний клиент/пользователь добавил тот же торрент в окно гонки после снимка тика) — worker add НЕ делает и задачу пропускает: на следующем тике её штатно усыновит promoteExisting (ветка «уже присутствует»). Чужие данные не трогаются.
  • Если infohash отсутствует — только тогда делаем add. Тем самым любой торрент, оказавшийся под этим infohash сразу после нашего add, — наш артефакт.

Именно подтверждённое отсутствие-перед-add — механизм различения «своё/чужое». Он не завязан на семантику ответа qbt.Add (qBittorrent на дубль отвечает тем же Ok., не сообщая, создал он раздачу или это был дубль).

D3. Scoped cleanup при отмене в окне после add

Если add прошёл (D2 подтвердил отсутствие), а затем PromoteCatched не применил переход, worker принимает решение об уборке по свежему re-read состояния под w.mu, а не по тексту/факту ошибки PromoteCatched. Причина: PromoteCatched возвращает ошибку в двух разных случаях — (1) гард n==0 (state действительно уже не catched, отмена) и (2) транзиентный сбой БД (state всё ещё catched, задача жива). Вешать delete-with-data на «любую ошибку промоушена» нельзя: при миге БД это снесло бы свой же, но ещё активный торрент.

Поэтому под тем же w.mu worker перечитывает запись:

  • state != catched (подтверждённая отмена) → удаляем добавленный торрент из qBittorrent с данными: qbt.Delete(ctx, hashes, deleteFiles=true) по infohash загрузки. Delete идемпотентен (неизвестный хеш qBittorrent игнорирует). Сбой удаления — ERROR-лог, состояние задачи (cancelled) не трогаем.
  • state == catched (транзиентный сбой БД) или re-read сам упал → торрент НЕ удаляем, WARN «promote failed, will retry»: на следующем тике promoteExisting усыновит присутствующую (нашу же) раздачу — переход доведётся, ничего не потеряно.

Cleanup достижим ТОЛЬКО по пути, где D2 подтвердил отсутствие infohash перед add, — поэтому удаляемый торрент гарантированно создан этим add.

Границы блокировки (сериализация переходов)

Порядок вокруг add соблюдает инвариант «медленные/сетевые вызовы вне w.mu»:

  1. [под w.mu] re-read состояния (D1);
  2. [вне w.mu] свежий листинг присутствия (D2) — сетевой вызов;
  3. [вне w.mu] qbt.Add;
  4. [под w.mu] PromoteCatched + (при неуспехе) re-read состояния для решения об уборке (D3);
  5. [вне w.mu] qbt.Delete при подтверждённой отмене.

Между шагами 1–3 остаётся окно (описанный TOCTOU), но add защищён свежим подтверждением отсутствия (шаг 2), а любая пропажа гарантии деградирует к безопасному «не удаляем» (D3).

D4. Логи

  • WARN "torrent left in qbittorrent after cancel, removing" — вход в cleanup, поле-причина промаха PromoteCatched.
  • WARN "added torrent removed after cancel" — факт успешного удаления.
  • ERROR — если qbt.Delete не удался (мусор остался, нужен разбор).
  • D1/D2-пропуски — INFO (штатная развилка, не проблема).

Все записи несут download_id/infohash (scoped-логгер cctx), без секретов. Сам вызов qbt.Delete/Torrents логирует клиент (ext.*).

Инвариант «источник неприкосновенен» и негативная гарантия

Артикуляция решения. Инвариант «источник неприкосновенен» (CLAUDE.md, architecture.md, ADR-hardlinks) защищает пользовательские данные под paths.downloads/существующие раздачи от НАШИХ прямых fs-операций (unlink/rename). Торрент, который jellybit добавил секундами ранее — уже после намерения отмены, — это наш собственный артефакт, а не пользовательские данные. Его удаление через API qBittorrent (не прямыми fs-операциями) — легитимная уборка своего мусора. Прецедент уже есть: команда «Удалить» (delete) осознанно сносит раздачу с данными через qbt.Delete(..., true) (state-reconciliation) — там обход инварианта санкционирован пользователем; здесь удаляется лишь то, что мы сами только что создали вопреки уже выраженной отмене.

Негативная гарантия (КРИТИЧНО). Удаление-с-данными недопустимо для торрента, который присутствовал в qBittorrent ДО нашего add (пользователь уже раздавал тот же infohash / внешний торрент с тем же хешем). Удалить его с данными означало бы снести чужие данные — прямое нарушение инварианта. Защита — D2: удаление достижимо только на пути, где отсутствие infohash подтверждено непосредственно перед add; при обнаруженном присутствии add не делается вовсе, а торрент уходит на усыновление. Так удаляется исключительно созданное нами этим add.

Остаточное окно (честно). Между проверкой присутствия (D2) и самим add остаётся микроскопический TOCTOU-зазор: внешний клиент теоретически мог добавить тот же infohash в этот промежуток (два последовательных сетевых вызова). У qBittorrent нет атомарного «create-or-fail по infohash» и add не сообщает, создал он раздачу или присоединился к дублю, — устранить зазор имеющимся API нельзя. Мы сознательно выбираем add только при подтверждённом отсутствии непосредственно перед ним (зазор на порядки меньше окна namer из D1) и считаем это санкцией на уборку. Дальнейшее сужение (напр. сверка added_on раздачи со временем нашего add) — возможное усиление на будущее, в этой задаче не делаем: базис по времени хрупок (скос часов, грубое разрешение), а вероятность внешнего добавления ровно в этот под-add зазор пренебрежимо мала.

Risks / Trade-offs

  • Лишний листинг qBittorrent перед каждым add пойманной загрузки. Добавления событийны и редки (не поллинг), стоимость незначительна; переиспользуем существующий Torrents.
  • Cleanup зависит от доступности qBittorrent. Если qbt.Delete не прошёл — торрент временно остаётся, но это уже отменённая задача; ERROR-лог фиксирует для разбора. Повторной авто-уборки не вводим (не усложняем): случай редкий.

Migration Plan

Изменение чисто поведенческое в worker.processCatched. Схема БД, конфиг, API qbt/store не меняются. Откат — возврат прежней ветки добавления.

Open Questions

Нет.