zapretgui/tests/test_net_resolve_deadline.py
loop-uh a8ac7b2c8a
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Windows-сборка / Dev Dev 21.1.7.36 опубликован
Проверки сети: общая основа для запросов DNS — у каждого способа связи своя функция (обычный DNS по UDP и по TCP, шифрованный DoT на порту 853 и DoH на порту 443), запрос можно снять кнопкой «Стоп», а вместо текста ошибки возвращается вид сбоя: сервер молчит, порт закрыт, соединение оборвано, чужой сертификат, ошибка HTTP; эталонных серверов для сравнения ответов теперь четыре вместо двух и список один на BlockCheck и «Проверку домена» — если провайдер закрыл один из них (как 8.8.8.8), BlockCheck и «DNS подмена» называют его с причиной и советуют не выбирать для защищённого DNS; перехват DNS по контрольному адресу, виды адресов-заглушек и отмена запросов сведены в одно место вместо двух-трёх копий с разной логикой; адрес внутренней сети провайдера (100.64.0.0/10) в ответе DNS считается подменой; удалена старая проверка, которую никто не вызывал (preflight, isp_page_detector, ping_tester) вместе с её настройками и моделями
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:59:00 +03:00

189 lines
7.5 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.

"""Регрессии на зависание BlockCheck из-за неограниченного разрешения имён.
История бага: ``socket.getaddrinfo`` не имеет таймаута и не прерывается.
Preflight «защищался» через ``future.result(timeout=3)`` + ``future.cancel()``,
но cancel() не действует на запущенную задачу, поток пула оставался занят
навсегда, а ``pool.shutdown(wait=True)`` в finally его дожидался. На мёртвом
DNS проверка одного домена занимала не 3 секунды, а столько, сколько система
отводит на резолв; «Остановить» не работал, а незавершённые non-daemon потоки
пула не давали процессу закрыться.
"""
from __future__ import annotations
import socket
import sys
import threading
import time
import unittest
from pathlib import Path
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
class _BlackholeDNS:
"""Подменяет getaddrinfo на вечно висящий вызов — как мёртвый DNS-сервер."""
def __init__(self, delay: float = 600.0) -> None:
self._delay = delay
self._original = None
self.released = threading.Event()
def __enter__(self):
import utils.net_resolve as net_resolve
self._original = socket.getaddrinfo
def _hang(*_args, **_kwargs):
# Не time.sleep: тест не должен ждать реального завершения потока.
self.released.wait(self._delay)
raise socket.gaierror(socket.EAI_NONAME, "blackhole")
socket.getaddrinfo = _hang
net_resolve.clear_cache()
return self
def __exit__(self, *_exc):
socket.getaddrinfo = self._original
self.released.set()
import utils.net_resolve as net_resolve
net_resolve.clear_cache()
return False
class NetResolveDeadlineTests(unittest.TestCase):
def test_resolve_respects_deadline_on_dead_dns(self) -> None:
from utils.net_resolve import DNSTimeoutError, resolve_addrinfo
with _BlackholeDNS():
started = time.monotonic()
with self.assertRaises(DNSTimeoutError):
resolve_addrinfo("blackhole.test", 443, timeout=1.0)
elapsed = time.monotonic() - started
self.assertLess(
elapsed, 3.0,
f"резолв должен уложиться в дедлайн, а занял {elapsed:.1f}с",
)
def test_hung_resolution_runs_on_daemon_thread(self) -> None:
"""Залипший резолв не должен мешать процессу завершиться.
Потоки ``ThreadPoolExecutor`` не демонические, и concurrent.futures
join-ит их при выходе из интерпретатора — из-за этого приложение
«просто зависало» при закрытии.
"""
from utils.net_resolve import DNSTimeoutError, resolve_addrinfo
with _BlackholeDNS() as blackhole:
with self.assertRaises(DNSTimeoutError):
resolve_addrinfo("daemon-check.test", 443, timeout=0.5)
hung = [
t for t in threading.enumerate()
if t.name.startswith("dns-resolve") and t.is_alive()
]
self.assertTrue(hung, "ожидался хотя бы один незавершённый резолв-поток")
for thread in hung:
self.assertTrue(
thread.daemon,
f"поток {thread.name} не демонический — заблокирует выход из процесса",
)
blackhole.released.set()
def test_nxdomain_is_not_reported_as_timeout(self) -> None:
"""Отказ DNS и молчание DNS — разные диагнозы, их нельзя смешивать."""
import utils.net_resolve as net_resolve
from utils.net_resolve import DNSTimeoutError, resolve_addrinfo
original = socket.getaddrinfo
socket.getaddrinfo = lambda *a, **k: (_ for _ in ()).throw(
socket.gaierror(socket.EAI_NONAME, "no such host")
)
net_resolve.clear_cache()
try:
with self.assertRaises(socket.gaierror):
resolve_addrinfo("nxdomain.test", 443, timeout=5.0)
self.assertFalse(
issubclass(DNSTimeoutError, socket.gaierror),
"DNSTimeoutError не должен выдавать себя за отсутствующий домен",
)
finally:
socket.getaddrinfo = original
net_resolve.clear_cache()
def test_concurrent_requests_share_one_lookup(self) -> None:
"""Одинаковые параллельные запросы не должны плодить резолвы."""
import utils.net_resolve as net_resolve
from utils.net_resolve import resolve_addrinfo
calls = []
original = socket.getaddrinfo
def _counting(*args, **kwargs):
calls.append(args)
time.sleep(0.3)
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.1", 443))]
socket.getaddrinfo = _counting
net_resolve.clear_cache()
try:
results = []
threads = [
threading.Thread(
target=lambda: results.append(
resolve_addrinfo("shared.test", 443, timeout=5.0)
)
)
for _ in range(4)
]
for t in threads:
t.start()
for t in threads:
t.join(10)
self.assertEqual(len(results), 4)
self.assertEqual(len(calls), 1, "ожидался один резолв на четыре запроса")
finally:
socket.getaddrinfo = original
net_resolve.clear_cache()
class NoUnboundedResolutionTests(unittest.TestCase):
"""Статический guard: блокирующий резолв не должен вернуться в код."""
ALLOWED = {
Path("src/utils/net_resolve.py"),
}
def test_no_direct_blocking_resolution_calls(self) -> None:
root = Path(__file__).resolve().parents[1]
offenders: list[str] = []
for path in sorted((root / "src").rglob("*.py")):
if any(part in {".claude", "__pycache__"} for part in path.parts):
continue
if path.relative_to(root) in self.ALLOWED:
continue
for lineno, line in enumerate(
path.read_text(encoding="utf-8-sig").splitlines(), start=1
):
code = line.split("#", 1)[0]
if "socket.getaddrinfo(" in code or "socket.gethostbyname(" in code:
offenders.append(f"{path.relative_to(root)}:{lineno}: {line.strip()}")
self.assertEqual(
offenders, [],
"разрешение имён без таймаута снова подвесит приложение; "
"используйте utils.net_resolve:\n" + "\n".join(offenders),
)
if __name__ == "__main__":
unittest.main()