Отмена задачи (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>
14 KiB
Context
processCatched (internal/worker/worker.go) добавляет пойманные загрузки в
qBittorrent. Медленные вызовы (namer/LLM, qbt.Add) идут вне блокировки
переходов w.mu, под замком — только короткие DB-переходы. Последовательность
на «обычном» пути добавления (торрента в снимке тика нет):
- под
w.mure-read записи → проверкаstate == catched, актуализацияsource_type(текущий код: worker.go:447–453); - вне замка:
namer.Derive(секунды) →qbt.Add; - под
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/addReqworker перечитывает перед 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»:
[под w.mu]re-read состояния (D1);[вне w.mu]свежий листинг присутствия (D2) — сетевой вызов;[вне w.mu]qbt.Add;[под w.mu]PromoteCatched+ (при неуспехе) re-read состояния для решения об уборке (D3);[вне 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
Нет.