zapret-kvn/xray_fluent/application/signature_service.py
loop-uh 18096c1319 Переходы подключения целиком шагами через координатор, без зависаний GUI
connect/disconnect/reconnect/смена прокси/полная замена runtime стали
генераторами (connect_selected_steps, disconnect_current_steps,
reconnect_steps, _replace_runtime_steps, _start_singbox_runtime_plan_steps,
_apply_proxy_runtime_change_steps) и выполняются TransitionRunner'ом.
Устаревание поколения генераторы проверяют сами после каждого шага и сами
прибирают недостартовавшее; раннер их не закрывает.

Координатор:
- клик во время перехода только фиксирует желаемое состояние и поколение;
  подготовка выбранного сервера (DNS/Zapret) повторяется после завершения
  идущего перехода (_kick_transition), клики сливаются в последнее состояние;
- синхронные disconnect_current() из обработчиков убраны: остановка перед
  DNS/Zapret — шаг координатора (_run_prestop), отказ профиля Zapret и потеря
  winws2 — тихий disconnect через очередь (_request_stop), lock() — через
  очередь;
- устаревший переход не блокирует сигнатуру и не сбрасывает desired;
  процессы прерванного перехода (включая запущенные, но не готовые —
  process_alive) гасит отдельный шаг cleanup;
- горячий свитч во время полного перехода уходит в очередь.

Системный прокси: все записи WinAPI — в отдельном FIFO-потоке
(PROXY_EXECUTOR): шагами в переходах, без ожидания из обработчиков сигналов,
с дренажом очереди перед финальным восстановлением при выходе.

Аварийное отключение из обработчиков (unexpected disconnect, отказ Hysteria)
закрывает допуск kill'ом без ожидания; внутри перехода — шагами.

Повторный PUT выбора сервера после connect удалён: тот же тег
(plan.selected_outbound_tag == selector_tags[node.id]) уже закреплён при
старте runtime; инварианты пула/тегов не затронуты.

Сигнатуры переходов больше не строят полный план 7–10 раз за клик: поля
плана (теги селектора, провайдер, used_selected_node) кэшируются по
документу, режиму, ноде и составу пула; сброс по nodes_changed.

Приёмка: tests/test_app_transition_gui_stalls.py — настоящий контроллер и
QProcess с заглушкой ядра, блокирующие примитивы с реалистичными задержками;
connect, горячий и полный свитч, смена прокси, TUN туда-обратно и disconnect
дают max_stall < 50 мс, ноль processEvents, клик во время перехода не
вкладывается.
2026-09-26 13:27:04 +03:00

171 lines
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.

from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING
from ..constants import DEFAULT_HTTP_PORT, DEFAULT_SOCKS_PORT, HYSTERIA_PATH_DEFAULT
from ..engines.singbox import classify_node_for_singbox
from ..importer.link_parser import hysteria2_uri_fingerprint
if TYPE_CHECKING:
from .controller import AppController
from ..profiles.models import AppSettings, Node, RoutingSettings
@dataclass(frozen=True, slots=True)
class SingboxPlanFacts:
"""The plan fields a signature/preflight needs (never the ports)."""
selector_tags: dict[str, str]
provider_payload: object
used_selected_node: bool
def signature(payload: object) -> str:
return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
def node_identity(
controller: AppController,
node: Node | None,
settings: AppSettings | None = None,
) -> object:
"""Что именно в конфиге зависит от выбора сервера.
Ротация ничего сюда не добавляет: она переключает сервер штатным путём, а тот
сначала пробует горячий свитч по тегу пула и лишь при неудаче просит переход —
и вот тогда `selected` обязан быть частью сигнатуры, иначе переход сочтёт, что
менять нечего, и смена сервера потеряется.
"""
pool_factory = getattr(controller, "xray_outbound_pool", None)
if callable(pool_factory):
pool = pool_factory()
if node is not None and pool.contains(node.id):
return {"outbound_pool": pool.signature_payload(), "selected": node.id}
return node.id if node else None
def routing_signature(controller: AppController, routing: RoutingSettings | None = None) -> str:
routing = routing or controller.state.routing
return signature(routing.to_dict())
def system_proxy_bypass_lan(controller: AppController, settings: AppSettings | None = None) -> bool:
settings = settings or controller.state.settings
return bool(settings.system_proxy_bypass_lan)
def _singbox_runtime_signature_payload(
controller: AppController,
node: Node | None,
settings: AppSettings,
) -> dict[str, object]:
source_path, config_hash, has_proxy_outbound = controller._inspect_active_singbox_config()
planner_outcome = "native_singbox"
if has_proxy_outbound and node is not None:
planner_outcome = classify_node_for_singbox(node)
selector_pool: object | None = None
if has_proxy_outbound and node is not None and planner_outcome == "hybrid_xray_sidecar":
# Initial capture and all later comparisons must describe the same
# runtime. Previously the pool appeared in the signature only after
# _active_session existed, so a successful connect immediately looked
# stale and scheduled another full proxy-hot-swap.
pool = controller.xray_outbound_pool()
selector_pool = {"xray_sidecar": pool.signature_payload()}
if has_proxy_outbound and node is not None and planner_outcome == "native_singbox":
tun = bool(controller.is_singbox_tun_mode(settings))
facts_getter = getattr(controller, "_singbox_plan_facts", None)
if callable(facts_getter):
# Полный план (сборка outbound'ов всего пула, пробы портов) нужен
# сигнатуре только ради тегов селектора и провайдера — они
# кэшируются по документу, ноде и составу пула.
facts = facts_getter(node, tun=tun)
else:
try:
plan = (
controller._plan_runtime_singbox(node)
if tun
else controller._plan_proxy_runtime_singbox(node)
)
except ValueError:
plan = None
facts = (
SingboxPlanFacts(
selector_tags=dict(plan.selector_tags or {}),
provider_payload=plan.provider_payload,
used_selected_node=bool(plan.used_selected_node),
)
if plan is not None
else None
)
if facts is not None and facts.selector_tags and node.id in facts.selector_tags:
selector_pool = {
"tags": [[node_id, tag] for node_id, tag in sorted(facts.selector_tags.items())],
"provider": facts.provider_payload,
}
payload: dict[str, object] = {
"mode": "singbox-tun" if controller.is_singbox_tun_mode(settings) else "singbox-proxy",
"singbox_path": str(settings.singbox_path),
"config_file": str(source_path.name),
"config_hash": config_hash,
"has_proxy_outbound": has_proxy_outbound,
"planner_outcome": planner_outcome,
"node_id": node.id if has_proxy_outbound and node else None,
"node_outbound": node.outbound if has_proxy_outbound and node and selector_pool is None else None,
"selector_pool": selector_pool,
}
if planner_outcome == "hysteria_sidecar" and node is not None:
payload["hysteria_path"] = str(HYSTERIA_PATH_DEFAULT)
payload["sidecar_uri_fingerprint"] = hysteria2_uri_fingerprint(node.link)
if planner_outcome == "hybrid_xray_sidecar":
payload["xray_path"] = str(settings.xray_path)
if controller.is_singbox_proxy_mode(settings):
payload.update(
{
"proxy_engine": "singbox",
"socks_port": int(DEFAULT_SOCKS_PORT),
"http_port": int(DEFAULT_HTTP_PORT),
}
)
return payload
def transition_signature(
controller: AppController,
node: Node | None = None,
settings: AppSettings | None = None,
routing: RoutingSettings | None = None,
) -> str:
settings = settings or controller.state.settings
node = node or controller.selected_node
payload = _singbox_runtime_signature_payload(controller, node, settings)
if not settings.tun_mode:
payload.update(
proxy_enabled=bool(settings.enable_system_proxy),
proxy_bypass_lan=system_proxy_bypass_lan(controller, settings),
)
return signature(payload)
def xray_layer_signature(
controller: AppController,
node: Node | None = None,
settings: AppSettings | None = None,
routing: RoutingSettings | None = None,
) -> str:
settings = settings or controller.state.settings
return signature(_singbox_runtime_signature_payload(
controller, node or controller.selected_node, settings,
))
def tun_layer_signature(
controller: AppController,
node: Node | None = None,
settings: AppSettings | None = None,
routing: RoutingSettings | None = None,
) -> str:
settings = settings or controller.state.settings
return transition_signature(controller, node, settings) if settings.tun_mode else ""