zapret-kvn/tests/test_subscription_fetch_race.py
loop-uh d301e8a49a Hysteria: рабочий сервер не заменяется из-за случайной ошибки; подписки — гонка маршрутов
Инцидент 01.10.2026 (диагностика 0.8.4): простаивавшая QUIC-сессия Hysteria2
оборвалась («timeout: no recent network activity»), официальный клиент
переподключился в ту же секунду, но приложение по одной строке лога запустило
восстановление: остановило туннель, взяло первый по порядку сервер (он
отвечал «authentication error, HTTP 301») и осталось отключённым до ручного
подключения. Пользователь видел это как «интернет отваливается и не встаёт».

Hysteria
- Ошибка транспорта в логе работающего туннеля — повод для проверки, а не
  приговор (HysteriaManager._begin_tunnel_check). Сервер остаётся, если он
  снова авторизовал клиента («connected to server») или HTTPS-проба через
  relay прошла; failure публикуется только после двух проваленных волн.
  Ошибки безопасности (TLS/pin/auth/obfs) по-прежнему публикуются сразу.
- Если замена всё же понадобилась и резервный сервер отказал, он исключается
  (auth/TLS — на час, сеть — на 5 минут), а приложение возвращается на
  выбранный пользователем сервер вместо отключения. То же при отсутствии
  резервного сервера. Ручное действие отменяет отложенный возврат.
- Кандидат с is_alive=False в замену не берётся.

Подписки
- fetch_subscription: прямой маршрут и VPN-прокси идут гонкой (фора прямого
  1 с) под общим дедлайном 30 с вместо последовательных попыток со сложением
  таймаутов; чтение тела ограничено тем же сроком. Ответ сервера напрямую
  остаётся окончательным, отказ через прокси ждёт прямой маршрут 3 с.
- Статус «Обновление…» не зависает: любая ошибка применения результата
  завершает обновление; в колонке виден этап и секунды; обновления пишутся
  в лог; выход из приложения отменяет загрузку.

Потоки
- Пинг — долгоживущая служба PingService без QThread; короткие QThread-воркеры
  живут в корзине до своего finished (worker_keeper). Убирает вылет
  «QThread: Destroyed while thread is still running».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:20:34 +03:00

123 lines
4.3 KiB
Python

"""Subscription fetch races its routes under one deadline.
The direct route gets a short head start; after it the VPN proxy route runs in
parallel, so a silently blackholed subscription host costs about a second
instead of every per-operation timeout of the direct path.
"""
from __future__ import annotations
import threading
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from xray_fluent.importer import subscription_http as sh
from xray_fluent.importer.subscription_http import (
SUBSCRIPTION_PARSER_REVISION,
SubscriptionFetchError,
SubscriptionFetchResult,
SubscriptionServerResponseError,
fetch_subscription,
)
def _subscription() -> SimpleNamespace:
return SimpleNamespace(
url="https://sub.example/a",
parser_revision=SUBSCRIPTION_PARSER_REVISION,
etag="",
last_modified="",
)
class SubscriptionFetchRaceTest(unittest.TestCase):
def setUp(self) -> None:
self.release = threading.Event()
self.addCleanup(self.release.set)
patcher = patch.object(sh, "PROXY_HEAD_START", 0.05)
patcher.start()
self.addCleanup(patcher.stop)
def _hang(self) -> None:
self.release.wait(10)
raise OSError("timed out")
def test_proxy_wins_while_direct_route_hangs(self) -> None:
proxied = SubscriptionFetchResult(data=b"ok", via_proxy=True)
routes: list[bool] = []
def fake(_sub, *, via_proxy, **_kw):
if not via_proxy:
self._hang()
return proxied
started = time.monotonic()
with patch.object(sh, "_fetch_once", side_effect=fake):
result = fetch_subscription(
_subscription(), mode="auto", proxy_port=1080, on_route=routes.append
)
self.assertIs(result, proxied)
self.assertLess(time.monotonic() - started, 2)
self.assertEqual(routes, [False, True])
self.assertGreater(result.elapsed, 0)
def test_deadline_bounds_a_hanging_fetch(self) -> None:
def fake(_sub, **_kw):
self._hang()
started = time.monotonic()
with patch.object(sh, "_fetch_once", side_effect=fake):
with self.assertRaisesRegex(SubscriptionFetchError, "не ответил"):
fetch_subscription(_subscription(), mode="direct", deadline=0.2)
self.assertLess(time.monotonic() - started, 2)
def test_cancel_stops_waiting(self) -> None:
cancel = threading.Event()
def fake(_sub, **_kw):
self._hang()
threading.Timer(0.1, cancel.set).start()
started = time.monotonic()
with patch.object(sh, "_fetch_once", side_effect=fake):
with self.assertRaisesRegex(SubscriptionFetchError, "отменено"):
fetch_subscription(_subscription(), mode="direct", cancel=cancel)
self.assertLess(time.monotonic() - started, 2)
def test_direct_answer_outranks_proxy_refusal(self) -> None:
direct = SubscriptionFetchResult(data=b"ok")
def fake(_sub, *, via_proxy, **_kw):
if via_proxy:
raise SubscriptionServerResponseError("HTTP 403")
time.sleep(0.3)
return direct
with patch.object(sh, "_fetch_once", side_effect=fake):
result = fetch_subscription(_subscription(), mode="auto", proxy_port=1080)
self.assertIs(result, direct)
def test_proxy_refusal_reported_when_direct_stays_silent(self) -> None:
def fake(_sub, *, via_proxy, **_kw):
if via_proxy:
raise SubscriptionServerResponseError("HTTP 403")
self._hang()
started = time.monotonic()
with patch.object(sh, "_DIRECT_GRACE", 0.2), patch.object(
sh, "_fetch_once", side_effect=fake
):
with self.assertRaisesRegex(SubscriptionFetchError, "HTTP 403"):
fetch_subscription(_subscription(), mode="auto", proxy_port=1080)
self.assertLess(time.monotonic() - started, 2)
def test_slow_body_is_cut_at_the_deadline(self) -> None:
response = SimpleNamespace(read1=lambda _n: (time.sleep(0.05), b"x")[1])
with self.assertRaises(TimeoutError):
sh._read_body(response, 1024, time.monotonic() + 0.2)
if __name__ == "__main__":
unittest.main()