zapret-kvn/xray_fluent/application/connection_service.py
loop-uh 2a35bd050e
Some checks failed
Windows project source guards / test (push) Failing after 33s
Транзакция выбора сервера и единый итог перехода (архитектура вместо заплатки)
Корень ошибки из диагностики 31.10.2026 (0.8.51, Hysteria2 без UDP из МГТС) —
в механизме, а не в одном месте:

1. «Какой сервер» имел три протокола: родной пул писал selected_node_id до
   горячего свитча, Hysteria — до readiness нового sidecar, Amnezia держала
   выбор в pending. После отказа прежний runtime работал, а выбранным и
   сохранённым оставался отказавший сервер.
2. Каждый шаг перехода сам писал ошибку в статус — даже вытесненный новым
   кликом («Не удалось запустить sing-box proxy runtime»), а поздний общий
   текст затирал точную причину; фаза «error» ставилась при живом соединении.

Новые модули:
- application/server_selection.py — единственная транзакция выбора для всех
  протоколов и видов перехода: при живом подключении новый сервер — только
  цель (pending), выбранным становится в commit() готового runtime; неудача
  её сбрасывает (откатывать нечего), без подключения выбор фиксируется сразу.
- application/transition_outcome.py — шаги сообщают причину
  (_fail_transition), сильнейшая побеждает (SECURITY > CAUSE > CORE > STEP),
  публикует один раз _on_transition_runner_finished; вытесненный переход
  молчит; фаза выводится из того, что работает («Подключено» + «Соединение
  осталось на «…»»).
- diagnostics/transport_failure.py (из предыдущего коммита) даёт причину CAUSE.
- signature_service считает желаемый runtime по цели, а не по выбранному.

E2E tests/test_app_e2e_hysteria_udp_switch.py на win10: настоящие sing-box и
hysteria, рабочий сервер и UDP-«чёрная дыра». v0.8.51: 3/3 FAIL (выбран
отказавший сервер; ложная ошибка sing-box; нет причины). Новый код: 3/3 OK.
Полный набор Windows: 1831 OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:42:12 -04:00

222 lines
10 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 ctypes
import socket
from typing import TYPE_CHECKING
from ..constants import DEFAULT_XRAY_STATS_API_PORT
from ..engines.singbox.operations import (
capture_runtime_session,
start_proxy_steps as start_singbox_proxy_steps,
start_tun_steps as start_singbox_tun_steps,
)
from ..engines.singbox import SingboxRuntimePlan
from .async_steps import TransitionSteps, run_steps_blocking
if TYPE_CHECKING:
from .controller import AppController
def find_free_api_port(preferred: int | None = None, excluded: set[int] | None = None) -> int:
if preferred is None:
preferred = DEFAULT_XRAY_STATS_API_PORT
for port in range(preferred, preferred + 100):
if excluded and port in excluded:
continue
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"No free port in range {preferred}-{preferred + 100}")
def connect_selected(controller: AppController, allow_during_reconnect: bool = False) -> bool:
"""Синхронный драйвер (shutdown/тесты). Переходы — ``connect_selected_steps``."""
return bool(run_steps_blocking(connect_selected_steps(controller, allow_during_reconnect)))
def connect_selected_steps(controller: AppController, allow_during_reconnect: bool = False) -> TransitionSteps:
if controller._connecting:
return False
controller._connecting = True
generation = controller._transition_generation
try:
if controller._reconnecting and not allow_during_reconnect:
controller._set_connection_status("starting", "Переподключение...", level="info")
return False
if controller.locked:
controller._fail_transition("Приложение заблокировано. Разблокируйте для подключения.")
return False
node = controller._runtime_selected_node()
if node is None and not controller._can_connect_without_selected_node():
message = "В конфиге есть outbound tag `proxy`. Сначала выберите сервер."
controller._fail_transition(message)
return False
controller._reset_auto_switch_state(
reset_cooldown=not controller._auto_switch_transitioning,
reset_cycle=not controller._auto_switch_transitioning,
)
prev_active_core = controller._active_core
tun = controller.state.settings.tun_mode
controller._xray_api_port = 0
session_label = node.name if node else controller.get_active_singbox_config_name()
if tun:
controller._log(f"[tun] attempting TUN connect, admin={_is_admin()}")
controller._set_connection_status("starting", f"Запуск VPN: {session_label}...", level="info")
if not _is_admin():
controller._log("[tun] NOT admin — aborting")
controller._fail_transition(
"Режим TUN требует прав Администратора. Запустите приложение от имени Администратора."
)
return False
# Перед TUN принудительно убираем только НАШ системный прокси
# (или восстанавливаем бэкап); чужой прокси не отключаем.
yield from controller._proxy_steps("release_if_owned", restore_previous=True)
controller._tun_log_count = 0
result = yield from start_singbox_tun_steps(controller, node, prev_active_core=prev_active_core)
else:
result = yield from start_singbox_proxy_steps(controller, node, prev_active_core=prev_active_core)
if result is None:
return False
if generation != controller._transition_generation or not controller._desired_connected:
yield from controller._stop_active_connection_processes_steps(
disable_proxy=not controller._desired_connected
)
controller._refresh_connected_state()
return False
singbox_plan: SingboxRuntimePlan = result.plan
session_label = result.session_label
session_node = node
if not singbox_plan.used_selected_node:
session_node = None
# Выбор активного сервера уже закреплён в _start_singbox_runtime_plan_steps
# тем же тегом plan.selected_outbound_tag == selector_tags[node.id] (для
# гибрида — в Xray-сайдкаре, для нативного пула — в селекторе sing-box).
# Повторный PUT был дублем и лишним control-plane вызовом на connect.
controller._set_connection_status(
"running",
f"Подключено: {session_label}"
+ (
" (TUN, xray sidecar)"
if tun and singbox_plan is not None and singbox_plan.is_hybrid
else " (TUN, Hysteria2)"
if tun and singbox_plan is not None and singbox_plan.is_hysteria_sidecar
else " (sing-box + Xray sidecar)"
if not tun and singbox_plan is not None and singbox_plan.is_hybrid
else " (sing-box + Hysteria2)"
if not tun and singbox_plan is not None and singbox_plan.is_hysteria_sidecar
else " (sing-box extended)"
if not tun and singbox_plan is not None
else " (TUN)" if tun else ""
),
level="success",
)
capture_runtime_session(controller, singbox_plan, node, tun=tun)
if not controller._commit_pending_transport_selection(session_node):
yield from controller._handle_unexpected_disconnect_steps()
return False
controller.schedule_save()
controller._traffic_history.start_session(session_label, "singbox")
# Log redaction learns the other pool servers' IPs (hot switch targets).
controller.bypass.learn_pool_addresses()
return True
finally:
controller._connecting = False
def disconnect_current(controller: AppController, disable_proxy: bool = True, emit_status: bool = True) -> bool:
"""Синхронный драйвер (shutdown/обновление ядра). Переходы — ``disconnect_current_steps``."""
return bool(run_steps_blocking(disconnect_current_steps(controller, disable_proxy, emit_status)))
def disconnect_current_steps(
controller: AppController, disable_proxy: bool = True, emit_status: bool = True,
) -> TransitionSteps:
controller._disconnecting = True
# A pending recovery may have set switching before a runner was started.
# A user disconnect owns that state too; otherwise callbacks stay muted.
if not controller._reconnecting:
controller._switching = False
controller._hysteria_recovery_active = False
try:
controller._cleanup_connection_runtime_state(
end_traffic_session=True,
reset_auto_switch_cycle=not controller._auto_switch_transitioning,
reset_auto_switch_cooldown=not controller._reconnecting and not controller._auto_switch_transitioning,
)
active_tun = controller._active_session.tun_mode if controller._active_session is not None else controller.state.settings.tun_mode
if emit_status and active_tun:
controller.status.emit("info", "Остановка VPN...")
stopped = yield from controller._stop_active_connection_processes_steps(disable_proxy=disable_proxy)
if stopped:
controller._active_core = "singbox"
controller._clear_active_session()
was_connected, connected = controller._refresh_connected_state()
if was_connected != connected and not controller._switching:
controller.connection_changed.emit(connected)
if emit_status:
if stopped:
controller._set_connection_status("idle", "Отключено", level="info")
else:
controller._fail_transition("Не удалось корректно остановить подключение")
return stopped
finally:
controller._disconnecting = False
def reconnect(controller: AppController, reason: str) -> bool:
"""Синхронный драйвер (тесты). Переходы — ``reconnect_steps``."""
return bool(run_steps_blocking(reconnect_steps(controller, reason)))
def reconnect_steps(controller: AppController, reason: str) -> TransitionSteps:
if controller._reconnecting:
return False
controller._reconnecting = True
controller._switching = True
try:
controller._log(f"[reconnect] {reason}")
controller._set_connection_status("starting", "Переподключение...", level="info")
stopped = yield from disconnect_current_steps(controller, disable_proxy=False, emit_status=False)
if not stopped:
controller._fail_transition("Не удалось остановить предыдущий процесс")
if controller.state.settings.enable_system_proxy:
yield from controller._proxy_steps("disable", restore_previous=True)
return False
ok = yield from connect_selected_steps(controller, allow_during_reconnect=True)
if not ok and controller.state.settings.enable_system_proxy:
yield from controller._proxy_steps("disable", restore_previous=True)
return ok
finally:
controller._reconnecting = False
controller._switching = False
controller._auto_switch_transitioning = False
_, controller.connected = controller._refresh_connected_state()
controller.connection_changed.emit(controller.connected)
if controller.connected:
controller._start_metrics_worker()
else:
controller._stop_metrics_worker()
def _is_admin() -> bool:
if not hasattr(ctypes, "windll"):
return False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False