zapret-kvn/tests/test_amnezia_ownership.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

87 lines
4.6 KiB
Python

from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from xray_fluent.application.controller import AppController
from tests.step_fakes import bridge_controller, drive
from xray_fluent.diagnostics.runtime_errors import RuntimeErrorJournal, core_failure
class AmneziaFailureOwnershipTests(unittest.TestCase):
def test_cancelled_initial_readiness_never_commits_front(self):
for stage in ("sidecar", "front", "dns"):
for disconnect in (True, False):
with self.subTest(stage=stage, disconnect=disconnect):
controller = bridge_controller(Mock())
controller._transition_generation = 1
controller._desired_connected = True
controller._active_singbox_plan = None
plan = SimpleNamespace(amnezia_sidecar=SimpleNamespace(config={}),
hysteria_sidecar=None, xray_sidecar=None, provider_payload=None,
used_selected_node=True, clash_api_port=0, is_hybrid=False,
selected_outbound_tag="", singbox_config={})
def supersede(*_args, **_kwargs):
if disconnect:
controller._desired_connected = False
else:
controller._transition_generation += 1
return True
controller._start_amnezia_manager.return_value = True
controller.singbox.start.return_value = True
controller.amnezia.verify_front_dns.return_value = True
operation = {"sidecar": controller._start_amnezia_manager,
"front": controller.singbox.start,
"dns": controller.amnezia.verify_front_dns}[stage]
operation.side_effect = supersede
self.assertFalse(drive(AppController._start_singbox_runtime_plan_steps(controller, plan)))
self.assertIsNone(controller._active_singbox_plan)
controller.amnezia.stop.assert_called_once()
if stage == "sidecar":
controller.singbox.start.assert_not_called()
else:
controller.singbox.stop.assert_called_once()
def controller(self):
controller = Mock()
controller.amnezia = object()
controller._active_session = SimpleNamespace(sidecar_kind="amnezia")
controller.runtime_errors = RuntimeErrorJournal()
controller._desired_connected = True
return controller
def test_retired_failure_is_kept_without_changing_new_session_status(self):
controller = self.controller()
event = core_failure("amnezia", "process", "old process exited", session_generation=1)
with patch("xray_fluent.application.controller.QTimer.singleShot") as timer:
AppController._on_amnezia_failure(controller, object(), event)
self.assertEqual(controller.runtime_errors.snapshot()[0].failure, event)
controller._set_connection_status.assert_not_called()
timer.assert_not_called()
def test_queued_failure_cannot_close_a_replaced_session_or_manager(self):
for replace in ("session", "manager", "disconnected"):
with self.subTest(replace=replace):
controller = self.controller()
event = core_failure("amnezia", "process", "process exited")
with patch("xray_fluent.application.controller.QTimer.singleShot") as timer:
AppController._on_amnezia_failure(controller, controller.amnezia, event)
callback = timer.call_args.args[1]
if replace == "manager":
controller.amnezia = object()
elif replace == "session":
controller._active_session = SimpleNamespace(sidecar_kind="")
else:
controller._active_session = None
callback()
controller._handle_unexpected_disconnect.assert_not_called()
self.assertTrue(controller._desired_connected)
def test_current_fatal_failure_closes_admission(self):
controller = self.controller()
with patch("xray_fluent.application.controller.QTimer.singleShot") as timer:
AppController._on_amnezia_failure(
controller, controller.amnezia, core_failure("amnezia", "process", "process exited"),
)
timer.call_args.args[1]()
controller._handle_unexpected_disconnect.assert_called_once()
self.assertFalse(controller._desired_connected)