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

62 lines
3.2 KiB
Python

from unittest import TestCase
from unittest.mock import MagicMock, Mock, patch
from subprocess import TimeoutExpired
from xray_fluent.platform.windows.dns_cache import cached_addresses, read_dns_cache
from xray_fluent.network.country_resolver import CountryResolver
from xray_fluent.network.ping_worker import tcp_observation
from xray_fluent.profiles.geoip import CountryDatabase
def record(name, value, kind='A', **extra):
return dict(Entry=name, Data=value, RecordType=kind, Status=0, TimeToLive=60, **extra)
class CachedCountryTests(TestCase):
def test_aliases_ipv6_expiry_negative_and_cycles(self):
records = [record('VPN.EXAMPLE.', 'target.example.', 'CNAME'),
record('target.example', '8.8.8.8'),
record('v6.example', '2606:4700:4700::1111', 'AAAA'),
record('cycle-a', 'cycle-b', 'CNAME'), record('cycle-b', 'cycle-a', 'CNAME'),
record('local', '127.0.0.1')]
records += [dict(record('expired', '1.1.1.1'), TimeToLive=0),
dict(record('negative', '1.1.1.1'), Status=9003)]
result = cached_addresses(records)
self.assertEqual(result['vpn.example'], ('8.8.8.8',))
self.assertEqual(result['v6.example'], ('2606:4700:4700::1111',))
self.assertFalse(result['cycle-a'])
for name in ('expired', 'negative', 'local'):
self.assertNotIn(name, result)
def test_cache_timeout_is_a_local_miss(self):
with patch('xray_fluent.platform.windows.dns_cache.sys.platform', 'win32'), patch(
'xray_fluent.platform.windows.dns_cache.run_text', side_effect=TimeoutExpired('powershell', 5)):
self.assertEqual(read_dns_cache(), {})
def test_domain_country_uses_only_cache_and_mmdb(self):
database = CountryDatabase()
database._reader = Mock()
database._reader.get.return_value = {'country': {'iso_code': 'US'}}
factory = MagicMock()
factory.return_value.__enter__.return_value = database
cache = Mock(return_value={'vpn.example': ('8.8.8.8',)})
worker = CountryResolver([('node', ('vpn.example',), ('vpn.example',))],
database_factory=factory, cache_provider=cache)
observed = []
worker.resolved.connect(observed.append)
with patch('socket.getaddrinfo', side_effect=AssertionError('DNS forbidden')), patch(
'socket.socket', side_effect=AssertionError('network forbidden')):
worker.run()
self.assertEqual(observed, [{'node': (('vpn.example',), 'US')}])
cache.assert_called_once()
database.country.cache_clear()
def test_ping_reuses_peer_of_its_single_existing_connection(self):
connection = MagicMock()
connection.__enter__.return_value.getpeername.return_value = ('8.8.8.8', 443)
with patch('socket.create_connection', return_value=connection) as connect, patch(
'socket.getaddrinfo', side_effect=AssertionError('extra DNS forbidden')):
ms, addresses = tcp_observation('vpn.example', 443)
connect.assert_called_once_with(('vpn.example', 443), timeout=2.0)
self.assertGreaterEqual(ms, 0)
self.assertEqual(addresses, ('8.8.8.8',))