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, клик во время перехода не вкладывается.
171 lines
7 KiB
Python
171 lines
7 KiB
Python
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 ""
|