fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user