Skip to content

fix: contain downloads to their own directory, keep partial results (#27) - #28

Merged
axisrow merged 3 commits into
mainfrom
ao/wordstat-14/issue-27-download-path-leak
Aug 21, 2026
Merged

axisrow merged 3 commits into
mainfrom
ao/wordstat-14/issue-27-download-path-leak

Conversation

@axisrow

@axisrow axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Fixes #27.

Дефект 1 — скачанный CSV мог уйти мимо downloads_path

session.downloaded_files (browser-use) — журнал загрузок за всю CDP-сессию,
не ограниченный downloads_path. _resolved_file_snapshot раньше объединял
его вслепую с содержимым каталога, так что "сбежавший" путь (например под
реальным ~/Downloads) детектировался как легитимная новая загрузка и
передавался в finalize_raw, где Path.replace/unlink либо падал
(Errno 1 Operation not permitted, TCC на macOS), либо (без --keep-raw)
тихо удалил бы чужой файл.

Правки:

  • _resolved_file_snapshot теперь допускает только пути, реально
    резолвящиеся внутрь downloads_path.
  • _download_current_view детектирует "сбежавший" путь и кидает новый
    DownloadEscapedError, ничего не трогая на диске.
  • finalize_raw получил независимую вторую проверку (belt-and-suspenders)
    против run_directory/output_root.
  • Кросс-файловая перестраховка: finalize_raw и rescue-блок в
    _collect_one используют shutil.move вместо Path.replace
    (os.rename падает между файловыми системами).

Root cause (живая диагностика, CDP :9223): экспортные ссылки dynamics
и regions структурно идентичны (blob: + target=_self), и несколько
полных 4-видовых живых прогонов (одна фраза и батч из двух, --output-dir
и внутри репозитория, и в scratchpad вне его) прошли чисто — эскейп не
воспроизвёлся детерминированно. Это нестабильная гонка на стороне Chrome,
не привязанная к конкретному виду и не следствие того, как настроен
downloads_path в этом коде — поэтому фикс не пытается "починить" саму
гонку, а делает код устойчивым к её последствиям.

Дефект 2 — сбой одного вида обнулял всю фразу

_collect_one теперь ловит ошибку одного вида (кроме
AuthenticationRequiredError — та по-прежнему прерывает весь батч, сессия
целиком мертва) и возвращает частичный CollectionResult вместо исключения.
Новое поле view_errors объясняет причину каждого пропущенного вида,
включая явное "не пробовался: сбой на виде X" для видов, следующих за тем,
что упал (цикл видов останавливается — состояние DOM после сбоя
непредсказуемо). Фраза, где не собрано вообще ничего, по-прежнему кидает
исключение — иначе CLI засчитал бы полностью провалившуюся фразу как
результат.

CLI больше не завязан на manifest.status (он "incomplete" всегда, когда
empty_views непуст — а top_popular/top_related пустые на каждом живом
прогоне, issue #22/#25) — критерий теперь missing_views + view_errors.

Тесты

pytest: 159 passed, ~0.5s (было 151 на main) — без регрессии (#23).
ruff check .: чисто.

Mutmut 3.x не смог корректно резолвить src-layout пакет в этой песочнице
(конфликт с editable-инсталлом соседнего чекаута) — вместо автоматического
прогона мутации внесены и откачены вручную для ключевых веток
(finalize_raw's containment guard, _collect_one's
"ничего не собрано → raise" guard, AuthenticationRequiredError re-raise) —
каждая убита релевантным тестом.

Живая верификация (CDP :9223, порт 9222 не трогался)

  • --granularity daily (--date-from 2026-06-23 --date-to 2026-08-20),
    --keep-raw, --output-dir вне репозитория — все 4 вида собраны,
    regions.parquet: 934 строки.
  • То же с --output-dir ./wordstat-output (внутри репозитория, как в
    исходном issue) — все 4 вида.
  • --granularity weekly, без --keep-raw — все 4 вида, только .parquet
    на диске (никаких временных .csv).
  • --granularity monthly (по умолчанию), --keep-raw — все 4 вида.
  • Батч из двух фраз (--granularity daily, --keep-raw) — оба manifest.json
    с missing_views: [].
  • ~/Downloads не трогался ни разу (не удалялся и не переносился) — во всех
    прогонах не появилось ни одного эскейпнутого пути; сам список ~/Downloads
    проверить ls не удалось (TCC блокирует доступ из этой сессии), поэтому
    утверждение ограничено «код ни разу не получил путь вне downloads_path
    и, соответственно, finalize_raw не тронул ничего постороннее».

Эскейп-сценарий (файл вне downloads_path) воспроизведён и проверен только
юнит-тестом с фейковой session — живой прогон его не поймал (см. root cause
выше про нестабильность гонки).

PR не мержится.

)

Two defects, both from live evidence (CDP :9223, daily/weekly/monthly,
single-phrase and 2-phrase batch runs — see PR body for manifests):

1. session.downloaded_files (browser-use) can report a download at a path
   outside the collector's own downloads_path — observed live under the
   real ~/Downloads. finalize_raw then tried to replace()/unlink() that
   path, either crashing on macOS's TCC-protected ~/Downloads (Errno 1) or,
   worse, silently deleting a file it doesn't own.

   _resolved_file_snapshot now only admits downloaded_files entries that
   resolve inside downloads_path; _download_current_view detects an escaped
   path and raises a new DownloadEscapedError instead of ever touching it.
   finalize_raw gets a second, independent containment check (belt and
   suspenders) against run_directory/output_root. Cross-filesystem moves
   (finalize_raw, and _collect_one's own CSV rescue block) now use
   shutil.move instead of Path.replace (bare os.rename), which raises
   OSError across filesystems.

   Root-caused live: DYNAMICS and REGIONS use structurally identical
   blob:/target=_self export links, and several full 4-view live runs
   (in-repo and outside --output-dir, single phrase and batch) all
   completed cleanly — the escape is an intermittent Chrome-side race, not
   tied to a specific view or to how downloads_path is configured. Treating
   it as a loud, contained failure is the correct fix, not a workaround for
   something fixable upstream.

2. A single failing view (e.g. the above race on regions) used to fail the
   whole phrase, discarding views already collected and printing "Собрано
   0 из 1" even when 3 of 4 were on disk with a valid manifest.

   _collect_one now catches a per-view failure (except
   AuthenticationRequiredError, which still aborts the whole batch — the
   session itself is gone) and returns a partial CollectionResult instead
   of raising, with a new view_errors field recording why each missing view
   is missing (including "не пробовался" for views skipped after an
   earlier one failed, not just the one that actually errored). A phrase
   where nothing at all was collected still raises, so the CLI can't count
   a fully failed phrase as a result. CLI output/exit code now key off
   missing_views + view_errors, not manifest.status (status is
   "incomplete" whenever top_popular/top_related are empty, which is every
   live run — see issue #22/#25 — so keying on it would fail every
   successful run again).

Tests: 159 passed (was 151 on main), ~0.5s warm — no regression (#23).
ruff clean. Manual mutation checks on finalize_raw's containment guard and
_collect_one's view_errors guards/break path (mutmut 3.x would not resolve
this project's src-layout package correctly in this sandbox — each mutant
was applied and reverted by hand instead, confirmed killed by the relevant
test each time).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTU6JGjjt2cZmH5ojqR9w4
@axisrow

axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — round 709c8db2-a74d-4202-9aee-13a7d01afba7

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
SKIP claude Дефект: контейнмент-проверка finalize_raw полагается на предварительное существование файла и теоретически не покрывает гонку появления файла после проверки — в текущем графе вызовов недостижимо, но стоит упрочнить бесплатно. src/wordstat/storage.py (finalize_raw)
FIX claude Дефект: если легитимный CSV и посторонний "сбежавший" путь появляются в одном окне поллинга, сбежавший файл не детектируется как эскейп ни в этом, ни в следующих вызовах — предупреждение оператору теряется навсегда (сам файл остаётся нетронутым). src/wordstat/collector.py (_download_current_view)
SKIP + FIX (adjacent) claude Заявленный дефект (missing_views без записи в view_errors) недостижим в текущем коде. Но при триаже найден смежный реальный баг: если parquet вручную удалён и повторный сбор вида на resume падает, missing_views остаётся пустым (вычисляется только из exports), и CLI репортит полный успех при отсутствующем parquet. src/wordstat/cli.py + collector.py

Codex companion: verdict approve, 0 findings (не смог локально прогнать pytest из-за прав на uv-кэш; отдельно подтверждено 159 passed в моём окружении).

Cycle-review follow-up to PR #28 (issue #27):

1. _download_current_view now checks new_escaped on every poll tick,
   including the one that finds the legitimate CSV, not only inside the
   "nothing found yet" branch. session.downloaded_files is session-lifetime
   and append-only, so a stray escaped path masked by a same-tick success
   was never merely deferred to the next call — it was permanently absorbed
   into that call's before_escaped baseline and never surfaced again. The
   view's own successful download still returns normally (its data is
   fine); the escape is now reported as a non-fatal warning
   (CollectionResult.escaped_download_warnings) instead of being silently
   lost.

2. cli.py's partial-report gate now unions manifest.missing_views with
   view_errors instead of keying off missing_views alone. missing_views is
   a computed field over manifest.exports only; under --resume-dir a view
   can retain a stale export entry (its parquet manually deleted, its
   re-collection attempted and failed) — view_errors gets an entry for it
   but missing_views stays empty, so the CLI was reporting a run with a
   missing parquet as a full success at exit 0. This is the same
   "врёт о фактическом результате" failure mode issue #27 was about, from
   the opposite direction.

Both were found and verified during this PR's local cycle-review (built-in
/review + Codex companion, verdicts posted to the PR). Codex: approve, 0
findings. /review flagged a third, unreachable-in-practice TOCTOU gap in
finalize_raw's containment guard — left as SKIP (documented in the PR
comment), not worth the complexity for a path that cannot occur in the
current call graph.

Tests: 163 passed (was 159 before this commit) — mutation-verified by
manually reverting each fix and confirming the new guard test fails for
the right reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTU6JGjjt2cZmH5ojqR9w4
@axisrow

axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2) — round f47c0909-c3bc-4397-b194-8ce840a13cf6

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex Подтверждён серьёзный дефект: при resume, если parquet вручную удалён и повторный сбор вида падает, устаревшая запись экспорта оставалась в manifest.exports, из-за чего missing_views ложно не показывал отсутствие данных. Исправлено: устаревшая запись удаляется из манифеста до повторной попытки сбора. src/wordstat/collector.py (resume path)
SKIP claude Дублирование текста сообщения в двух местах одной функции — чисто косметическая избыточность без влияния на корректность или производительность. src/wordstat/collector.py (_download_current_view)
IRRELEVANT claude Гипотетический сценарий (rescue-блок мог бы переместить сбежавший файл, если бы инвариант когда-нибудь нарушился) — на текущем коде недостижим, защитный код на будущее, не баг. src/wordstat/collector.py (rescue block)
SKIP claude Повторная проверка прошлой находки про fallback-сообщение "не собран" — подтверждено отсутствие пробела в покрытии view_errors/missing_views union. src/wordstat/cli.py
HALLUCINATION claude Утверждение о разнице в поведении shutil.move vs Path.replace при существующем destination-файле не подтвердилось: оба метода на POSIX молча заменяют существующий файл (семантика rename(2)) — эмпирически проверено. src/wordstat/storage.py (finalize_raw)

Cycle-review round 2 follow-up (Codex companion, high confidence):
views_to_collect() re-attempts a view whose <view>.parquet is missing
from disk even though manifest.exports still has a stale ExportSummary
for it (e.g. the file was manually deleted after a prior run). If that
re-attempt then failed too, the stale export entry was never removed —
missing_views (a computed field over exports only) fell back to falsely
reporting nothing missing for that view, letting a programmatic manifest
consumer trust a "this view is fine" signal for a file that doesn't exist.

Root-fixed by pruning any stale export entries for pending_views up front,
before the retry loop runs — not only after a failed retry — so a
crash/Ctrl-C mid-retry also leaves an honest manifest, consistent with
this method's existing "write after every step" invariant.

Other round-2 findings triaged as SKIP/IRRELEVANT/HALLUCINATION (posted to
the PR): a cosmetic duplicate string build, a defensive-only rescue-block
concern with no live bug, and a false claim about shutil.move vs
Path.replace overwrite semantics (both silently replace an existing
destination on POSIX — verified empirically).

Tests: 164 passed (was 163) — mutation-verified by reverting the prune
and confirming the new resume test fails for the right reason (status
stayed "complete" with an empty missing_views despite the deleted
parquet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTU6JGjjt2cZmH5ojqR9w4
@axisrow

axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 3, final) — round 9d343843-6b5d-476b-9181-28078ea3a597

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
HALLUCINATION codex Заявленный UnboundLocalError в rescue-блоке опровергнут: присваивание переменной физически предшествует единственному пути к этому коду, существующий тест уже покрывает этот сценарий и проходит. src/wordstat/collector.py
SKIP claude Exit code 0 при частичном сборе — намеренное, дважды закреплённое тестами поведение этого PR (частичный успех сигнализируется через stderr, а не через код возврата); валидный product feedback для отдельного follow-up, не баг данного цикла. src/wordstat/cli.py
SKIP claude Гипотетическая гонка вокруг ещё не существующего на момент проверки эскейпнутого файла — тот же недостижимый в текущем call graph паттерн, что и в прошлых раундах. src/wordstat/collector.py
SKIP claude Механически верное наблюдение про shutil.move в существующую директорию-цель, но требует внешней порчи run-каталога — ничего в кодовой базе не создаёт такую директорию в штатном потоке. src/wordstat/storage.py
SKIP claude Заметка о хрупкости порядка (fragility note), не находка по сути — подтверждено, что порядок гарантирован enum-итерацией. src/wordstat/collector.py

Итог: 0 FIX в 3-м (финальном) цикле — дальнейшие циклы не требуются. 164 теста зелёных, ruff check . чист.

@axisrow

axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Локальное ревью (/review + Codex companion), 3 цикла, GitHub-боты не привлекались.

Cycle Reviewer Finding Verdict Resolution
1 claude Скачанный файл вне downloads_path (эскейп в тот же тик, что и легитимная загрузка) не детектировался и терялся навсегда из-за append-only природы списка загрузок сессии FIX Fixed in 29da2f3
1 claude При resume с вручную удалённым parquet устаревшая запись экспорта оставалась в манифесте, и missing_views ложно не показывал отсутствие данных (первое обнаружение) SKIP+FIX(adjacent) CLI-патч в 29da2f3, корневой фикс в cycle 2
2 codex При resume с вручную удалённым parquet устаревшая запись экспорта оставалась в манифесте, и missing_views ложно не показывал отсутствие данных (корневая причина) FIX Fixed in 4341fa4
1 claude Гипотетическая TOCTOU-гонка в containment guard finalize_raw, недостижимая в текущем графе вызовов SKIP Недостижимо, документировано
1 claude Дублирование в сообщении об ошибке между CLI и view_errors, недостижимо в текущем коде SKIP Недостижимо, документировано
2 claude Дублирование текста сообщения (cosmetic) SKIP Не критично
2 claude Rescue-блок hypothetical IRRELEVANT Защитный код
2 claude Ложное утверждение о разнице поведения shutil.move vs Path.replace при существующем destination-файле HALLUCINATION Опровергнуто эмпирически
3 codex Ложное утверждение об UnboundLocalError в rescue-блоке — структура вложенных try/except делает это физически недостижимым HALLUCINATION Опровергнуто анализом кода + тестом
3 claude Exit code 0 при частичном сборе — намеренный, дважды протестированный дизайн (мягкий сигнал через stderr) SKIP Намеренный дизайн
3 claude TOCTOU в escape detection SKIP Недостижимо
3 claude shutil.move в directory-target SKIP Нереалистичный сценарий
3 claude Fragility note (views order) SKIP Подтверждён enum order

Итого: 2 FIX (оба устранены), 7 SKIP, 1 IRRELEVANT, 2 HALLUCINATION, 1 APPROVE (Codex round 1). 164/164 теста зелёных, ruff check . чист. 3 цикла ревью завершены без открытых находок — PR готов к финальной проверке (живая верификация day/week/month грануляций следующим шагом).

@axisrow

axisrow commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

✅ Живая верификация (CDP :9223)

После завершения 3 циклов ревью проведена обязательная живая проверка всех грануляций против исправленного кода (4341fa4):

Прогон Грануляция Флаги Результат
1 monthly --keep-raw missing_views: [], dynamics.parquet: 24 строки, regions.parquet: 934 строки
2 weekly (без --keep-raw) missing_views: [], dynamics_weekly.parquet: 107 строк, regions.parquet: 934 строки, на диске только .parquet
3 daily --date-from 2026-06-23 --date-to 2026-08-20, --keep-raw missing_views: [], dynamics_daily.parquet: 58 строк, regions.parquet: 934 строки
4 resume вручную удалён regions.parquet из прогона №1, затем --resume-dir пересобран только отсутствующий вид, regions.parquet восстановлен (934 строки), missing_views: []

top_popular/top_related — ожидаемо row_count: 0 на каждом прогоне (issue #22/#25, задокументированное поведение живого Wordstat, не регрессия).

Все .parquet файлы прочитаны через pyarrow.parquet.read_table и содержат ожидаемое количество строк — не только присутствие файла проверено, но и фактическая запись данных.

Прогон №4 — целевая проверка round-2 фикса (4341fa4, prune stale export при resume) вне юнит-тестов: collector.py не покрыт unit-тестами by design, так что это первое реальное исполнение изменённого resume-пути.

164/164 теста зелёных, ruff check . чист, CI (lint) зелёный.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant