zapretgui/tests/test_net_resolve_deadline.py
loop-uh f88541fa6b Перестроить BlockCheck на единый планировщик проб
Проверки шли семью последовательными фазами, и один хост резолвился до пяти
раз: в preflight, в каждой TLS-пробе, в ISP-пробе и в ping-пробе. Теперь этапа
три — опорная точка с однократным резолвом, параллельная группа проб под общим
дедлайном и чистый анализ, — а массовый preflight убран: те же проверки делает
планировщик, и отдельная фаза означала только двойной коннект к каждому хосту.

Вместе с этим:

- вердикт вынесен в отдельный движок с сигнатурами, данные списков — в
  data_lists, подписи DPI для интерфейса — в ui/dpi_labels;
- удалён blockcheck2.py и файлы данных, которые он обслуживал;
- наблюдатель за зависаниями интерфейса ставится из entry.py, где живёт event
  loop, а не из post-startup задач;
- profile payload и background worker gate приведены к одному пути обновления.

Проверки: compileall, architecture_checks, 369 тестов затронутых областей.
Полный прогон — 4121 passed; падения в preset-UI (Qt/fluent на Linux),
preset_folder_actions (тест не знает про папку 1.10.0) и build_resource_layout
(состояние приватного репозитория) существовали и до этих правок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:46:08 +03:00

242 lines
9.7 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 PreflightHangRegressionTests(unittest.TestCase):
def test_check_one_domain_bounded_by_dns_timeout(self) -> None:
"""Раньше: 25с при PREFLIGHT_DNS_TIMEOUT=3 из-за shutdown(wait=True)."""
import blockcheck.preflight as preflight
with _BlackholeDNS():
started = time.monotonic()
result = preflight.check_one_domain("blackhole.test", cancelled=lambda: False)
elapsed = time.monotonic() - started
budget = preflight.PREFLIGHT_DNS_TIMEOUT + 3.0
self.assertLess(
elapsed, budget,
f"preflight домена занял {elapsed:.1f}с при лимите {budget:.1f}с",
)
self.assertIsNotNone(result.dns_result)
self.assertEqual(result.dns_result.error_code, "DNS_TIMEOUT")
def test_no_pool_is_left_waiting_on_uncancellable_dns(self) -> None:
"""finally не должен ждать задачу, которую нельзя отменить."""
import inspect
import blockcheck.preflight as preflight
source = inspect.getsource(preflight.check_one_domain)
self.assertIn("shutdown(wait=False", source)
self.assertNotIn("shutdown(wait=True", source)
self.assertNotIn("wait=not cancelled", source)
def test_cancel_stops_mass_resolution_promptly(self) -> None:
"""«Остановить» обязана срабатывать, пока сеть ещё молчит.
Массовый preflight заменён этапом резолва в планировщике проб — проверка
та же, точка входа новая.
"""
from blockcheck.runner import BlockcheckRunner
targets = [{"name": f"d{i}", "value": f"https://d{i}.test"} for i in range(20)]
runner = BlockcheckRunner(parallel=4)
threading.Timer(0.5, runner.cancel).start()
with _BlackholeDNS():
started = time.monotonic()
runner._stage_resolve(targets)
elapsed = time.monotonic() - started
self.assertLess(
elapsed, 4.0,
f"после отмены этап резолва возвращался {elapsed:.1f}с",
)
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()