zapret-kvn/xray_fluent/network/speed_test_worker.py
loop-uh 18f49a1573
Some checks failed
Windows project source guards / test (push) Failing after 26s
Тест скорости узла проверяет и известные сервисы: пауза и медленный ответ видны в карточке сервера
После замера скорости через тот же временный прокси к YouTube, Discord, Telegram и ещё восьми сервисам идёт серия запросов по одному соединению. По времени до первого байта решается: быстро, медленно, с паузами, открывается не каждый адрес или не открывается. Итог хранится в узле и показан строкой «Через этот узел» в карточке сервера со словами о проблемах. Раньше узел проверялся одним байтом generate_204 и скоростью файла.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:48:35 -04:00

784 lines
35 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.

"""Воркер тестирования скорости — измеряет скорость загрузки через каждый прокси-узел."""
from __future__ import annotations
import json
import os
import random
import shutil
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from urllib.request import ProxyHandler, Request
from PyQt6.QtCore import QThread, pyqtSignal
from ..constants import (
PROXY_HOST,
HYSTERIA_PATH_DEFAULT,
SPEED_TEST_DEFAULT_URL,
SPEED_TEST_HYSTERIA_PATH,
SPEED_TEST_ROUNDS,
SPEED_TEST_TEMP_HTTP_PORT,
SPEED_TEST_TIMEOUT,
SPEED_TEST_URLS_BY_COUNTRY,
SPEED_TEST_XRAY_PATH,
)
from ..engines.hysteria.config_adapter import build_uri_client_config
from ..engines.hysteria.runtime_contract import classify_hysteria_uri
from ..importer.link_parser import xray_core_tls_problem
from .http_utils import build_opener
from ..profiles.models import Node
from ..engines.tls_fingerprint import apply_browser_fingerprint
from ..engines.xray_outbound_compat import normalize_xray_outbound
from . import probe_gate, service_check
# Обычный браузерный User-Agent: хост тестового файла не должен видеть, что с
# адреса прокси-сервера ходит именно это приложение.
SPEED_TEST_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
# Окно короче этого не считается установившимся замером.
_MIN_WINDOW_SEC = 0.5
# Адаптивный замер: скорость снимается отсчётами, и загрузка идёт, пока
# последняя секунда быстрее предыдущей больше чем на этот допуск.
_SAMPLE_SEC = 0.25
_SETTLE_STEP_SEC = 1.0
_SETTLE_TOLERANCE = 0.10
_SETTLED_SPAN_SEC = 2.0
def _bytes_at(samples: list[tuple[float, int]], moment: float) -> float:
"""Сколько байт было принято к моменту ``moment`` (линейно между отсчётами)."""
if moment <= samples[0][0]:
return float(samples[0][1])
for (t0, b0), (t1, b1) in zip(samples, samples[1:]):
if moment <= t1:
return b0 + (b1 - b0) * (moment - t0) / (t1 - t0) if t1 > t0 else float(b1)
return float(samples[-1][1])
def _span_rate(samples: list[tuple[float, int]], begin: float, end: float) -> float | None:
"""Средняя скорость на отрезке ``[begin, end]`` или ``None``, если он не покрыт."""
if len(samples) < 2 or begin < samples[0][0] - 1e-9 or end - begin < _MIN_WINDOW_SEC:
return None
return (_bytes_at(samples, end) - _bytes_at(samples, begin)) / (end - begin)
def measure_download_bps(
opener,
url: str,
*,
timeout: float,
max_bytes: int | None = None,
cancelled=lambda: False,
on_fraction=None,
partial_on_error: bool = False,
user_agent: str = SPEED_TEST_USER_AGENT,
warmup: float = 0.0,
window: float | None = None,
settle_max: float | None = None,
) -> float | None:
"""Скачать файл через ``opener`` и вернуть скорость в байтах/с.
Замер ограничен и по времени (``timeout`` — и на ответ, и на всю загрузку),
и по объёму (``max_bytes``). ``None`` — ничего не скачано или отмена.
``partial_on_error`` — обрыв/таймаут посреди загрузки считается замером по
уже пришедшим байтам (для «умной проверки»: зависающий сервер — медленный);
по умолчанию такой раунд не засчитывается.
``window`` включает оконный замер ручного теста скорости: первые
``warmup`` секунд после первого байта (разгон TCP) отбрасываются, затем
``window`` секунд считаются скоростью. Загрузка заканчивается сама, не
дожидаясь конца файла. Если объём кончился раньше разгона, скорость
считается от первого байта.
``settle_max`` делает окно адаптивным: ``window`` становится минимумом, а
загрузка продолжается, пока скорость растёт (последняя секунда быстрее
предыдущей больше чем на 10 %), но не дольше ``settle_max`` секунд от
первого байта. Результат — скорость последних двух секунд, то есть уже
установившаяся. Фиксированное окно на быстром сервере ловило сам разгон,
и число зависело от того, насколько быстро соединение разогналось в этот
раз.
Без ``window`` поведение прежнее: время идёт
от отправки запроса — так меряет «умная проверка», и обе её стороны
(текущий сервер и кандидат) обязаны мериться одинаково.
Блокирующий вызов: только из рабочего потока.
"""
req = Request(url, headers={"User-Agent": user_agent})
start = time.perf_counter()
total_bytes = 0
windowed = window is not None and window > 0
warmup = max(0.0, float(warmup)) if windowed else 0.0
first_byte_at: float | None = None
first_chunk_bytes = 0
mark_at: float | None = None
mark_bytes = 0
adaptive = windowed and settle_max is not None and settle_max > 0
samples: list[tuple[float, int]] = []
settled = False
now = start
try:
with opener.open(req, timeout=timeout) as resp:
length_header = resp.headers.get("Content-Length") or ""
try:
total_length = int(length_header)
except (TypeError, ValueError):
total_length = 0
if max_bytes:
total_length = min(total_length, max_bytes) if total_length > 0 else max_bytes
# read1 отдаёт то, что уже пришло, не дожидаясь полного чанка: на
# еле живом канале цикл всё равно доходит до проверок окна.
read = getattr(resp, "read1", None) if windowed else None
if read is None:
read = resp.read
while True:
chunk = read(64 * 1024)
if not chunk:
break
total_bytes += len(chunk)
if cancelled():
return None
now = time.perf_counter()
elapsed = now - start
if first_byte_at is None:
first_byte_at = now
first_chunk_bytes = total_bytes
since_first = now - first_byte_at
if windowed and mark_at is None and since_first >= warmup:
mark_at, mark_bytes = now, total_bytes
# Решение об остановке принимается только на свежем отсчёте:
# между отсчётами «сейчас» ушло бы вперёд, а байты — нет.
sampled = adaptive and (not samples or now - samples[-1][0] >= _SAMPLE_SEC)
if sampled:
samples.append((now, total_bytes))
if on_fraction is not None:
if windowed:
fraction = since_first / (settle_max if adaptive else warmup + window)
if total_length > 0:
fraction = max(fraction, total_bytes / total_length)
fraction = min(1.0, fraction)
elif total_length > 0:
fraction = min(1.0, total_bytes / total_length)
else:
fraction = min(1.0, elapsed / max(timeout, 0.1))
on_fraction(fraction)
if max_bytes and total_bytes >= max_bytes:
break
if adaptive:
if sampled and mark_at is not None and now - mark_at >= window:
recent = _span_rate(samples, now - _SETTLE_STEP_SEC, now)
earlier = _span_rate(
samples, now - 2 * _SETTLE_STEP_SEC, now - _SETTLE_STEP_SEC,
)
if (
recent is not None
and earlier is not None
and recent <= earlier * (1.0 + _SETTLE_TOLERANCE)
):
settled = True
break
if since_first >= settle_max or elapsed > timeout + settle_max:
break
elif windowed:
if mark_at is not None and now - mark_at >= window:
break
if elapsed > timeout + warmup + window:
break
elif elapsed > timeout:
break
except Exception:
if cancelled() or total_bytes <= 0 or not partial_on_error:
return None
now = time.perf_counter()
if total_bytes <= 0:
return None
if adaptive and samples:
# Установилась — среднее за две последние секунды; оборвалась на
# разгоне (кончился файл, лимит или время) — последняя секунда, самая
# разогнанная часть.
if samples[-1][0] < now:
samples.append((now, total_bytes))
span = _SETTLED_SPAN_SEC if settled else _SETTLE_STEP_SEC
begin = max(samples[0][0], now - span)
rate = _span_rate(samples, begin, now)
if rate is not None and rate > 0:
return rate
if windowed and first_byte_at is not None:
if mark_at is not None and now - mark_at >= _MIN_WINDOW_SEC and total_bytes > mark_bytes:
return (total_bytes - mark_bytes) / (now - mark_at)
# Окна не получилось (объём или соединение кончились раньше разгона).
# Первый чанк пришёл В момент отсчёта, а не после него, поэтому в
# числитель не входит. Совсем короткий ответ — пара чанков подряд —
# скоростью канала не является: его делим на всё время запроса.
since_first = now - first_byte_at
if since_first >= _MIN_WINDOW_SEC and total_bytes > first_chunk_bytes:
return (total_bytes - first_chunk_bytes) / since_first
elapsed = time.perf_counter() - start
if elapsed <= 0:
return None
return total_bytes / elapsed
def _same_binary(source: Path, target: Path) -> bool:
try:
src, dst = source.stat(), target.stat()
except OSError:
return False
return src.st_size == dst.st_size and abs(src.st_mtime - dst.st_mtime) < 2.0
def prepare_speed_test_xray(source: Path, target: Path = SPEED_TEST_XRAY_PATH) -> Path:
"""Путь, из которого запускать временный xray теста скорости.
Тот же бинарь, но под своим именем (жёсткая ссылка, иначе копия):
правило ``process_path → direct`` в плане sing-box TUN
(``runtime_planner._ensure_speed_test_process_direct_route``) выводит его
трафик мимо туннеля, не задевая ``xray.exe`` гибридного сайдкара. Если
источник обновился, ссылка пересоздаётся (сверка размера и mtime). Любой
сбой — запуск из исходного пути: замер важнее обхода TUN.
Блокирующий вызов: только из рабочего потока.
"""
source = Path(source)
if not source.is_file():
return source
try:
if source.resolve() == target.resolve():
return source
except OSError:
return source
if _same_binary(source, target):
return target
tmp = target.with_name(target.name + ".tmp")
try:
target.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
try:
os.link(source, tmp)
except OSError:
shutil.copy2(source, tmp)
os.replace(tmp, target)
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
return source
return target if _same_binary(source, target) else source
def _speed_test_env(source: Path) -> dict[str, str]:
"""geoip.dat/geosite.dat лежат рядом с исходным xray, а не с его ссылкой."""
env = dict(os.environ)
env.setdefault("XRAY_LOCATION_ASSET", str(Path(source).parent))
return env
# Метка места, куда в команду запуска подставляется путь к конфигу.
_CONFIG = object()
# Сколько ждать готовности временного ядра.
SPEED_TEST_CORE_START_TIMEOUT = 20.0
_CORE_POLL_SEC = 0.1
def _port_open(host: str, port: int) -> bool:
try:
with socket.create_connection((host, int(port)), timeout=0.5):
return True
except OSError:
return False
def _free_port(host: str) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind((host, 0))
return int(probe.getsockname()[1])
def _pick_port(host: str, preferred: int) -> int:
"""Предпочитаемый порт, а если он занят — любой свободный.
Порт может держать ``xray-speedtest.exe``, осиротевший после падения
приложения: новый xray тогда не стартует, а «порт открыт» выглядел бы как
готовность — и замер ушёл бы через чужой сервер.
"""
if not _port_open(host, preferred):
return int(preferred)
try:
return _free_port(host)
except OSError:
return int(preferred)
def build_speed_test_config(node: Node, http_port: int) -> dict:
"""Минимальный конфиг временного xray: один HTTP-inbound → сервер.
Правил маршрутизации нет намеренно. Весь трафик замера обязан идти через
проверяемый сервер (правила «напрямую» дали бы скорость провайдера), а без
ссылок на ``geosite:``/``geoip:`` xray не читает гео-базы и стартует за
доли секунды вместо 9–16 с на холодном диске.
"""
outbound = apply_browser_fingerprint(normalize_xray_outbound(json.loads(json.dumps(node.outbound))))
outbound["tag"] = "proxy"
return {
"log": {"loglevel": "none"},
"inbounds": [
{
"tag": "http-in",
"listen": PROXY_HOST,
"port": int(http_port),
"protocol": "http",
"settings": {},
}
],
"outbounds": [outbound],
}
def _get_speed_url(country_code: str) -> str:
"""Возвращает URL тестового файла по стране сервера с явным fallback."""
return SPEED_TEST_URLS_BY_COUNTRY.get(country_code.lower(), SPEED_TEST_DEFAULT_URL)
def is_hysteria_speed_node(node: Node) -> bool:
"""Hysteria2 из ссылки hy2:// — её меряет официальный клиент Hysteria."""
outbound = node.outbound if isinstance(node.outbound, dict) else {}
if str(outbound.get("type") or "").strip().lower() != "hysteria2":
return False
link = str(node.link or "").strip()
return link.partition(":")[0].lower() in {"hy2", "hysteria2"}
def should_skip_speed_test(node: Node) -> tuple[bool, str]:
"""Кого тест скорости не умеет мерить; пропуск мягкий, без is_alive=False.
Временное ядро — то же, которым приложение подключается к серверу: xray
для нод в формате xray, официальный клиент для Hysteria2. Остальные
native sing-box ноды (включая endpoint-ноды WireGuard/AWG) пропускаются.
"""
outbound = node.outbound if isinstance(node.outbound, dict) else {}
if not (outbound.get("type") and not outbound.get("protocol")):
# Временный xray не принимает allowInsecure без пина сертификата.
tls_problem = xray_core_tls_problem(node)
if tls_problem:
return True, f"Тест скорости для {node.name} пропущен: Xray 26.x не поддерживает allowInsecure без pcs."
return False, ""
protocol = str(outbound.get("type") or node.scheme or "native").upper()
if is_hysteria_speed_node(node):
capability = classify_hysteria_uri(str(node.link or "").strip(), platform="windows")
if capability.valid:
return False, ""
reason = capability.validation_message or "ссылка несовместима с клиентом Hysteria"
return True, f"Тест скорости для {node.name} пропущен: {reason}."
if str(outbound.get("type") or "").strip().lower() == "hysteria2":
return True, (
f"Тест скорости для {node.name} пропущен: Hysteria2 меряется только "
"по исходной ссылке hy2:// — импортируйте сервер ссылкой."
)
return True, f"Тест скорости для {node.name} пропущен: протокол {protocol} пока не измеряется."
def build_hysteria_speed_test_config(node: Node, http_port: int) -> dict:
"""Конфиг временного клиента Hysteria: локальный HTTP-прокси → сервер.
Собирается тем же адаптером, что и рабочее подключение (обфускация,
закрепление сертификата, port hopping, полоса), только вместо SOCKS-relay
с паролем — HTTP-прокси на loopback, через который идёт замер.
"""
config = build_uri_client_config(
str(node.link or "").strip(),
node.outbound if isinstance(node.outbound, dict) else {},
relay_host=PROXY_HOST,
relay_port=int(http_port),
relay_username="",
relay_password="",
)
config.pop("socks5", None)
config["http"] = {"listen": f"{PROXY_HOST}:{int(http_port)}"}
return config
class SpeedTestWorker(QThread):
"""Тестирует скорость загрузки через каждый узел с помощью временного экземпляра xray."""
result = pyqtSignal(str, object, bool) # node_id, speed_mbps (float|None), is_alive
progress = pyqtSignal(int, int) # current, total
node_progress = pyqtSignal(str, int) # node_id, percent 0..100
skipped = pyqtSignal(str, str) # node_id, короткое сообщение о пропуске
services = pyqtSignal(str, object) # node_id, {ключ сервиса: вывод} — что открывается через узел
completed = pyqtSignal()
def __init__(
self,
nodes: list[Node],
xray_path: str,
timeout: float = SPEED_TEST_TIMEOUT,
*,
rounds: int = SPEED_TEST_ROUNDS,
max_bytes: int | None = None,
url: str | None = None,
http_port: int = SPEED_TEST_TEMP_HTTP_PORT,
retries: int = 0,
warmup: float = 0.0,
window: float | None = None,
settle_max: float | None = None,
partial_on_error: bool = False,
pause_range: tuple[float, float] | None = None,
hysteria_path: str | None = None,
journal_kind: str | None = probe_gate.SWEEP,
check_services: bool = False,
):
super().__init__()
self._nodes = list(nodes)
self._xray_path = xray_path
self._hysteria_path = str(hysteria_path or HYSTERIA_PATH_DEFAULT)
self._hysteria_launch_path: str | None = None
self._timeout = timeout
# ``rounds`` — сколько удачных замеров усреднять; ``retries`` — сколько
# лишних попыток дать серверу, пока удачных меньше. Ручной тест берёт
# один оконный замер (``warmup``/``window``) и одну повторную попытку;
# «умная проверка» авто-переключения — один короткий замер без окна,
# с общим URL и своим временным портом.
self._rounds = max(1, int(rounds))
self._retries = max(0, int(retries))
self._max_bytes = max_bytes
self._url = url
self._http_port = int(http_port)
self._warmup = warmup
self._window = window
self._settle_max = settle_max
self._partial_on_error = bool(partial_on_error)
# Случайная пауза между серверами: подряд идущие объёмные потоки на
# каждый адрес списка без передышки — самый заметный след теста.
self._pause_range = pause_range
# Вид проверки для журнала шлюза; None — уже записал вызывающий.
self._journal_kind = journal_kind
# Ручной тест после скорости проверяет и известные сервисы через тот же временный прокси.
self._check_services = bool(check_services)
self._cancelled = False
self._completed_nodes = 0
self._current_proc: subprocess.Popen | None = None
self._last_percent: tuple[str, int] | None = None
def cancel(self) -> None:
"""Отмена тестирования."""
self._cancelled = True
proc = self._current_proc
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
@property
def completed_nodes(self) -> int:
return self._completed_nodes
@property
def was_cancelled(self) -> bool:
return self._cancelled
# ------------------------------------------------------------------
def run(self) -> None:
total = len(self._nodes)
self._completed_nodes = 0
self._launch_path = str(prepare_speed_test_xray(Path(self._xray_path)))
measured_any = False
try:
for node in self._nodes:
if self._cancelled:
break
skip, skip_message = should_skip_speed_test(node)
if not skip and is_hysteria_speed_node(node) and not Path(self._hysteria_path).is_file():
skip, skip_message = True, (
f"Тест скорости для {node.name} пропущен: не найдено ядро Hysteria."
)
if skip:
self._completed_nodes += 1
self.skipped.emit(node.id, skip_message)
self.progress.emit(self._completed_nodes, total)
continue
if measured_any and not self._pause():
break
measured_any = True
self._emit_percent(node.id, 0)
finished, speed, alive = self._test_node(node)
if not finished:
break
self._completed_nodes += 1
self._emit_percent(node.id, 100)
self.result.emit(node.id, speed, alive)
self.progress.emit(self._completed_nodes, total)
finally:
self.completed.emit()
# ------------------------------------------------------------------
def _emit_percent(self, node_id: str, percent: int) -> None:
"""Прогресс строки — только когда процент изменился.
Чанк 64 КБ приходит сотни раз в секунду, а процент меняется пару
десятков раз за замер: без этого фильтра очередь сигналов в GUI-поток
забивалась дубликатами.
"""
key = (node_id, int(percent))
if key == self._last_percent:
return
self._last_percent = key
self.node_progress.emit(node_id, int(percent))
def _pause(self) -> bool:
"""Пауза перед следующим сервером; ``False`` — отменили."""
if not self._pause_range:
return not self._cancelled
low, high = self._pause_range
deadline = time.monotonic() + random.uniform(low, max(low, high))
while time.monotonic() < deadline:
if self._cancelled:
return False
time.sleep(_CORE_POLL_SEC)
return not self._cancelled
def _wait_core_ready(self, proc: subprocess.Popen, node_id: str, http_port: int) -> bool:
"""Дождаться HTTP-inbound временного xray; ``False`` — не поднялся."""
started = time.monotonic()
deadline = started + SPEED_TEST_CORE_START_TIMEOUT
while not self._cancelled:
if proc.poll() is not None:
return False
if _port_open(PROXY_HOST, http_port):
# Порт мог открыть не наш процесс: свой обязан быть жив.
return proc.poll() is None
now = time.monotonic()
if now >= deadline:
return False
self._emit_percent(node_id, 2 + int(16 * (now - started) / SPEED_TEST_CORE_START_TIMEOUT))
time.sleep(_CORE_POLL_SEC)
return False
def _core_launch(self, node: Node, http_port: int):
"""(конфиг, команда, окружение, рабочий каталог) временного ядра или ``None``.
В команде на месте пути к конфигу стоит метка ``_CONFIG``.
"""
if is_hysteria_speed_node(node):
source = Path(self._hysteria_path)
if not source.is_file():
return None
if self._hysteria_launch_path is None:
self._hysteria_launch_path = str(
prepare_speed_test_xray(source, SPEED_TEST_HYSTERIA_PATH)
)
command = [
self._hysteria_launch_path,
"--config", _CONFIG,
"--disable-update-check",
"--log-level", "error",
"client",
]
return build_hysteria_speed_test_config(node, http_port), command, None, str(source.parent)
if not Path(self._xray_path).is_file():
return None
command = [getattr(self, "_launch_path", "") or self._xray_path, "run", "-c", _CONFIG]
return build_speed_test_config(node, http_port), command, _speed_test_env(Path(self._xray_path)), None
def _test_node(self, node: Node) -> tuple[bool, float | None, bool]:
"""Запускает временный xray, скачивает тестовый файл.
Возвращает кортеж (finished, speed_mbps, is_alive), где finished=False
означает ручную отмену текущего измерения и отсутствие результата для ноды.
"""
if self._journal_kind:
probe_gate.GATE.record(self._journal_kind, str(node.server or ""))
http_port = _pick_port(PROXY_HOST, self._http_port)
try:
launch = self._core_launch(node, http_port)
except Exception:
return True, None, False
if launch is None:
return True, None, False
config, command, env, cwd = launch
config_path: Path | None = None
proc = None
try:
tmp = tempfile.NamedTemporaryFile(
mode="w",
suffix=".json",
prefix="xray_speed_",
delete=False,
encoding="utf-8",
)
config_path = Path(tmp.name)
json.dump(config, tmp, ensure_ascii=True)
tmp.close()
proc = subprocess.Popen(
[part if part is not _CONFIG else str(config_path) for part in command],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
cwd=cwd,
creationflags=0x08000000, # CREATE_NO_WINDOW
)
self._current_proc = proc
ready = self._wait_core_ready(proc, node.id, http_port)
# Конфиг содержит ключи сервера: ядро его уже прочитало, на диске
# он больше не нужен.
_unlink_quietly(config_path)
config_path = None
if self._cancelled:
return False, None, False
if not ready:
return True, None, False
url = self._url or _get_speed_url(node.country_code)
attempts = self._rounds + self._retries
results: list[float] = []
for attempt in range(attempts):
if self._cancelled:
return False, None, False
s = self._measure_speed(url, node.id, http_port, attempt, attempts)
if self._cancelled:
return False, None, False
if s is not None and s > 0:
results.append(s)
if len(results) >= self._rounds:
break
if not results:
return True, None, False
# Несколько раундов: отбрасываем худший замер, берём среднее оставшихся
if len(results) > 1:
results.sort()
results = results[1:]
speed = round(sum(results) / len(results), 2)
self._check_node_services(node, http_port)
return True, speed, True
except Exception:
if self._cancelled:
return False, None, False
return True, None, False
finally:
self._current_proc = None
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
if config_path is not None:
_unlink_quietly(config_path)
def _check_node_services(self, node: Node, http_port: int) -> None:
"""Какие сервисы открываются через узел и как отвечают. Сбой проверки скорость не портит."""
if not self._check_services or self._cancelled:
return
try:
verdicts = service_check.check_all(PROXY_HOST, http_port, cancelled=lambda: self._cancelled)
except Exception:
return
if not self._cancelled:
self.services.emit(node.id, {key: verdict.as_dict() for key, verdict in verdicts.items()})
def _measure_speed(
self, url: str, node_id: str, http_port: int, attempt: int, attempts: int,
) -> float | None:
"""Скачивает тестовый файл через временный прокси, возвращает скорость в МБ/с."""
proxy_url = f"http://{PROXY_HOST}:{http_port}"
handler = ProxyHandler({"http": proxy_url, "https": proxy_url})
opener = build_opener(handler)
percent_start = 20 + int(70 * attempt / max(attempts, 1))
percent_end = 20 + int(70 * (attempt + 1) / max(attempts, 1))
def _progress(fraction: float) -> None:
percent = percent_start + int((percent_end - percent_start) * fraction)
self._emit_percent(node_id, max(percent_start, min(percent_end, percent)))
bps = measure_download_bps(
opener,
url,
timeout=self._timeout,
max_bytes=self._max_bytes,
cancelled=lambda: self._cancelled,
on_fraction=_progress,
partial_on_error=self._partial_on_error,
warmup=self._warmup,
window=self._window,
settle_max=self._settle_max,
)
if bps is None:
return None
self._emit_percent(node_id, percent_end)
return round(bps / (1024 * 1024), 2)
def _unlink_quietly(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
pass
class ActiveProxySpeedProbeWorker(QThread):
"""Короткий замер скорости ТЕКУЩЕГО сервера через работающий локальный прокси.
``http_port`` > 0 — через HTTP-inbound запущенного ядра; ``None`` в режиме
TUN — обычным соединением, которое сам TUN уводит в туннель (как у
``ConnectivityTestWorker``). Результат — байты/с или ``None``.
"""
measured = pyqtSignal(object) # bytes/sec (float) | None
def __init__(self, http_port: int | None, url: str, *, timeout: float, max_bytes: int):
super().__init__()
self._http_port = http_port
self._url = url
self._timeout = timeout
self._max_bytes = max_bytes
self._cancelled = False
def cancel(self) -> None:
self._cancelled = True
def run(self) -> None:
if self._http_port:
proxy_url = f"http://{PROXY_HOST}:{int(self._http_port)}"
opener = build_opener(ProxyHandler({"http": proxy_url, "https": proxy_url}))
else:
opener = build_opener()
bps: float | None = None
try:
bps = measure_download_bps(
opener,
self._url,
timeout=self._timeout,
max_bytes=self._max_bytes,
cancelled=lambda: self._cancelled,
partial_on_error=True,
)
finally:
if not self._cancelled:
self.measured.emit(bps)