zapret-kvn/xray_fluent/application/signature_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

186 lines
7.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 _target_node(controller: AppController) -> Node | None:
"""The server the runtime must serve next: the pending switch target if any.
A signature describes the *desired* runtime, so it follows the selection
transaction's target, not the committed (serving) server — otherwise a
pending switch looks like «nothing changed» and a hot swap becomes a full
reconnect that drops the serving connection first.
"""
target = getattr(controller, "_runtime_selected_node", None)
node = target() if callable(target) else None
return node if node is not None else controller.selected_node
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 _target_node(controller)
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),
)
elif settings.lan_proxy_enabled:
payload["lan_proxy"] = True
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 _target_node(controller), 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 ""