zapret-kvn/xray_fluent/engines/singbox/selector_api.py
loop-uh c624a581ac
Some checks failed
Windows project source guards / test (push) Has been cancelled
Подключение и переключения быстрее: без лишних ожиданий и PowerShell
- Ожидание освобождения TUN-адаптера ждало любой адаптер xftun*, а
  Wintun убитого ядра остаётся в системе выключенным: после первой же
  TUN-сессии каждое отключение и переключение в TUN стояло полные 10 с.
  Теперь ждём только адаптер своей сессии, пока он не перестанет быть Up.
- sing-box останавливается сразу kill(): terminate() консольный sing-box
  на Windows не обрабатывает, 500 мс «мягкой» остановки истекали всегда.
- «Осиротевшие» ядра и winws2 ищутся снимком WinAPI вместо PowerShell+WMI
  (~0,2 с на каждый запуск ядра, у Zapret — дважды); PowerShell — запасной.
- Готовность портов ядер, relay и Clash API сначала смотрится по таблице
  TCP: подключение к ещё закрытому порту Windows держит повторами SYN, и
  ранняя проба всегда выжидала свой таймаут (~0,35–0,5 с).
- sing-box check — только после неудачного запуска, ради понятной ошибки:
  параллельно с работающим ядром она для TUN-конфига мешает туннелю.
- cryptography грузится только при пароле данных и ссылках Happ.

Проверка готовности TUN оставлена прежней: при более ранней готовности
закрепление сервера через Clash API в первые ~0,4 с ломало TCP туннеля
в «родном» режиме sing-box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:41:18 +03:00

172 lines
6.8 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.

"""Minimal Clash-compatible selector client for a running sing-box."""
from __future__ import annotations
import json
import time
from collections.abc import Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, build_opener, ProxyHandler
from ...application.async_steps import TransitionSteps, run_in_worker, sleep_ms
from ...constants import PROXY_HOST
from ...network.proxy_readiness import LISTENER_POLL_MS, ports_listening
# Вызов идёт на loopback, но ядро отвечает не мгновенно: под нагрузкой диска или
# сразу после старта двух секунд не хватало, переключение объявлялось отвергнутым
# и уходило в полный перезапуск ядра — со стороны это выглядело случайным сбоем.
COMMAND_TIMEOUT_SEC = 6.0
# PUT задаёт выбранный узел, повтор приводит к тому же состоянию, поэтому
# ретраи безопасны.
COMMAND_ATTEMPTS = 3
RETRY_DELAY_SEC = 0.4
# A cold sing-box start can spend several seconds loading local providers and
# remote rule-set state before the Clash API begins listening. Hot switches
# keep the short retry budget above; startup gets a bounded readiness window.
STARTUP_READY_TIMEOUT_SEC = 12.0
STARTUP_RETRY_DELAY_SEC = 0.25
STARTUP_REQUEST_TIMEOUT_SEC = 1.0
def build_selector_url(api_port: int, selector_tag: str) -> str:
if int(api_port) <= 0:
raise ValueError("Порт Clash API sing-box не задан")
if not str(selector_tag or "").strip():
raise ValueError("Тег selector sing-box не задан")
return f"http://{PROXY_HOST}:{int(api_port)}/proxies/{quote(str(selector_tag), safe='')}"
def _selector_request(
api_port: int,
selector_tag: str,
outbound_tag: str,
) -> tuple[Request | None, str]:
if not str(outbound_tag or "").strip():
return None, "Тег outbound sing-box не задан"
try:
url = build_selector_url(api_port, selector_tag)
except ValueError as exc:
return None, str(exc)
return (
Request(
url,
data=json.dumps({"name": str(outbound_tag)}).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="PUT",
),
"",
)
def _send_selector_request(request: Request, timeout_sec: float) -> tuple[bool, str, bool]:
"""Return ``(ok, message, retryable)`` for one loopback PUT."""
try:
# Never inherit the system proxy for a loopback control-plane call.
with build_opener(ProxyHandler({})).open(
request, timeout=max(0.05, float(timeout_sec))
) as response:
if 200 <= int(response.status) < 300:
return True, "", False
return False, f"HTTP {response.status}", False
except HTTPError as exc:
# The core is ready and rejected the target; restarting cannot repair
# an invalid selector member.
return False, f"HTTP {exc.code}: {exc.reason}", False
except (URLError, OSError, TimeoutError) as exc:
return False, f"{type(exc).__name__}: {exc}", True
def select_outbound(api_port: int, selector_tag: str, outbound_tag: str) -> tuple[bool, str]:
request, problem = _selector_request(api_port, selector_tag, outbound_tag)
if request is None:
return False, problem
last_error = ""
for attempt in range(1, COMMAND_ATTEMPTS + 1):
ok, last_error, retryable = _send_selector_request(request, COMMAND_TIMEOUT_SEC)
if ok or not retryable:
return ok, last_error
if attempt < COMMAND_ATTEMPTS:
time.sleep(RETRY_DELAY_SEC)
return False, last_error
def select_outbound_when_ready(
api_port: int,
selector_tag: str,
outbound_tag: str,
*,
timeout_sec: float = STARTUP_READY_TIMEOUT_SEC,
wait: Callable[[float], None] = time.sleep,
) -> tuple[bool, str]:
"""Pin a selector during cold start, waiting for the local API listener.
Connection refusals and loopback timeouts mean that the control plane is
not ready yet, so they are retried until the bounded deadline. An HTTP
response is authoritative and is never retried.
"""
request, problem = _selector_request(api_port, selector_tag, outbound_tag)
if request is None:
return False, problem
deadline = time.monotonic() + max(0.05, float(timeout_sec))
last_error = ""
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False, last_error or "Clash API sing-box не стал доступен вовремя"
ok, last_error, retryable = _send_selector_request(
request,
min(STARTUP_REQUEST_TIMEOUT_SEC, remaining),
)
if ok or not retryable:
return ok, last_error
remaining = deadline - time.monotonic()
if remaining <= 0:
return False, last_error
wait(min(STARTUP_RETRY_DELAY_SEC, remaining))
def select_outbound_when_ready_steps(
api_port: int,
selector_tag: str,
outbound_tag: str,
*,
timeout_sec: float = STARTUP_READY_TIMEOUT_SEC,
) -> TransitionSteps:
"""Step version of :func:`select_outbound_when_ready` for transitions.
Each loopback PUT runs in the worker pool; the pause between attempts is
a timer step, so the GUI thread never waits on the Clash API.
"""
request, problem = _selector_request(api_port, selector_tag, outbound_tag)
if request is None:
return False, problem
deadline = time.monotonic() + max(0.05, float(timeout_sec))
last_error = ""
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False, last_error or "Clash API sing-box не стал доступен вовремя"
attempt_timeout = min(STARTUP_REQUEST_TIMEOUT_SEC, remaining)
# Порт Clash API ещё не открыт (таблица TCP) — запрос рано: на Windows
# он выждал бы повторные SYN к закрытому порту (~0,5 с) впустую.
closed, (ok, last_error, retryable) = yield run_in_worker(
lambda: (True, (False, "Clash API sing-box ещё не слушает порт", True))
if ports_listening((api_port,)) is False
else (False, _send_selector_request(request, attempt_timeout))
)
if ok or not retryable:
return ok, last_error
remaining = deadline - time.monotonic()
if remaining <= 0:
return False, last_error
delay = LISTENER_POLL_MS / 1000 if closed else STARTUP_RETRY_DELAY_SEC
yield sleep_ms(int(min(delay, remaining) * 1000))