yt-dlp-gui/tests/test_lifecycle.py
codex-pve addde2704a Время каждого скачивания, фавикон и разбор находок второго аудита
Времена скачиваний. Раньше хранился только агрегат, поэтому в ленте было
видно лишь «сколько раз» и «когда в последний». Добавлена таблица событий,
раскрытие по клику на счётчик и точное время рядом с относительным.

Метки округляются до минуты — и это не косметика. При нынешнем трафике
(десятки загрузок в сутки, единицы роликов) микросекундная метка
однозначно выделяет сеанс: тот, кто знает, когда человек заходил, узнал
бы, что он скачал. Уже накопленные метки огрубляются одноразовой
миграцией при старте, включая first_seen/last_seen ленты, где запись с
единственным скачиванием публиковала точную секунду. Эндпоинт событий
закрыт тем же лимитом частоты, что и остальные: он был единственным
открытым.

Просадка SQLite в 1700 раз. Прошлая починка утечки дескрипторов открывала
и закрывала соединение на каждый вызов, а в режиме WAL SQLite выполняет
контрольную точку при закрытии последнего соединения — то есть на каждой
операции. Замер: 12 операций в секунду. Доступ и так сериализован
глобальной блокировкой, поэтому теперь одно долгоживущее соединение:
20 203 операции в секунду, дескрипторов 3, прогон тестов ускорился с 90
секунд до двух.

Молчаливая подмена формата. В каскаде не хватало ступени «снять фильтр
кодека, но оставить склейку»: на сайтах с раздельными дорожками выбор
проваливался мимо склейки к муксованному 360p, и пользователь получал не
то, что заказывал, при верной подписи. Замыкающий вариант вдобавок
игнорировал ограничение высоты и отдавал 1080p на запрос 360p.

Продвинутый режим не работал на DASH-сайтах: регулярка отвергала
настоящие идентификаторы формата, в которых остаются '=', ':', '@', '~'.

Мусор от неудачных загрузок. Уборка частичных файлов стояла только в
ветке отмены, а самый частый исход в проде — сетевой сбой, 403 или
падение ffmpeg — оставлял до двух потолков размера на два часа. Он
считается в квоте, и сервис начинал отвечать «нет места» здоровым
пользователям.

Сторож размера считал байты по одному файлу, поэтому связка video+audio
давала вдвое больше потолка, а всё, что скачивалось быстрее интервала
опроса, не проверялось вовсе.

Снятие зависшей задачи не останавливало воркер: он вечно занимал слот из
трёх, уборщик сносил файлы у него из-под рук, а по завершении ссылка всё
равно уходила в ленту.

Кнопка «Скачать» залипала навсегда. Ранний выход при «Выберите формат»
стоял до try/finally, поэтому признак занятости не снимался и кнопка
становилась некликабельной до перезагрузки страницы. Регрессия предыдущего
коммита.

Карточка замирала навсегда при 404 на поток прогресса: EventSource уходит
в CLOSED и больше не переподключается, а порог попыток не достигался
никогда, поэтому сообщение о потере связи не показывалось.

Смена сортировки ленты молча терялась во время подгрузки — блокировка
«уже гружу» отбрасывала запрос, кнопка выглядела нажатой, а данные ей не
соответствовали. Заменено счётчиком поколений.

Ошибки nginx приходят HTML-страницей, и разбор JSON падал сырым
английским исключением прямо в интерфейс.

Также: init и миграция больше не роняют сервис при недоступном хранилище
(они вызываются на импорте, и исключение не давало воркеру подняться);
миграция переносит и события, иначе история оставалась осиротевшей;
чистка событий выполняется сразу при старте, а не через сутки сна, иначе
обещанные 90 дней были неправдой при частых перезапусках; UnavailableVideoError
больше не выдаётся за внутреннюю ошибку; размер страницы ленты берётся с
сервера; добавлен фавикон. Тестов 199.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
2026-09-21 18:47:10 +03:00

178 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Регрессии на гонки и живучесть, найденные аудитом.
Каждый тест здесь закрывает конкретный воспроизведённый баг, а не
гипотетический сценарий.
"""
import threading
import time
import pytest
import config
import downloader as dl
@pytest.fixture
def manager():
m = dl.DownloadManager()
yield m
def _task(m, status="finished", tid=None):
t = dl.Task(id=tid or ("a" * 32), url="https://example.com/v", title="Ролик",
fmt="best", extra={}, label="тест")
t.status = status
with m.lock:
m.tasks[t.id] = t
return t
# --- выдача файла ----------------------------------------------------------
def test_mark_served_is_atomic(manager):
"""Параллельные Range-запросы кратно завышали публичную статистику:
проверка served_at и присваивание шли по отдельности."""
t = _task(manager)
results = []
def grab():
results.append(manager.mark_served(t))
threads = [threading.Thread(target=grab) for _ in range(16)]
for th in threads:
th.start()
for th in threads:
th.join()
assert results.count(True) == 1, "первой выдачей может быть только одна"
assert results.count(False) == 15
# --- уборщик ---------------------------------------------------------------
def test_janitor_keeps_files_of_live_tasks(manager, monkeypatch):
"""Загрузка длиннее FILE_TTL теряла из-под yt-dlp уже скачанную
дорожку: страховочное удаление по mtime не смотрело на живые задачи."""
monkeypatch.setattr(config, "FILE_TTL_MINUTES", 0)
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
live = _task(manager, status="downloading", tid="b" * 32)
part = config.DOWNLOAD_DIR / f"{live.id}.f137.mp4.part"
part.write_bytes(b"data")
old = config.DOWNLOAD_DIR / "cccccccc.mp4"
old.write_bytes(b"junk")
# состарим оба файла
past = time.time() - 3600
import os
os.utime(part, (past, past))
os.utime(old, (past, past))
try:
manager._janitor_pass()
assert part.exists(), "файл живой задачи удалён"
assert not old.exists(), "чужой протухший файл должен был уйти"
finally:
part.unlink(missing_ok=True)
old.unlink(missing_ok=True)
def test_janitor_evicts_terminal_tasks(manager, monkeypatch):
monkeypatch.setattr(config, "TASK_TTL_MINUTES", 0)
t = _task(manager, status="finished", tid="d" * 32)
t.created_at = time.time() - 10
manager._janitor_pass()
assert manager.get(t.id) is None
def test_janitor_removes_stuck_task(manager, monkeypatch):
"""Задача в нетерминальном статусе не выселялась НИКОГДА: карточки
копились до TASKS_MAX, и сервис отвечал вечным «перегружен»."""
monkeypatch.setattr(config, "TASK_TTL_MINUTES", 1)
monkeypatch.setattr(config, "STUCK_TASK_SEC", 60)
t = _task(manager, status="downloading", tid="e" * 32)
t.created_at = time.time() - 10_000
manager._janitor_pass()
stuck = manager.get(t.id)
assert stuck.status == "error"
# Без флага отмены воркер продолжал работать и вечно занимал слот,
# а уборщик сносил файлы у него из-под рук.
assert stuck.cancel.is_set()
def test_janitor_keeps_gitkeep(manager, monkeypatch):
monkeypatch.setattr(config, "FILE_TTL_MINUTES", 0)
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
keep = config.DOWNLOAD_DIR / ".gitkeep"
keep.write_bytes(b"")
import os
past = time.time() - 3600
os.utime(keep, (past, past))
manager._janitor_pass()
assert keep.exists(), "маркер каталога удалён уборщиком"
# --- очередь и пул ---------------------------------------------------------
def test_queue_overflow_is_rejected(manager, monkeypatch):
"""Переполнение должно давать честный отказ сразу, а не молчаливое
ожидание в очереди.
Очередь набиваем не вручную: постоянные воркеры её тут же разбирают
(что само по себе подтверждает их живучесть). Поэтому имитируем
переполнение на самой постановке.
"""
import queue as _q
def full(_item):
raise _q.Full
monkeypatch.setattr(manager.queue, "put_nowait", full)
with pytest.raises(dl.Overloaded):
manager.create(url="https://example.com/v", fmt="best", extra={},
label="l", title="t", thumbnail=None)
# карточка не должна остаться висеть после отказа
assert manager.health()["tasks_total"] == 0
def test_tasks_cap_is_enforced(manager, monkeypatch):
monkeypatch.setattr(config, "TASKS_MAX", 1)
_task(manager, tid="f" * 32)
with pytest.raises(dl.Overloaded):
manager.create(url="https://example.com/v", fmt="best", extra={},
label="l", title="t", thumbnail=None)
def test_worker_survives_failing_task(manager):
"""Воркер умирал молча, и пул усыхал до нуля."""
m = dl.DownloadManager()
done = threading.Event()
seen = []
m.on_complete = lambda t: (seen.append(t.status), done.set())
def boom(task):
raise BaseException("внезапно") # noqa: TRY002
m._run = boom
t = dl.Task(id="g" * 32, url="https://example.com/v", title="t",
fmt="best", extra={}, label="l")
with m.lock:
m.tasks[t.id] = t
m.queue.put_nowait(t)
assert done.wait(timeout=5), "on_complete не вызван — воркер умер"
assert seen == ["error"]
# пул всё ещё разбирает очередь
done2 = threading.Event()
m.on_complete = lambda t: done2.set()
t2 = dl.Task(id="h" * 32, url="https://example.com/v", title="t",
fmt="best", extra={}, label="l")
m.queue.put_nowait(t2)
assert done2.wait(timeout=5), "после сбоя пул перестал работать"
# --- здоровье --------------------------------------------------------------
def test_health_reports_numbers(manager):
h = manager.health()
for key in ("free_disk_mb", "downloads_mb", "tasks_total",
"tasks_active", "queue_pending", "janitor_age_sec"):
assert key in h