fix(k1): restore canonical local connection lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 13:52:46 +03:00
parent 52da9b75b7
commit aff331082f
19 changed files with 2169 additions and 274 deletions
+224 -4
View File
@@ -1,9 +1,15 @@
from __future__ import annotations
import hashlib
import json
import os
import stat
import subprocess
import sys
from collections.abc import Callable
import tempfile
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any, Literal, TypedDict
@@ -70,6 +76,14 @@ _ERROR_MESSAGES = {
"host-wifi-operation-timeout": (
"оператор не завершил системное подключение Wi-Fi за отведённое время"
),
"host-wifi-helper-build-timeout": (
"локальный Wi-Fi helper не успел скомпилироваться за отведённое время"
),
"host-wifi-helper-build-failed": "локальный Wi-Fi helper не удалось скомпилировать",
"host-wifi-helper-compiler-unavailable": "компилятор локального Wi-Fi helper недоступен",
"host-wifi-helper-cache-unavailable": "кэш локального Wi-Fi helper недоступен",
"host-wifi-helper-missing": "исходный файл локального Wi-Fi helper не найден",
"host-wifi-helper-unavailable": "локальный Wi-Fi helper недоступен",
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
}
@@ -84,15 +98,20 @@ class HostWifiProfileError(RuntimeError):
*,
scan_attempt_count: int | None = None,
scan_elapsed_ms: int | None = None,
helper_stage: str | None = None,
helper_elapsed_ms: int | None = None,
) -> None:
self.reason_code = reason_code
self.scan_attempt_count = scan_attempt_count
self.scan_elapsed_ms = scan_elapsed_ms
self.helper_stage = helper_stage
self.helper_elapsed_ms = helper_elapsed_ms
message = _ERROR_MESSAGES.get(reason_code, "операция системного Wi-Fi завершилась ошибкой")
super().__init__(f"{message} ({reason_code})")
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
def _validate_profile_id(profile_id: str) -> None:
@@ -105,26 +124,227 @@ def _validate_profile_id(profile_id: str) -> None:
raise ValueError("host Wi-Fi profile id contains unsupported characters")
def _helper_cache_directory(helper_path: Path) -> Path:
"""Resolve the process-local helper cache without an environment override."""
source = helper_path.expanduser().resolve()
for ancestor in source.parents:
if ancestor.name != "plugins":
continue
try:
relative = source.relative_to(ancestor)
except ValueError: # pragma: no cover - guarded by Path.parents
continue
if relative.parts[:2] == ("xgrids-k1", "macos"):
return ancestor.parent / ".runtime" / "mission-core" / "helpers"
# Tests and separately packaged adapters still get a stable cache beside
# their source tree. The repository layout above is the production path.
return source.parent / ".runtime" / "mission-core" / "helpers"
def _compiled_macos_helper_path(helper_path: Path) -> Path:
source = helper_path.expanduser().resolve()
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
return _helper_cache_directory(source) / f"{source.stem}-{source_sha256}"
def _is_ready_executable(path: Path) -> bool:
try:
metadata = path.lstat()
except OSError:
return False
return (
stat.S_ISREG(metadata.st_mode)
and metadata.st_size > 0
and metadata.st_mode & 0o111 != 0
)
@contextmanager
def _exclusive_helper_build_lock(
path: Path,
*,
timeout_seconds: float,
) -> Iterator[None]:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
try:
import fcntl
except ImportError as exc: # pragma: no cover - production target is macOS
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = False
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise HostWifiProfileError("host-wifi-helper-cache-unavailable")
lock_started = time.monotonic()
while True:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError as exc:
elapsed_seconds = max(0.0, time.monotonic() - lock_started)
if elapsed_seconds >= timeout_seconds:
raise HostWifiProfileError(
"host-wifi-helper-build-timeout",
helper_stage="compile-lock",
helper_elapsed_ms=int(elapsed_seconds * 1000),
) from exc
time.sleep(min(0.05, timeout_seconds - elapsed_seconds))
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
locked = True
yield
finally:
if locked:
with suppress(OSError):
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _ensure_compiled_macos_helper(
helper_path: Path,
*,
build_timeout_seconds: float,
runner: RunProcess,
) -> Path:
"""Build the source-hash-addressed helper once and reuse it thereafter."""
if build_timeout_seconds <= 0:
raise ValueError("build_timeout_seconds must be positive")
source = helper_path.expanduser().resolve()
if not source.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
try:
executable = _compiled_macos_helper_path(source)
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
if _is_ready_executable(executable):
return executable
build_started_ns = time.monotonic_ns()
build_deadline = time.monotonic() + build_timeout_seconds
def build_error(
reason_code: str,
*,
helper_stage: str = "compile",
) -> HostWifiProfileError:
elapsed_ms = max(0, (time.monotonic_ns() - build_started_ns) // 1_000_000)
return HostWifiProfileError(
reason_code,
helper_stage=helper_stage,
helper_elapsed_ms=elapsed_ms,
)
lock_path = executable.with_name(f".{executable.name}.lock")
with _exclusive_helper_build_lock(
lock_path,
timeout_seconds=max(0.001, build_deadline - time.monotonic()),
):
if _is_ready_executable(executable):
return executable
try:
descriptor, staging_name = tempfile.mkstemp(
prefix=f".{executable.name}.",
suffix=".tmp",
dir=executable.parent,
)
os.close(descriptor)
staging = Path(staging_name)
staging.unlink()
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
try:
remaining_build_seconds = build_deadline - time.monotonic()
if remaining_build_seconds <= 0:
raise build_error("host-wifi-helper-build-timeout")
try:
completed = runner(
[
"/usr/bin/xcrun",
"swiftc",
str(source),
"-o",
str(staging),
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=remaining_build_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise build_error("host-wifi-helper-build-timeout") from exc
except OSError as exc:
raise build_error("host-wifi-helper-compiler-unavailable") from exc
if completed.returncode != 0 or not _is_ready_executable(staging):
raise build_error("host-wifi-helper-build-failed")
try:
staging.chmod(0o700)
with staging.open("rb") as stream:
os.fsync(stream.fileno())
os.replace(staging, executable)
_fsync_directory(executable.parent)
except OSError as exc:
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
finally:
with suppress(OSError):
staging.unlink(missing_ok=True)
return executable
def _run_macos_helper(
helper_path: Path,
request: dict[str, object],
*,
timeout_seconds: float,
runner: RunProcess,
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
) -> dict[str, Any]:
if sys.platform != "darwin":
raise HostWifiProfileError("unsupported-platform")
if not helper_path.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
executable = _ensure_compiled_macos_helper(
helper_path,
build_timeout_seconds=build_timeout_seconds,
runner=runner,
)
request_bytes = bytearray(
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
try:
completed = runner(
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
[str(executable)],
input=request_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,