zapret-kvn/xray_fluent/application/auto_switch_service.py
loop-uh b46ecdf886 Пинг и тест скорости: быстрее, без подвисаний и заметно тише на линии
Тест скорости
- один оконный замер вместо трёх полных скачиваний по 100 МБ: первая
  секунда (разгон TCP) отбрасывается, дальше считаются 3 с, не больше 32 МиБ;
  повтор только если замер не удался;
- временный xray получает минимальный конфиг без правил маршрутизации: не
  грузит гео-базы (9–16 с на холодном диске) и не уводит замер «напрямую»;
- готовность ядра опрашивается сразу, без обязательной секунды ожидания;
- занятый порт (осиротевший xray-speedtest.exe) заменяется свободным вместо
  ложного результата по чужому серверу;
- конфиг с ключами сервера удаляется, как только ядро его прочитало;
- прогресс строки шлётся только при смене процента, а не на каждый чанк;
- случайный порядок серверов и случайная пауза между ними;
- пропущенные серверы — одной строкой в конце, а не плашкой на каждый;
- браузерный User-Agent вместо «ZapretKVN/SpeedTest».
Методика «умной проверки» авто-переключения не изменилась.

Пинг
- серверы с одним адресом и портом меряются одним соединением;
- случайный порядок, 8 одновременных соединений вместо 16, случайная
  задержка перед каждым;
- раунд можно остановить кнопкой.

Фоновый пинг активного сервера больше не идёт каждые 3 с всю сессию: раз в
минуту, пока есть трафик, раз в 20 с в тишине и часто — только когда прошлый
пинг не прошёл или запросы уходят без ответа.

Таблица серверов при сортировке по пингу или скорости перестраивается во
время раунда раз в 1,5 с, а не трижды в секунду.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:56:34 +03:00

199 lines
8 KiB
Python

from __future__ import annotations
import time
from typing import TYPE_CHECKING
from .selection_source import SelectionSource
if TYPE_CHECKING:
from .controller import AppController
from ..profiles.models import Node
# Auto-switch leaves a server only when it is dead, never because traffic got
# slower: after a download ends, the speed naturally falls to a trickle and a
# speed threshold would read that as degradation and drop a healthy server.
# Traffic below this rate counts as "no payload flowing" for the dead-link check.
AUTO_SWITCH_IDLE_BPS = 1024.0
# Hysteria/TUIC use UDP/QUIC. A TCP connect to their server port is not a
# functional health check for the tunnel and must not be used as a dead-link
# verdict; a dead Hysteria server is handled by the Hysteria failure observer.
UDP_NATIVE_TYPES = frozenset({"hysteria", "hysteria2", "tuic", "wireguard"})
AUTO_SWITCH_WARMUP_SEC = 20.0
AUTO_SWITCH_UDP_WARMUP_SEC = 45.0
# The metrics worker TCP-pings the active node: rarely while the link is fine,
# every ~3s once a ping fails or requests go unanswered. This many seconds of
# continuously failing pings with no payload traffic mean a TCP link is dead.
AUTO_SWITCH_DEAD_LINK_SEC = 15.0
def transport_kind_for_node(node: Node | None) -> str:
"""Return the transport family used by auto-switch health policy.
Hysteria 2 and TUIC are native UDP/QUIC outbounds. Their URI still has a
server and port, but opening that port over TCP does not prove that the
actual tunnel works. Keep this small classifier local to the policy layer
so metrics and switching share the same contract.
"""
outbound = node.outbound if node is not None and isinstance(node.outbound, dict) else {}
native_type = str(outbound.get("type") or "").strip().lower()
if native_type in UDP_NATIVE_TYPES:
return "udp"
protocol = str(outbound.get("protocol") or "").strip().lower()
if protocol in UDP_NATIVE_TYPES:
return "udp"
if protocol:
stream = outbound.get("streamSettings")
stream = stream if isinstance(stream, dict) else {}
network = str(stream.get("network") or "tcp").strip().lower()
if network in {"kcp", "quic"}:
return "udp"
return "tcp"
def begin_auto_switch_warmup(controller: AppController, node: Node | None = None) -> None:
"""Start a fresh health observation window for a newly active node.
This intentionally preserves the cycle/cooldown accounting and manual hold;
it only discards samples belonging to the previous runtime generation.
"""
now = time.monotonic()
warmup = AUTO_SWITCH_UDP_WARMUP_SEC if transport_kind_for_node(node) == "udp" else AUTO_SWITCH_WARMUP_SEC
controller._auto_switch_warmup_until = now + warmup
controller._auto_switch_health_node_id = getattr(node, "id", None)
controller._auto_switch_link_down_since = 0.0
def _transition_in_progress(controller: AppController) -> bool:
"""Return true while any connection transition owns the runtime."""
return bool(
getattr(controller, "_auto_switch_transitioning", False)
or getattr(controller, "_transition_active", False)
or getattr(controller, "_transition_pending", False)
or getattr(controller, "_transition_runner", None) is not None
or getattr(controller, "_hot_switch_runner", None) is not None
or getattr(controller, "_connecting", False)
or getattr(controller, "_disconnecting", False)
)
def check_auto_switch(
controller: AppController,
down_bps: float,
link_alive: bool | None = None,
*,
traffic_valid: bool = True,
) -> None:
"""Switch away from the active server only when it is dead.
``link_alive`` is the last TCP-ping verdict for the active node:
True/False when the worker probes it, None when no probe is configured.
A slow but working server is never switched.
"""
settings = controller.state.settings
if not settings.auto_switch_enabled:
return
if not controller.connected or controller._switching or controller._reconnecting:
return
if _transition_in_progress(controller):
return
if getattr(controller, "_auto_switch_manual_hold", False):
return
if len(controller.state.nodes) < 2:
return
if controller._auto_switch_exhausted:
return
now = time.monotonic()
if now < float(getattr(controller, "_auto_switch_warmup_until", 0.0) or 0.0):
return
# A failed stats/API read is not an idle sample: never let an
# observability gap count towards a dead-link verdict.
if not traffic_valid:
controller._auto_switch_link_down_since = 0.0
return
node = getattr(controller, "selected_node", None)
# TCP reachability is intentionally not a dead-link verdict for Hysteria,
# TUIC, WireGuard, or any other UDP/QUIC transport.
if transport_kind_for_node(node) == "udp" or link_alive is not False or down_bps >= AUTO_SWITCH_IDLE_BPS:
controller._auto_switch_link_down_since = 0.0
return
if controller._auto_switch_link_down_since == 0.0:
controller._auto_switch_link_down_since = now
return
down_duration = now - controller._auto_switch_link_down_since
if down_duration < AUTO_SWITCH_DEAD_LINK_SEC:
return
if now - controller._auto_switch_last_switch < settings.auto_switch_cooldown_sec:
return
controller._auto_switch_link_down_since = 0.0
_execute_auto_switch(
controller,
now,
f"[auto-switch] active server unreachable for {down_duration:.0f}s → switching",
)
def _execute_auto_switch(controller: AppController, now: float, log_message: str) -> None:
"""Shared tail of both triggers: exhaustion guard, node pick, switch."""
max_attempts = max(1, len(controller.state.nodes) - 1)
if controller._auto_switch_cycle_attempts >= max_attempts:
controller._auto_switch_exhausted = True
controller.status.emit("warning", "Автопереключение остановлено: все серверы уже проверены")
controller._log("[auto-switch] exhausted all nodes for current session")
return
next_node = get_next_node_for_auto_switch(controller)
if not next_node:
controller._log("[auto-switch] no eligible node for current session")
return
controller._auto_switch_last_switch = now
controller._auto_switch_cycle_attempts += 1
controller._auto_switch_transitioning = True
controller._log(f"{log_message} to {next_node.name}")
controller.auto_switch_triggered.emit(next_node.name)
# П4 (AC11/AC12): единый путь переключения — set_selected_node сам делает
# selection_changed/schedule_save, пробует горячий свитч и при неудаче
# честно падает в очередь переходов. Автоматический источник сохраняет
# учёт cooldown/cycle (анти-дребезг, A6), выставленный выше.
controller.set_selected_node(next_node.id, source=SelectionSource.AUTO_SWITCH)
def get_next_node_for_auto_switch(controller: AppController) -> Node | None:
current_id = controller.state.selected_node_id
nodes = controller.state.nodes
if not nodes:
return None
candidates = [
node
for node in nodes
if node.id != current_id and node.is_alive is True and node.speed_mbps is not None and node.speed_mbps > 0
]
if candidates:
return max(candidates, key=lambda node: node.speed_mbps)
candidates = [node for node in nodes if node.id != current_id and node.is_alive is True]
if candidates:
return min(candidates, key=lambda node: node.ping_ms if node.ping_ms is not None else float("inf"))
current_idx: int | None = None
for idx, node in enumerate(nodes):
if node.id == current_id:
current_idx = idx
break
if current_idx is None:
return nodes[0]
next_idx = (current_idx + 1) % len(nodes)
if nodes[next_idx].id == current_id:
return None
return nodes[next_idx]