zapret-kvn/xray_fluent/application/async_steps.py
loop-uh a4330f1e44 Xray: один несобираемый сервер больше не валит запуск всего пула
Диагностика 0.8.41: Xray 26.x отвергает kcpSettings с ключом header (даже {"type":"none"}),
а в одном конфиге пула лежат все ~650 серверов, поэтому один такой узел останавливал запуск
ядра для всех, а переключение откатывалось.

- engines/xray_outbound_compat: header none убирается без потерь, srtp/utp/… и seed — узел
  исключается из пула с причиной; парсер ссылок не пишет пустой header
- XrayManager.start_steps: если ядро назвало тег, на котором упал разбор конфига, outbound
  убирается и запуск повторяется (до 6 раз), ошибка показывается один раз и только если
  попытки кончились; проверено на настоящем Xray (0,41 с)
- контроллер: карантин отвергнутых серверов до замены их outbound, ошибка с именем, если
  отвергнут выбранный сервер
- предупреждения Xray «feature … is deprecated» не сбой: одна строка и счётчик повторов
- сводка [perf] перехода называет места, где GUI-поток занят дольше 100 мс
- пул целиком не урезается: замер показал ~30 мс разбора на 644 outbound и ломку ротации,
  умного/автосвитча и правила winws2 при урезании

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:06:09 +03:00

578 lines
22 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.

"""Generator-based transition steps (AC21).
A transition is written as a plain Python generator that yields
:class:`TransitionStep` objects. Two drivers execute such generators:
- :class:`TransitionRunner` — the asynchronous driver used by every
connection transition (connect, disconnect, reconnect, server switch,
proxy/TUN changes) and by manager-owned background runs (Zapret start,
Hysteria compatibility retry). It subscribes to the completion of each
yielded step and resumes the generator in the GUI thread through a queued
Qt signal, so the Qt event loop keeps running between steps (no
``processEvents``/``waitForFinished`` re-entrancy). An optional
``is_current`` predicate closes a stale generator before its next resume;
the connection coordinator does not use it — its generators check the
transition generation themselves and clean up explicitly.
- :func:`run_steps_blocking` — the synchronous driver for application
shutdown and unit tests only. It executes each step with the historical
pumped-wait primitives and fails fast on anything that is not a generator
yielding steps.
Every waiting step has a deadline: process waits and sleeps by construction,
worker steps by ``WORKER_STEP_DEADLINE_MS``.
Steps deliver worker exceptions into the generator via ``throw()`` so the
existing ``try/except/finally`` blocks of migrated operations (rollback,
cleanup, ``connection_changed`` in ``finally``) keep working unchanged.
QProcess objects never leave the GUI thread: only pure callables (subprocess
runs, socket probes, file writes, WinAPI calls) are shipped to worker pools.
"""
from __future__ import annotations
import inspect
import time
from concurrent.futures import Executor, Future, TimeoutError as FutureTimeoutError
from typing import Any, Callable, Generator
from PyQt6.QtCore import QObject, QProcess, Qt, QTimer, pyqtSignal
from ..platform.windows.subprocess_utils import (
_SUBPROCESS_EXECUTOR,
pump_qt_events,
sleep_with_events,
wait_for_qprocess_finished,
wait_for_qprocess_started,
)
# Type produced by transition generators.
TransitionSteps = Generator["TransitionStep", Any, Any]
# report(value, error) — exactly one call, may come from any thread.
StepReport = Callable[[Any, BaseException | None], None]
#: Upper bound for one worker step. Every blocking callable shipped to a
#: worker has its own, much shorter timeout (subprocess 3-15 s, sockets < 7 s,
#: WinAPI); this deadline only guarantees that a wedged call can never keep a
#: transition (or the synchronous shutdown driver) waiting forever.
WORKER_STEP_DEADLINE_MS = 60_000
class StepDeadlineExceeded(TimeoutError):
"""A step did not complete before its hard deadline."""
class TransitionStep:
"""One awaitable unit yielded by a transition generator."""
def start(self, report: StepReport) -> None:
"""Begin the step; ``report`` must be invoked exactly once when done."""
raise NotImplementedError
def cancel(self) -> None:
"""Best-effort cancellation; late ``report`` calls are ignored by the runner."""
def run_blocking(self) -> Any:
"""Execute synchronously (legacy pumped path for cold call sites)."""
raise NotImplementedError
class RunInWorkerStep(TransitionStep):
"""Run ``fn`` in a worker pool; the result returns to the GUI thread.
The default pool is the shared ``_SUBPROCESS_EXECUTOR``. Work that must
keep a strict order (system proxy writes) passes its own single-thread
executor. ``fn`` must be a pure callable: it must not touch QObjects or
controller state and must not use the pumped helpers of
``subprocess_utils`` (they would submit a nested job into the same pool).
"""
def __init__(
self,
fn: Callable[[], Any],
executor: Executor | None = None,
*,
deadline_ms: int = WORKER_STEP_DEADLINE_MS,
):
self._fn = fn
self._executor = executor
self._deadline_ms = max(1, int(deadline_ms))
self._future: Future[Any] | None = None
self._timer: QTimer | None = None
self._cancelled = False
self._reported = False
def _pool(self) -> Executor:
return self._executor if self._executor is not None else _SUBPROCESS_EXECUTOR
def start(self, report: StepReport) -> None:
future = self._pool().submit(self._fn)
self._future = future
def _on_done(done: Future[Any]) -> None:
if self._cancelled or self._reported or done.cancelled():
return
self._reported = True
error = done.exception()
if error is not None:
report(None, error)
else:
report(done.result(), None)
# Hard deadline: a wedged worker call fails the step instead of
# parking the transition forever (the late result is ignored).
timer = QTimer()
timer.setSingleShot(True)
def _on_deadline() -> None:
if self._cancelled or self._reported:
return
self._reported = True
future.cancel()
report(None, StepDeadlineExceeded(f"worker step exceeded {self._deadline_ms} ms"))
timer.timeout.connect(_on_deadline)
self._timer = timer
timer.start(self._deadline_ms)
future.add_done_callback(_on_done)
def cancel(self) -> None:
self._cancelled = True
if self._timer is not None:
self._timer.stop()
if self._future is not None:
self._future.cancel()
def run_blocking(self) -> Any:
# Cold-path compatibility only (shutdown, tests): preserves the
# historical run_text_pumped behaviour (worker thread + event pumping),
# bounded by the same hard deadline as the asynchronous path.
future = self._pool().submit(self._fn)
deadline = time.monotonic() + self._deadline_ms / 1000.0
while True:
try:
return future.result(timeout=0.05)
except FutureTimeoutError:
if time.monotonic() >= deadline:
future.cancel()
raise StepDeadlineExceeded(f"worker step exceeded {self._deadline_ms} ms") from None
pump_qt_events()
class SleepStep(TransitionStep):
"""QTimer-based pause; never blocks the GUI thread in async mode."""
def __init__(self, ms: int):
self.ms = max(0, int(ms))
self._timer: QTimer | None = None
def start(self, report: StepReport) -> None:
timer = QTimer()
timer.setSingleShot(True)
timer.timeout.connect(lambda: report(None, None))
self._timer = timer
timer.start(self.ms)
def cancel(self) -> None:
if self._timer is not None:
self._timer.stop()
def run_blocking(self) -> Any:
sleep_with_events(self.ms / 1000.0)
return None
class WaitProcessFinishedStep(TransitionStep):
"""Race the QProcess ``finished`` signal against a QTimer.
Resolves to ``True`` when the process finished, ``False`` on timeout —
the same contract as ``wait_for_qprocess_finished`` but without
``waitForFinished``/``processEvents``.
"""
def __init__(self, process: Any, timeout_ms: int):
self._process = process
self._timeout_ms = max(0, int(timeout_ms))
self._timer: QTimer | None = None
self._reported = False
self._signal_handler: Callable[..., None] | None = None
def _already_done(self) -> bool:
return self._process.state() == QProcess.ProcessState.NotRunning
def _watched_signals(self) -> list[tuple[Any, Any]]:
"""(signal, value-on-fire) pairs to race against the timer."""
return [(self._process.finished, True)]
def _timeout_value(self) -> Any:
return self._already_done()
def start(self, report: StepReport) -> None:
if self._already_done():
report(True, None)
return
self._start_race(report)
def _start_race(self, report: StepReport) -> None:
connections: list[tuple[Any, Callable[..., None]]] = []
def _finish(value: Any) -> None:
if self._reported:
return
self._reported = True
for signal, handler in connections:
try:
signal.disconnect(handler)
except (TypeError, RuntimeError):
pass
if self._timer is not None:
self._timer.stop()
report(value, None)
for signal, value in self._watched_signals():
handler = (lambda *args, _value=value: _finish(_value))
connections.append((signal, handler))
signal.connect(handler)
self._connections = connections
timer = QTimer()
timer.setSingleShot(True)
timer.timeout.connect(lambda: _finish(self._timeout_value()))
self._timer = timer
timer.start(self._timeout_ms)
def cancel(self) -> None:
self._reported = True
for signal, handler in getattr(self, "_connections", []):
try:
signal.disconnect(handler)
except (TypeError, RuntimeError):
pass
if self._timer is not None:
self._timer.stop()
def run_blocking(self) -> Any:
return wait_for_qprocess_finished(self._process, self._timeout_ms)
class WaitProcessStartedStep(WaitProcessFinishedStep):
"""Race ``started``/``errorOccurred`` against a QTimer.
Resolves to ``True`` when the process reached Running, ``False`` on
startup error or timeout.
"""
def _already_done(self) -> bool:
return self._process.state() == QProcess.ProcessState.Running
def _watched_signals(self) -> list[tuple[Any, Any]]:
return [
(self._process.started, True),
(self._process.errorOccurred, False),
]
def _timeout_value(self) -> Any:
return self._process.state() == QProcess.ProcessState.Running
def run_blocking(self) -> Any:
return wait_for_qprocess_started(self._process, self._timeout_ms)
def run_in_worker(
fn: Callable[[], Any],
*,
executor: Executor | None = None,
deadline_ms: int = WORKER_STEP_DEADLINE_MS,
) -> RunInWorkerStep:
"""Awaitable step: execute ``fn`` in a worker pool (shared by default)."""
return RunInWorkerStep(fn, executor, deadline_ms=deadline_ms)
def sleep_ms(ms: int) -> SleepStep:
"""Awaitable step: resume the generator after ``ms`` milliseconds."""
return SleepStep(ms)
def wait_process_finished(process: Any, timeout_ms: int) -> WaitProcessFinishedStep:
"""Awaitable step: wait until the QProcess finishes (or timeout)."""
return WaitProcessFinishedStep(process, timeout_ms)
def wait_process_started(process: Any, timeout_ms: int) -> WaitProcessStartedStep:
"""Awaitable step: wait until the QProcess reaches Running (or fails/timeout)."""
return WaitProcessStartedStep(process, timeout_ms)
def _step_origin(generator: Any) -> str:
"""``function:line`` самого глубокого генератора в цепочке ``yield from``."""
frame = None
current = generator
for _ in range(64):
frame = getattr(current, "gi_frame", None) or frame
nested = getattr(current, "gi_yieldfrom", None)
if nested is None:
break
current = nested
if frame is None:
return "?"
return f"{frame.f_code.co_name}:{frame.f_lineno}"
def _step_kind(step: TransitionStep) -> str:
if isinstance(step, RunInWorkerStep):
name = getattr(step._fn, "__name__", "") or type(step._fn).__name__
return f"worker {name}" if name != "<lambda>" else "worker"
if isinstance(step, SleepStep):
return f"sleep {step.ms}"
if isinstance(step, WaitProcessStartedStep):
return "process start"
if isinstance(step, WaitProcessFinishedStep):
return "process exit"
return type(step).__name__
class TransitionProfile:
"""Где переход провёл время: шаги (ожидание) и код между ними (GUI-поток).
Ничего не меняет в выполнении — только складывает длительности, чтобы
журнал показывал, какой шаг задержал подключение или переключение.
"""
#: Шаги короче этого порога не печатаются поимённо, только суммой.
LIST_THRESHOLD_MS = 15.0
#: Кусок кода генератора на GUI-потоке (между двумя yield) дольше этого порога
#: попадает в журнал с местом в коде: gui=… в сводке сам не говорит, где потрачено.
SLOW_SLICE_MS = 100.0
MAX_SLOW_SLICES = 5
def __init__(self) -> None:
self.started = time.perf_counter()
self.finished: float | None = None
self.gui_ms = 0.0
self.slow_slices: list[tuple[str, float]] = []
self.steps: list[tuple[str, float]] = []
self._step_started = 0.0
self._step_label = ""
def begin_step(self, label: str) -> None:
self._step_label = label
self._step_started = time.perf_counter()
def end_step(self) -> None:
if self._step_label:
self.steps.append((self._step_label, (time.perf_counter() - self._step_started) * 1000.0))
self._step_label = ""
def add_gui(self, began: float, where: str = "") -> None:
elapsed_ms = (time.perf_counter() - began) * 1000.0
self.gui_ms += elapsed_ms
if where and elapsed_ms >= self.SLOW_SLICE_MS:
self.slow_slices.append((where, elapsed_ms))
def close(self) -> None:
self.end_step()
if self.finished is None:
self.finished = time.perf_counter()
@property
def total_ms(self) -> float:
end = self.finished if self.finished is not None else time.perf_counter()
return (end - self.started) * 1000.0
def summary(self) -> str:
listed = [(label, ms) for label, ms in self.steps if ms >= self.LIST_THRESHOLD_MS]
rest = [ms for _label, ms in self.steps if ms < self.LIST_THRESHOLD_MS]
parts = [f"{label}={ms:.0f}" for label, ms in listed]
if rest:
parts.append(f"прочие {len(rest)} шагов={sum(rest):.0f}")
text = f"total={self.total_ms:.0f}ms gui={self.gui_ms:.0f}ms steps={len(self.steps)}"
text += (" | " + ", ".join(parts) if parts else "")
if self.slow_slices:
worst = sorted(self.slow_slices, key=lambda item: item[1], reverse=True)[: self.MAX_SLOW_SLICES]
text += " | медленно в GUI: " + ", ".join(f"{where}={ms:.0f}" for where, ms in worst)
return text
class TransitionRunner(QObject):
"""Drives a transition generator on the GUI thread.
- Each yielded :class:`TransitionStep` is started; its completion resumes
the generator through a queued signal (always in the runner's thread).
- Before every resume the ``is_current`` predicate is checked; when it
turns false (a newer transition request arrived) the generator is closed
(``GeneratorExit`` runs its ``finally`` blocks) and the run finishes
with ``cancelled=True``, without executing the remaining steps.
- A step error is delivered into the generator via ``throw()``; an
exception escaping the generator finishes the run with ``error`` set.
"""
# (seq, value, error) — queued so resumes never re-enter the caller.
_step_completed = pyqtSignal(int, object, object)
def __init__(
self,
generator: TransitionSteps,
*,
is_current: Callable[[], bool] | None = None,
on_finished: Callable[["TransitionRunner"], None] | None = None,
parent: QObject | None = None,
):
super().__init__(parent)
self._gen = generator
self._is_current = is_current if is_current is not None else (lambda: True)
self._on_finished = on_finished
self._seq = 0
self._current_step: TransitionStep | None = None
self._done = False
self.result: Any = None
self.error: BaseException | None = None
self.cancelled = False
self.profile = TransitionProfile()
#: perf_counter() запроса, ради которого запущен переход (для журнала).
self.requested_at: float | None = None
self._step_completed.connect(self._handle_step_completed, Qt.ConnectionType.QueuedConnection)
@property
def done(self) -> bool:
return self._done
def start(self) -> None:
self._advance(None, None)
def cancel(self) -> None:
"""Cancel immediately (used by shutdown paths)."""
if not self._done:
self._cancel_run()
# ── internals ──
def _handle_step_completed(self, seq: int, value: Any, error: BaseException | None) -> None:
if self._done or seq != self._seq:
return # stale report from a cancelled/superseded step
self._advance(value, error)
def _advance(self, value: Any, error: BaseException | None) -> None:
if self._done:
return
self._current_step = None
self.profile.end_step()
if not self._is_current():
self._cancel_run()
return
slice_from = _step_origin(self._gen)
gui_began = time.perf_counter()
try:
if error is not None:
step = self._gen.throw(error)
else:
step = self._gen.send(value)
except StopIteration as stop:
# Synchronous steps can pump Qt events internally, including Stop
# or a newer selection, without yielding back to this runner.
self.profile.add_gui(gui_began, f"{slice_from}→конец")
self._finish(result=stop.value, cancelled=not self._is_current())
return
except BaseException as exc: # noqa: BLE001 — transition errors surface via .error
self.profile.add_gui(gui_began, f"{slice_from}→ошибка")
self._finish(error=exc)
return
self.profile.add_gui(gui_began, f"{slice_from}→{_step_origin(self._gen)}")
if not isinstance(step, TransitionStep):
bad = TypeError(f"Transition generator must yield TransitionStep, got {type(step)!r}")
try:
self._gen.close()
except BaseException: # noqa: BLE001
pass
self._finish(error=bad)
return
self._seq += 1
seq = self._seq
self._current_step = step
self.profile.begin_step(f"{_step_origin(self._gen)} {_step_kind(step)}")
def _report(step_value: Any, step_error: BaseException | None) -> None:
# May be invoked from a worker thread: the queued signal marshals
# the resume back to the runner's (GUI) thread.
try:
self._step_completed.emit(seq, step_value, step_error)
except RuntimeError:
# Runner already deleted (cancelled run torn down) — stale report.
pass
try:
step.start(_report)
except BaseException as exc: # noqa: BLE001 — deliver into the generator
self._step_completed.emit(seq, None, exc)
def _cancel_run(self) -> None:
step = self._current_step
self._current_step = None
if step is not None:
try:
step.cancel()
except Exception:
pass
try:
self._gen.close()
except BaseException as exc: # noqa: BLE001 — a broken finally block
self.cancelled = True
self._finish(error=exc, cancelled=True)
return
self._finish(cancelled=True)
def _finish(
self,
*,
result: Any = None,
error: BaseException | None = None,
cancelled: bool = False,
) -> None:
if self._done:
return
self._done = True
self.profile.close()
self.result = result
self.error = error
self.cancelled = cancelled
self._current_step = None
callback = self._on_finished
self._on_finished = None
if callback is not None:
callback(self)
def run_steps_blocking(generator: TransitionSteps, *, max_steps: int = 100_000) -> Any:
"""Synchronous driver for cold compatibility wrappers.
Executes each yielded step with the historical pumped-wait behaviour.
Only application shutdown and tests may use it: hot transition paths go
through :class:`TransitionRunner`.
Guards (a wrong argument must fail fast, never spin): ``generator`` must be
a real generator, every yielded object must be a :class:`TransitionStep`,
and the number of steps is bounded. Each step has its own deadline.
"""
if not inspect.isgenerator(generator):
raise TypeError(f"run_steps_blocking needs a generator, got {type(generator)!r}")
value: Any = None
error: BaseException | None = None
for _ in range(max(1, int(max_steps))):
try:
if error is not None:
step = generator.throw(error)
else:
step = generator.send(value)
except StopIteration as stop:
return stop.value
if not isinstance(step, TransitionStep):
generator.close()
raise TypeError(f"Transition generator must yield TransitionStep, got {type(step)!r}")
value, error = None, None
try:
value = step.run_blocking()
except BaseException as exc: # noqa: BLE001 — delivered via generator.throw
error = exc
generator.close()
raise RuntimeError(f"run_steps_blocking exceeded {max_steps} steps")