zapret-kvn/xray_fluent/network/ping_service.py
loop-uh 54ac4b0251
Some checks failed
Windows project source guards / test (push) Has been cancelled
Шлюз проверок: по одному серверу с паузой, кандидат замены в бюджете
Требование незаметности: пачка соединений к разным серверам или ровный
ритм проверок выдаёт провайдеру (ТСПУ) весь список подписки.

- network/probe_gate.py — одно место с правилами: пауза прохода «все»
  10–20 с со случайным разбросом, не больше 3 автоматических проверок
  других серверов в скользящий час, журнал всех проверок для тестов.
- «Пинг всех» — одно соединение за раз (было до 8) и пауза шлюза между
  серверами; отмена будит паузу, одиночный пинг после прохода не ждёт.
- «Скорость всех» — пауза 10–20 с между серверами вместо 0,4–1,6 с.
- Умная замена мерит одного кандидата (было до 3 прямых рукопожатий с
  другими серверами каждые ~5 мин) и только в бюджете шлюза.
- Тест-список сетевых вызовов: create_connection/getaddrinfo/urlopen и т. п.
  в новом модуле роняют тест, пока не решено, по какому правилу шлюза
  идёт проверка.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:06:17 +03:00

284 lines
12 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.

"""Пинг серверов: одна долгоживущая служба вместо QThread на каждый клик.
Раньше каждый запрос создавал свой ``PingWorker(QThread)``, а ссылку на него
обнуляли в слоте его же сигнала ``completed``. Этот сигнал испускается ещё
внутри ``run()``, поэтому при частых кликах Python удалял объект потока,
который ещё не вышел из ``run()``, — Qt завершал процесс через
``qFatal("QThread: Destroyed while thread is still running")``.
Здесь потоков Qt нет совсем, а значит, нечего и уничтожить вовремя:
* замеры идут в обычных daemon-потоках, число которых ограничено; без работы
потоки сами завершаются, а на выходе приложения не держат процесс
(зависший ``getaddrinfo`` не ограничен таймаутом сокета);
* результат переходит в GUI-поток через внутренний сигнал объекта, живущего в
GUI-потоке, — Qt ставит такой вызов в очередь сам;
* повторный запрос уже измеряемого сервера ничего не запускает, а новые
серверы вливаются в текущий раунд: растёт его ``total``, и раунд кончается,
когда не осталось ни одного замера в работе;
* GUI-поток никогда ничего не ждёт.
Заметность на линии. TCP-замер — пустое соединение без данных, и веер таких
соединений на весь список за секунду выдаёт сам список серверов. Поэтому:
* серверы с одним адресом и портом меряются одним соединением, результат
получают все (в подписках один endpoint часто повторяется десятки раз);
* порядок замеров случайный, а не порядок списка;
* соединение одно за раз, а между серверами одного прохода — пауза шлюза
проверок (``probe_gate.SWEEP_PAUSE_SEC``, 10–20 с со случайным разбросом):
на проводе это отдельные подключения, а не веер;
* раунд можно отменить — оставшиеся в очереди серверы не трогаются.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
import random
import threading
import time
from typing import Callable, Iterable
from PyQt6.QtCore import QObject, pyqtSignal
from ..engines.singbox.config_builder import is_singbox_endpoint_node
from ..profiles.geoip import endpoint_hosts
from ..profiles.models import Node
from . import probe_gate
from .ping_worker import tcp_observation
MAX_PARALLEL_PINGS = 1
PING_TIMEOUT_SEC = 2.0
IDLE_THREAD_EXIT_SEC = 20.0
# Случайная задержка перед каждым соединением: размывает ровный «залп».
PING_START_JITTER_SEC = 0.12
@dataclass(frozen=True)
class PingTarget:
"""Неизменяемый снимок сервера на момент запроса."""
node_id: str
server: str
port: int
hosts: tuple[str, ...]
measurable: bool # False у endpoint-нод (UDP-only): TCP их не мерит
@classmethod
def of(cls, node: Node) -> "PingTarget":
return cls(
node_id=node.id,
server=str(node.server or ""),
port=int(node.port or 0),
hosts=endpoint_hosts(node),
measurable=not is_singbox_endpoint_node(node),
)
@property
def endpoint(self) -> tuple[str, int, bool]:
"""Куда реально идёт соединение: одинаковый endpoint — один замер."""
return self.server.strip().lower(), self.port, self.measurable
def matches(self, node: Node) -> bool:
"""Сервер не сменил адрес, пока шёл замер."""
return (
str(node.server or "") == self.server
and int(node.port or 0) == self.port
and endpoint_hosts(node) == self.hosts
)
@dataclass(frozen=True)
class PingOutcome:
target: PingTarget
ping_ms: int | None
peers: tuple[str, ...] # адрес, с которым реально установлено соединение
Probe = Callable[[PingTarget, float], tuple[int | None, tuple[str, ...]]]
def probe_target(target: PingTarget, timeout: float) -> tuple[int | None, tuple[str, ...]]:
if not target.measurable:
return None, ()
return tcp_observation(target.server, target.port, timeout)
class PingService(QObject):
"""Очередь TCP-замеров с дедупликацией; все сигналы — в GUI-потоке."""
measured = pyqtSignal(object) # PingOutcome
progress = pyqtSignal(int, int, bool) # done, total, finished
_arrived = pyqtSignal(object) # мост: поток замера → GUI-поток
def __init__(
self,
parent: QObject | None = None,
*,
probe: Probe = probe_target,
timeout: float = PING_TIMEOUT_SEC,
max_parallel: int = MAX_PARALLEL_PINGS,
idle_exit_sec: float = IDLE_THREAD_EXIT_SEC,
start_jitter_sec: float = PING_START_JITTER_SEC,
pause: Callable[[], float] = probe_gate.sweep_pause,
gate: probe_gate.ProbeGate = probe_gate.GATE,
) -> None:
super().__init__(parent)
self._probe = probe
self._timeout = timeout
self._max_parallel = max(1, int(max_parallel))
self._idle_exit_sec = idle_exit_sec
self._start_jitter_sec = max(0.0, float(start_jitter_sec))
self._pause = pause
self._gate = gate
# Общее с потоками замеров — только под замком.
self._lock = threading.Lock()
self._wakeup = threading.Condition(self._lock)
self._queue: deque[PingTarget] = deque()
self._threads = 0
self._idle = 0
self._closed = False
# Когда можно брать следующий сервер прохода (0 — сразу).
self._resume_at = 0.0
# Состояние раунда — только в GUI-потоке.
self._pending: set[str] = set()
# endpoint → все серверы, ждущие результата одного соединения.
self._waiters: dict[tuple[str, int, bool], list[PingTarget]] = {}
self._done = 0
self._total = 0
self._arrived.connect(self._on_arrived)
# ── GUI-поток ──
@property
def busy(self) -> bool:
return bool(self._pending)
def pending_ids(self) -> frozenset[str]:
return frozenset(self._pending)
def request(self, nodes: Iterable[Node]) -> int:
"""Поставить серверы в очередь; вернуть, сколько из них новых."""
fresh: list[PingTarget] = []
for node in nodes:
if node.id in self._pending:
continue
self._pending.add(node.id)
fresh.append(PingTarget.of(node))
if not fresh:
return 0
with self._lock:
closed = self._closed
if closed:
self._pending.difference_update(target.node_id for target in fresh)
return 0
leaders: list[PingTarget] = []
for target in fresh:
waiting = self._waiters.get(target.endpoint)
if waiting is None:
self._waiters[target.endpoint] = [target]
leaders.append(target)
else:
waiting.append(target)
random.shuffle(leaders)
with self._lock:
self._queue.extend(leaders)
spawn = min(self._max_parallel - self._threads, max(0, len(self._queue) - self._idle))
spawn = max(0, spawn)
self._threads += spawn
self._wakeup.notify_all()
for _ in range(spawn):
threading.Thread(target=self._run, name="ping", daemon=True).start()
self._total += len(fresh)
self.progress.emit(self._done, self._total, False)
return len(fresh)
def cancel(self) -> int:
"""Остановить раунд: серверы из очереди не измеряются.
Соединения, которые уже идут, дойдут до конца (не дольше таймаута) и
отдадут результат как обычно. Возвращает число снятых серверов.
"""
with self._lock:
dropped = list(self._queue)
self._queue.clear()
self._resume_at = 0.0
self._wakeup.notify_all()
removed = 0
for leader in dropped:
for target in self._waiters.pop(leader.endpoint, ()):
self._pending.discard(target.node_id)
removed += 1
if not removed:
return 0
self._total -= removed
finished = not self._pending
self.progress.emit(self._done, max(self._total, self._done), finished)
if finished:
self._done = self._total = 0
return removed
def close(self) -> None:
"""Выход из приложения: снять очередь; замеры в работе дойдут в никуда."""
with self._lock:
self._closed = True
self._queue.clear()
self._wakeup.notify_all()
self._pending.clear()
self._waiters.clear()
self._done = self._total = 0
def _on_arrived(self, outcome: PingOutcome) -> None:
for target in self._waiters.pop(outcome.target.endpoint, ()):
if target.node_id not in self._pending:
continue
self._pending.discard(target.node_id)
self._done += 1
self.measured.emit(PingOutcome(target, outcome.ping_ms, outcome.peers))
finished = not self._pending
self.progress.emit(self._done, self._total, finished)
if finished:
self._done = self._total = 0
# ── потоки замеров ──
def _run(self) -> None:
while True:
with self._lock:
self._idle += 1
self._wakeup.wait_for(lambda: self._queue or self._closed, timeout=self._idle_exit_sec)
self._idle -= 1
if self._closed or not self._queue:
self._threads -= 1
return
# Следующий сервер прохода — только после паузы; отмена или
# выход будят поток раньше.
while not self._closed and self._queue:
delay = self._resume_at - time.monotonic()
if delay <= 0:
break
self._wakeup.wait(delay)
if self._closed or not self._queue:
continue
sweeping = self._resume_at > 0 or len(self._queue) > 1
target = self._queue.popleft()
self._gate.record(probe_gate.SWEEP if sweeping else probe_gate.USER, target.server)
if self._start_jitter_sec:
time.sleep(random.uniform(0.0, self._start_jitter_sec))
try:
ping_ms, peers = self._probe(target, self._timeout)
except Exception:
ping_ms, peers = None, ()
with self._lock:
self._resume_at = time.monotonic() + max(0.0, self._pause()) if self._queue else 0.0
if self._closed:
continue
try:
self._arrived.emit(PingOutcome(target, ping_ms, tuple(peers)))
except RuntimeError:
return # объект службы уже разрушен (выход приложения)