zapret-kvn/tests/test_hysteria_tunnel_check.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

158 lines
7 KiB
Python

"""A transport error in the log of a working tunnel is evidence, not a verdict.
Regression for 0.8.4 (2026-10-01): one "timeout: no recent network activity"
line — an idle QUIC session the official client re-established within the same
second — started recovery, replaced a healthy server with a broken one and left
the app disconnected. The manager now verifies the tunnel first; the failure
is published only when the tunnel really did not come back.
"""
from __future__ import annotations
from concurrent.futures import Future
import unittest
from unittest.mock import Mock, patch
from PyQt6.QtCore import QCoreApplication, QProcess
from xray_fluent.application.controller import AppController
from xray_fluent.engines.hysteria.manager import HysteriaManager
from xray_fluent.engines.hysteria.runtime_contract import HysteriaFailureCode
_APP = QCoreApplication.instance() or QCoreApplication([])
TIMEOUT = ('2026-10-01T18:45:45+03:00\tWARN\tSOCKS5 TCP error\t'
'{"addr": "127.0.0.1:38705", "reqAddr": "example.com:443", '
'"error": "timeout: no recent network activity"}')
RECONNECTED = ('2026-10-01T18:45:45+03:00\tINFO\tconnected to server\t'
'{"addr": "203.0.113.7:443", "udpEnabled": true, "tx": 0, "count": 2}')
AUTH_REJECTED = ('2026-10-01T18:45:46+03:00\tWARN\tSOCKS5 TCP error\t'
'{"addr": "127.0.0.1:19864", "reqAddr": "example.com:443", '
'"error": "authentication error, HTTP status code: 301"}')
class TunnelCheckTests(unittest.TestCase):
def setUp(self) -> None:
self.manager = HysteriaManager()
self.manager._begin_attempt(None, {})
self.manager._relay_probe = (11809, "u", "p")
self.manager._remote_authenticated = True
self.manager._mark_running()
self.addCleanup(self.manager.deleteLater)
self.addCleanup(self.manager._cancel_health)
self.failures: list[str] = []
self.logs: list[str] = []
self.manager.failure.connect(lambda code, _message, _generation: self.failures.append(code))
self.manager.log_received.connect(self.logs.append)
state = patch.object(
self.manager._process, "state", return_value=QProcess.ProcessState.Running
)
state.start()
self.addCleanup(state.stop)
self.futures: list[Future] = []
executor = Mock()
def submit(*_args, **_kwargs) -> Future:
future: Future = Future()
self.futures.append(future)
return future
executor.submit.side_effect = submit
pool = patch("xray_fluent.engines.hysteria.manager.ThreadPoolExecutor", return_value=executor)
pool.start()
self.addCleanup(pool.stop)
def _finish_wave(self, *, ok: bool) -> None:
for future in [item for item in self.futures if not item.done()]:
if ok:
future.set_result(None)
else:
future.set_exception(TimeoutError("timed out"))
self.manager._tunnel_check.poll()
def test_timeout_line_starts_a_check_instead_of_a_failure(self) -> None:
self.manager._emit_process_line(TIMEOUT)
self.manager._emit_process_line(TIMEOUT)
self.assertEqual(self.failures, [])
self.assertTrue(self.manager._tunnel_check.active)
self.assertEqual(self.manager.stats["tunnel_checks"], 1)
def test_reauthenticated_server_is_retained(self) -> None:
self.manager._emit_process_line(TIMEOUT)
self.manager._emit_process_line(RECONNECTED)
self.assertEqual(self.failures, [])
self.assertFalse(self.manager._tunnel_check.active)
self.assertEqual(self.manager.stats["tunnel_recoveries"], 1)
self.assertTrue(any("server retained" in line for line in self.logs))
def test_successful_probe_retains_the_server(self) -> None:
self.manager._emit_process_line(TIMEOUT)
self._finish_wave(ok=True)
self.assertEqual(self.failures, [])
self.assertFalse(self.manager._tunnel_check.active)
# The next unrelated error is verified again, not swallowed.
self.manager._emit_process_line(TIMEOUT)
self.assertTrue(self.manager._tunnel_check.active)
def test_failure_is_published_only_after_every_wave_failed(self) -> None:
self.manager._emit_process_line(TIMEOUT)
self._finish_wave(ok=False)
self.assertEqual(self.failures, [])
self.assertTrue(self.manager._tunnel_check.active)
self._finish_wave(ok=False)
self.assertEqual(self.failures, [HysteriaFailureCode.TARGET_NETWORK_TIMEOUT.value])
self.assertFalse(self.manager._tunnel_check.active)
def test_security_failure_is_never_delayed_by_a_check(self) -> None:
self.manager._emit_process_line(TIMEOUT)
self.manager._emit_process_line(AUTH_REJECTED)
self.assertEqual(self.failures, [HysteriaFailureCode.TARGET_AUTH_REJECTED.value])
class ReturnToSelectedServerTests(unittest.TestCase):
def _controller(self) -> Mock:
controller = Mock()
controller._hysteria_return_episode = 3
controller.locked = False
controller._transition_active = False
controller._transition_pending = False
controller._disconnecting = False
controller.connected = False
controller._desired_connected = False
return controller
def test_rejected_replacement_is_excluded_and_return_is_scheduled(self) -> None:
controller = Mock()
controller._pending_transport_node_id = "replacement"
controller._hysteria_cooldown_until = {}
AppController._reject_hysteria_replacement(controller, 3600.0, "replacement rejected")
self.assertIn("replacement", controller._hysteria_cooldown_until)
controller._schedule_return_to_selected.assert_called_once_with("replacement rejected")
def test_return_reconnects_the_selected_server(self) -> None:
controller = self._controller()
AppController._return_to_selected_server(controller, 3, 0)
self.assertTrue(controller._desired_connected)
controller._request_transition.assert_called_once_with("return to selected server")
def test_return_is_dropped_after_a_manual_action_or_when_connected(self) -> None:
manual = self._controller()
manual._hysteria_return_episode = 0
AppController._return_to_selected_server(manual, 3, 0)
manual._request_transition.assert_not_called()
connected = self._controller()
connected.connected = True
AppController._return_to_selected_server(connected, 3, 0)
connected._request_transition.assert_not_called()
def test_return_waits_for_the_failed_transition_to_finish(self) -> None:
controller = self._controller()
controller._transition_active = True
with patch("xray_fluent.application.controller.QTimer.singleShot") as single_shot:
AppController._return_to_selected_server(controller, 3, 0)
controller._request_transition.assert_not_called()
single_shot.assert_called_once()
if __name__ == "__main__":
unittest.main()