wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# ``k1link.web.app`` builds the installed plugin environment at import time.
|
||||
# Pytest imports test modules during collection and future tests may lazy-load
|
||||
# it during execution or in a spawned child. Keep every mutable Mission Core
|
||||
# store used by that composition inside one process-scoped test root for the
|
||||
# entire pytest lifetime. Restoring the caller environment after collection
|
||||
# would reopen the operator's real stores for lazy imports and child processes.
|
||||
_ISOLATED_ENVIRONMENT_KEYS = (
|
||||
"MISSIONCORE_DATA_DIR",
|
||||
"MISSIONCORE_EVIDENCE_DIR",
|
||||
"MISSIONCORE_LEGACY_SESSIONS_DIR",
|
||||
)
|
||||
_ORIGINAL_ENVIRONMENT = {
|
||||
key: os.environ.get(key) for key in _ISOLATED_ENVIRONMENT_KEYS
|
||||
}
|
||||
_TEST_RUNTIME_ROOT = Path(
|
||||
tempfile.mkdtemp(prefix="mission-core-pytest-runtime-")
|
||||
).resolve()
|
||||
_TEST_RUNTIME_ROOT.chmod(0o700)
|
||||
|
||||
_ISOLATED_ENVIRONMENT = {
|
||||
"MISSIONCORE_DATA_DIR": _TEST_RUNTIME_ROOT / "data",
|
||||
"MISSIONCORE_EVIDENCE_DIR": _TEST_RUNTIME_ROOT / "evidence",
|
||||
"MISSIONCORE_LEGACY_SESSIONS_DIR": _TEST_RUNTIME_ROOT / "legacy-sessions",
|
||||
}
|
||||
for _key, _path in _ISOLATED_ENVIRONMENT.items():
|
||||
_path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
_path.chmod(0o700)
|
||||
os.environ[_key] = str(_path)
|
||||
|
||||
|
||||
def _restore_environment() -> None:
|
||||
for key, original_value in _ORIGINAL_ENVIRONMENT.items():
|
||||
if original_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original_value
|
||||
|
||||
|
||||
@atexit.register
|
||||
def _remove_test_runtime() -> None:
|
||||
_restore_environment()
|
||||
shutil.rmtree(_TEST_RUNTIME_ROOT, ignore_errors=True)
|
||||
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
|
||||
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
|
||||
from k1link.sessions import active as active_session_module
|
||||
|
||||
|
||||
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
|
||||
@@ -38,3 +42,65 @@ def test_startup_recovery_removes_only_an_unlocked_stale_marker(tmp_path: Path)
|
||||
assert recover_stale_active_session_marker(sessions) is True
|
||||
assert not marker.exists()
|
||||
assert recover_stale_active_session_marker(sessions) is False
|
||||
|
||||
|
||||
def test_active_session_release_retries_unlink_without_dropping_lock(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = sessions / "20260812T231000Z_viewer_live"
|
||||
lease = ActiveSessionLease.acquire(sessions, session)
|
||||
marker = sessions / ".current_session"
|
||||
original_unlink = Path.unlink
|
||||
attempts = 0
|
||||
|
||||
def fail_once(path: Path, *args: object, **kwargs: object) -> None:
|
||||
nonlocal attempts
|
||||
if path == marker and attempts == 0:
|
||||
attempts += 1
|
||||
raise OSError("injected marker unlink failure")
|
||||
original_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", fail_once)
|
||||
with pytest.raises(OSError, match="injected marker unlink failure"):
|
||||
lease.release()
|
||||
|
||||
assert marker.exists()
|
||||
assert lease._released is False # noqa: SLF001
|
||||
os.fstat(lease._descriptor) # noqa: SLF001
|
||||
assert recover_stale_active_session_marker(sessions) is False
|
||||
|
||||
lease.release()
|
||||
assert lease._released is True # noqa: SLF001
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_active_session_release_retries_directory_fsync_after_unlink(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = sessions / "20260812T231100Z_viewer_live"
|
||||
lease = ActiveSessionLease.acquire(sessions, session)
|
||||
original_fsync = active_session_module._fsync_directory_strict
|
||||
attempts = 0
|
||||
|
||||
def fail_once(path: Path) -> None:
|
||||
nonlocal attempts
|
||||
if attempts == 0:
|
||||
attempts += 1
|
||||
raise OSError("injected directory fsync failure")
|
||||
original_fsync(path)
|
||||
|
||||
monkeypatch.setattr(active_session_module, "_fsync_directory_strict", fail_once)
|
||||
with pytest.raises(OSError, match="injected directory fsync failure"):
|
||||
lease.release()
|
||||
|
||||
assert lease._marker_removed is True # noqa: SLF001
|
||||
assert lease._released is False # noqa: SLF001
|
||||
os.fstat(lease._descriptor) # noqa: SLF001
|
||||
|
||||
lease.release()
|
||||
lease.release()
|
||||
assert lease._released is True # noqa: SLF001
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
@@ -9,3 +11,23 @@ def test_write_json_atomic(tmp_path: Path) -> None:
|
||||
write_json_atomic(output, {"value": "тест"})
|
||||
assert json.loads(output.read_text(encoding="utf-8")) == {"value": "тест"}
|
||||
assert not list(output.parent.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_write_json_atomic_flushes_file_and_parent_directory(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
output = tmp_path / "nested" / "artifact.json"
|
||||
flushed_kinds: list[str] = []
|
||||
real_fsync = os.fsync
|
||||
|
||||
def observing_fsync(descriptor: int) -> None:
|
||||
mode = os.fstat(descriptor).st_mode
|
||||
flushed_kinds.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(os, "fsync", observing_fsync)
|
||||
|
||||
write_json_atomic(output, {"durable": True})
|
||||
|
||||
assert flushed_kinds == ["file", "directory"]
|
||||
|
||||
+1423
-54
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from http.client import BadStatusLine
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import cli
|
||||
@@ -10,6 +17,36 @@ from k1link.device_plugins.xgrids_k1.cli import app
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class _FakeServeLease:
|
||||
def __init__(self) -> None:
|
||||
self.active = False
|
||||
|
||||
def __enter__(self) -> _FakeServeLease:
|
||||
assert self.active is False
|
||||
self.active = True
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
assert self.active is True
|
||||
self.active = False
|
||||
|
||||
|
||||
def _install_fake_serve_lease(
|
||||
monkeypatch: Any,
|
||||
*,
|
||||
error: BaseException | None = None,
|
||||
) -> _FakeServeLease:
|
||||
lease = _FakeServeLease()
|
||||
|
||||
def acquire(_: Path) -> _FakeServeLease:
|
||||
if error is not None:
|
||||
raise error
|
||||
return lease
|
||||
|
||||
monkeypatch.setattr(cli, "_acquire_mission_core_serve_lease", acquire)
|
||||
return lease
|
||||
|
||||
|
||||
def test_help() -> None:
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
@@ -28,11 +65,14 @@ def test_doctor_json() -> None:
|
||||
|
||||
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
lease = _install_fake_serve_lease(monkeypatch)
|
||||
|
||||
def fake_run(application: str, **kwargs: object) -> None:
|
||||
assert lease.active is True
|
||||
captured.update({"application": application, **kwargs})
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
@@ -44,6 +84,315 @@ def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
||||
"log_level": "info",
|
||||
"access_log": True,
|
||||
}
|
||||
assert lease.active is False
|
||||
|
||||
|
||||
def test_serve_releases_singleton_lease_when_uvicorn_fails(monkeypatch: Any) -> None:
|
||||
lease = _install_fake_serve_lease(monkeypatch)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
||||
|
||||
def failed_run(*_: object, **__: object) -> None:
|
||||
assert lease.active is True
|
||||
raise RuntimeError("startup failed")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", failed_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, RuntimeError)
|
||||
assert lease.active is False
|
||||
|
||||
|
||||
def test_serve_reuses_an_existing_mission_core_backend(monkeypatch: Any) -> None:
|
||||
lease = _install_fake_serve_lease(monkeypatch)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "mission-core")
|
||||
|
||||
def unexpected_run(*_: object, **__: object) -> None:
|
||||
raise AssertionError("a second backend must not be started")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "уже запущен" in result.stdout
|
||||
assert "второй процесс не создан" in result.stdout
|
||||
assert lease.active is False
|
||||
|
||||
|
||||
def test_serve_rejects_an_unrelated_loopback_listener(monkeypatch: Any) -> None:
|
||||
lease = _install_fake_serve_lease(monkeypatch)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "occupied")
|
||||
|
||||
def unexpected_run(*_: object, **__: object) -> None:
|
||||
raise AssertionError("an occupied port must fail before Uvicorn startup")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "уже занят" in result.stdout
|
||||
assert "Mission Core не стал создавать второй backend" in result.stdout
|
||||
assert "--port" not in result.stdout
|
||||
assert lease.active is False
|
||||
|
||||
|
||||
def test_serve_rejects_noncanonical_port_before_lock_or_health(monkeypatch: Any) -> None:
|
||||
def unexpected_call(*_: object, **__: object) -> None:
|
||||
raise AssertionError("noncanonical port must fail before lock or health inspection")
|
||||
|
||||
monkeypatch.setattr(cli, "_acquire_mission_core_serve_lease", unexpected_call)
|
||||
monkeypatch.setattr(cli, "_local_server_status", unexpected_call)
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_call)
|
||||
|
||||
result = runner.invoke(app, ["serve", "--port", "8001"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "только на каноническом порту 8000" in result.stdout
|
||||
assert "Другой локальный backend не создан" in result.stdout
|
||||
|
||||
|
||||
def test_serve_busy_lock_reuses_confirmed_mission_core(monkeypatch: Any) -> None:
|
||||
_install_fake_serve_lease(
|
||||
monkeypatch,
|
||||
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
||||
)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "mission-core")
|
||||
|
||||
def unexpected_run(*_: object, **__: object) -> None:
|
||||
raise AssertionError("busy singleton lease must not start Uvicorn")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "уже запущен" in result.stdout
|
||||
assert "второй процесс не создан" in result.stdout
|
||||
|
||||
|
||||
def test_serve_busy_lock_reports_in_progress_when_health_is_not_listening(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
_install_fake_serve_lease(
|
||||
monkeypatch,
|
||||
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
||||
)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "free")
|
||||
|
||||
def unexpected_run(*_: object, **__: object) -> None:
|
||||
raise AssertionError("busy singleton lease must not start Uvicorn")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "уже запускается или завершает работу" in result.stdout
|
||||
assert "Второй backend не создан" in result.stdout
|
||||
|
||||
|
||||
def test_serve_busy_lock_rejects_unconfirmed_health_listener(monkeypatch: Any) -> None:
|
||||
_install_fake_serve_lease(
|
||||
monkeypatch,
|
||||
error=cli._MissionCoreServeLeaseUnavailable("busy"),
|
||||
)
|
||||
monkeypatch.setattr(cli, "_local_server_status", lambda _: "occupied")
|
||||
|
||||
def unexpected_run(*_: object, **__: object) -> None:
|
||||
raise AssertionError("busy singleton lease must not start Uvicorn")
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "канонический health endpoint" in result.stdout
|
||||
assert "подтверждён" in result.stdout
|
||||
assert "Второй backend не создан" in result.stdout
|
||||
|
||||
|
||||
def test_serve_lock_integrity_error_fails_before_health_or_uvicorn(monkeypatch: Any) -> None:
|
||||
_install_fake_serve_lease(
|
||||
monkeypatch,
|
||||
error=cli._MissionCoreServeLeaseError("unsafe lock"),
|
||||
)
|
||||
|
||||
def unexpected_call(*_: object, **__: object) -> None:
|
||||
raise AssertionError("an untrusted lock must block health and Uvicorn")
|
||||
|
||||
monkeypatch.setattr(cli, "_local_server_status", unexpected_call)
|
||||
monkeypatch.setattr(cli.uvicorn, "run", unexpected_call)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "безопасно получить блокировку" in result.stdout
|
||||
assert "Второй backend не создан" in result.stdout
|
||||
|
||||
|
||||
def test_serve_lock_is_stable_private_and_cross_process(tmp_path: Path) -> None:
|
||||
expected_path = tmp_path / ".runtime" / "mission-core" / ".serve.lock"
|
||||
|
||||
with cli._MissionCoreServeLease.acquire(tmp_path) as first:
|
||||
assert first.path == expected_path
|
||||
first_identity = (expected_path.stat().st_dev, expected_path.stat().st_ino)
|
||||
assert expected_path.stat().st_mode & 0o777 == 0o600
|
||||
assert expected_path.parent.stat().st_mode & 0o777 == 0o700
|
||||
child = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import sys; from pathlib import Path; "
|
||||
"from k1link.device_plugins.xgrids_k1.cli import "
|
||||
"_MissionCoreServeLease, _MissionCoreServeLeaseUnavailable; "
|
||||
"root=Path(sys.argv[1]); "
|
||||
"\ntry: lease=_MissionCoreServeLease.acquire(root)"
|
||||
"\nexcept _MissionCoreServeLeaseUnavailable: raise SystemExit(0)"
|
||||
"\nelse: lease.release(); raise SystemExit(1)"
|
||||
),
|
||||
str(tmp_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
assert child.returncode == 0, child.stderr
|
||||
|
||||
assert expected_path.is_file()
|
||||
with cli._MissionCoreServeLease.acquire(tmp_path) as second:
|
||||
assert (second.path.stat().st_dev, second.path.stat().st_ino) == first_identity
|
||||
|
||||
|
||||
def test_serve_lock_rejects_nonprivate_lock_directory(tmp_path: Path) -> None:
|
||||
lock_dir = tmp_path / ".runtime" / "mission-core"
|
||||
lock_dir.mkdir(mode=0o755, parents=True)
|
||||
lock_dir.chmod(0o755)
|
||||
|
||||
with pytest.raises(cli._MissionCoreServeLeaseError, match="not private"):
|
||||
cli._MissionCoreServeLease.acquire(tmp_path)
|
||||
|
||||
|
||||
def test_serve_lock_rejects_symlinked_lock_directory(tmp_path: Path) -> None:
|
||||
runtime_dir = tmp_path / ".runtime"
|
||||
runtime_dir.mkdir()
|
||||
redirected = tmp_path / "redirected"
|
||||
redirected.mkdir(mode=0o700)
|
||||
(runtime_dir / "mission-core").symlink_to(redirected, target_is_directory=True)
|
||||
|
||||
with pytest.raises(cli._MissionCoreServeLeaseError, match="not private"):
|
||||
cli._MissionCoreServeLease.acquire(tmp_path)
|
||||
|
||||
|
||||
class _FakeHealthResponse:
|
||||
def __init__(self, *, status: int = 200, body: bytes = b"") -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
def read(self, limit: int) -> bytes:
|
||||
assert limit == 16_385
|
||||
return self.body[:limit]
|
||||
|
||||
|
||||
class _FakeHealthConnection:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
response: _FakeHealthResponse | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.error = error
|
||||
self.request_call: tuple[str, str, dict[str, str]] | None = None
|
||||
self.closed = False
|
||||
|
||||
def request(self, method: str, path: str, *, headers: dict[str, str]) -> None:
|
||||
self.request_call = (method, path, headers)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def getresponse(self) -> _FakeHealthResponse:
|
||||
assert self.response is not None
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _install_fake_health_connection(
|
||||
monkeypatch: Any,
|
||||
connection: _FakeHealthConnection,
|
||||
) -> None:
|
||||
def fake_connection(host: str, port: int, *, timeout: float) -> _FakeHealthConnection:
|
||||
assert (host, port, timeout) == ("127.0.0.1", 8000, 0.75)
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(cli.http.client, "HTTPConnection", fake_connection)
|
||||
|
||||
|
||||
def test_local_server_status_recognizes_exact_mission_core_health(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(
|
||||
response=_FakeHealthResponse(
|
||||
body=json.dumps({"service": "mission-core-control-plane"}).encode("utf-8")
|
||||
)
|
||||
)
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "mission-core"
|
||||
assert connection.request_call == (
|
||||
"GET",
|
||||
"/api/health",
|
||||
{"Accept": "application/json", "Connection": "close"},
|
||||
)
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_local_server_status_rejects_a_different_health_identity(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(
|
||||
response=_FakeHealthResponse(body=json.dumps({"service": "other"}).encode("utf-8"))
|
||||
)
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "occupied"
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_local_server_status_only_treats_explicit_refusal_as_free(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(error=ConnectionRefusedError())
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "free"
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_local_server_status_fails_closed_on_listener_timeout(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(error=TimeoutError())
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "occupied"
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_local_server_status_fails_closed_on_non_http_listener(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(error=BadStatusLine("not HTTP"))
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "occupied"
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_local_server_status_rejects_oversized_health_payload(monkeypatch: Any) -> None:
|
||||
connection = _FakeHealthConnection(
|
||||
response=_FakeHealthResponse(body=b" " * 16_385)
|
||||
)
|
||||
_install_fake_health_connection(monkeypatch, connection)
|
||||
|
||||
assert cli._local_server_status(8000) == "occupied"
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||
@@ -144,3 +493,68 @@ def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
||||
}
|
||||
assert "messages: 2" in result.stdout
|
||||
assert "subscriptions active" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ledger_status", ["resolved", "corrupt"])
|
||||
def test_cli_wifi_configure_blocks_retired_or_untrusted_audit_before_credentials_and_ble(
|
||||
monkeypatch: Any,
|
||||
tmp_path: Path,
|
||||
ledger_status: str,
|
||||
) -> None:
|
||||
repository_root = tmp_path / "repository"
|
||||
repository_root.mkdir()
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
monkeypatch.setattr(cli, "_configure_ble_process_lease", lambda: repository_root)
|
||||
retired_transport_ref = "F89438FA-55ED-85AD-EED7-734AC84746D8"
|
||||
record = (
|
||||
SimpleNamespace(
|
||||
operator_retirements=(
|
||||
SimpleNamespace(
|
||||
retirement_id="retirement-cli-blocked-target",
|
||||
retired_transport_ref=retired_transport_ref,
|
||||
),
|
||||
),
|
||||
operator_reconciliation_reopens=(),
|
||||
)
|
||||
if ledger_status == "resolved"
|
||||
else None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"PhysicalCommandLedger",
|
||||
lambda _root: SimpleNamespace(
|
||||
snapshot=lambda: SimpleNamespace(status=ledger_status, record=record)
|
||||
),
|
||||
)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"prompt_wifi_credentials",
|
||||
lambda: calls.append("credentials") or ("ssid", "secret"),
|
||||
)
|
||||
|
||||
async def forbidden_provision(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
calls.append("ble-write")
|
||||
raise AssertionError("retired CLI target must not reach BLE")
|
||||
|
||||
monkeypatch.setattr(cli, "provision_wifi_once", forbidden_provision)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"ble",
|
||||
"wifi-configure",
|
||||
"--device",
|
||||
retired_transport_ref.lower(),
|
||||
"--out",
|
||||
str(tmp_path / "wifi-result.json"),
|
||||
"--profile",
|
||||
cli.PROFILE_ID,
|
||||
"--confirm-write",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "blocked before credential or device access" in result.stdout
|
||||
assert calls == []
|
||||
assert not (tmp_path / "wifi-result.json").exists()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,11 @@ def test_operation_journal_reuses_an_idempotent_request_without_storing_input()
|
||||
|
||||
def test_operation_journal_records_ack_progress_and_terminal_result() -> None:
|
||||
journal = OperationJournal()
|
||||
operation, _ = journal.begin("acquisition.prepare", cancellable=True)
|
||||
operation, _ = journal.begin(
|
||||
"acquisition.prepare",
|
||||
cancellable=True,
|
||||
context={"automatic_retry": False},
|
||||
)
|
||||
|
||||
journal.transition(
|
||||
operation.operation_id,
|
||||
@@ -61,6 +65,14 @@ def test_operation_journal_records_ack_progress_and_terminal_result() -> None:
|
||||
assert document["result"] == {"acquisition_id": "acq-test"}
|
||||
assert document["evidence_refs"] == ["evidence-manifest-test"]
|
||||
assert document["completed_at"] is not None
|
||||
assert document["context"] == {"automatic_retry": False}
|
||||
assert [event["stage_code"] for event in document["events"]] == [
|
||||
"accepted",
|
||||
"compatibility-check",
|
||||
"prepared",
|
||||
]
|
||||
assert document["events"][-1]["status"] == "succeeded"
|
||||
assert document["events"][-1]["automatic_retry"] is False
|
||||
|
||||
with pytest.raises(ValueError, match="already terminal"):
|
||||
journal.transition(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.web.frontend_assets import (
|
||||
HASHED_ASSET_IMMUTABLE,
|
||||
HTML_NO_STORE,
|
||||
ControlStationStaticFiles,
|
||||
frontend_build_id,
|
||||
)
|
||||
|
||||
|
||||
def _frontend(tmp_path: Path) -> Path:
|
||||
frontend = tmp_path / "dist"
|
||||
assets = frontend / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
(frontend / "index.html").write_text(
|
||||
"""<!doctype html><html><body>
|
||||
<script type="module" crossorigin src="/assets/index-dT7dN-y4.js"></script>
|
||||
</body></html>""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(assets / "index-dT7dN-y4.js").write_text("export {};", encoding="utf-8")
|
||||
(assets / "runtime.js").write_text("export {};", encoding="utf-8")
|
||||
return frontend
|
||||
|
||||
|
||||
def test_frontend_build_id_is_exact_hashed_entry_module(tmp_path: Path) -> None:
|
||||
frontend = _frontend(tmp_path)
|
||||
|
||||
assert frontend_build_id(frontend) == "/assets/index-dT7dN-y4.js"
|
||||
|
||||
(frontend / "index.html").write_text(
|
||||
'<script type="module" src="/assets/runtime.js"></script>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert frontend_build_id(frontend) is None
|
||||
|
||||
|
||||
def test_spa_shell_is_no_store_and_only_hashed_assets_are_immutable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
frontend = _frontend(tmp_path)
|
||||
app = FastAPI()
|
||||
app.mount("/", ControlStationStaticFiles(directory=frontend, html=True))
|
||||
client = TestClient(app)
|
||||
|
||||
shell = client.get("/")
|
||||
hashed = client.get("/assets/index-dT7dN-y4.js")
|
||||
unhashed = client.get("/assets/runtime.js")
|
||||
|
||||
assert shell.status_code == 200
|
||||
assert shell.headers["cache-control"] == HTML_NO_STORE
|
||||
assert hashed.status_code == 200
|
||||
assert hashed.headers["cache-control"] == HASHED_ASSET_IMMUTABLE
|
||||
assert unhashed.status_code == 200
|
||||
assert "immutable" not in unhashed.headers.get("cache-control", "")
|
||||
@@ -104,8 +104,13 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
|
||||
assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index(
|
||||
"xgridsK1Api.startAcquisition"
|
||||
)
|
||||
assert "isSoftwareCommandedAcquisition(state)" in hook_source
|
||||
assert '? "graceful" : "capture-only"' in hook_source
|
||||
assert "isSoftwareCommandedAcquisition(currentState)" in hook_source
|
||||
assert hook_source.index("isSoftwareCommandedAcquisition(currentState)") < (
|
||||
hook_source.index('mode: "graceful"')
|
||||
)
|
||||
assert hook_source.index('mode: "graceful"') < hook_source.index(
|
||||
'mode: "capture-only"'
|
||||
)
|
||||
assert "state.device_ref" in runtime_source
|
||||
assert "instanceId: deviceRef.device_id" in runtime_source
|
||||
assert "acquisition?.acquisition_id" in runtime_source
|
||||
@@ -113,7 +118,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
|
||||
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
|
||||
|
||||
|
||||
def test_xgrids_live_copy_exposes_one_response_gated_launch_intent() -> None:
|
||||
def test_xgrids_live_copy_exposes_response_gated_prepare_and_one_physical_start() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
connection_source = (
|
||||
repository_root
|
||||
@@ -125,16 +130,26 @@ def test_xgrids_live_copy_exposes_one_response_gated_launch_intent() -> None:
|
||||
/ "K1AcquisitionPipeline.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
assert "Запустить сканирование и локальный приём" in connection_source
|
||||
assert "Имя войдёт в единственный канонический START" in connection_source
|
||||
assert "этапы идут строго по записанному порядку и только после ответов K1" in (
|
||||
assert "Запустить приём" in connection_source
|
||||
assert "Запустить K1" not in connection_source
|
||||
assert "Одно нажатие выполняет каноническую подготовку и один START" in (
|
||||
connection_source
|
||||
)
|
||||
assert "Проверить условия и отправить START" not in connection_source
|
||||
assert "Физический START отправляется только после отдельного финального окна" not in (
|
||||
connection_source
|
||||
)
|
||||
assert "Имя войдёт в единственный канонический START" in connection_source
|
||||
assert "один START после подтверждённого READY" in connection_source
|
||||
assert "Автоматических повторов команд нет" in connection_source
|
||||
assert "Подключить управление K1" not in connection_source
|
||||
assert "Открыть рабочее пространство K1" not in connection_source
|
||||
assert "Сохранить проект и подготовить локальный приём" not in connection_source
|
||||
assert "Остановить локальный приём" in connection_source
|
||||
assert "Физическое состояние сканера остаётся неизвестным" in connection_source
|
||||
assert "Аварийно завершить локальный приём" in connection_source
|
||||
assert "Состояние сканирования остаётся неизвестным" in connection_source
|
||||
assert "Физическое состояние сканера остаётся неизвестным" not in (
|
||||
connection_source
|
||||
)
|
||||
|
||||
|
||||
def test_xgrids_start_uses_atomic_automatic_source_transition() -> None:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_k1_connection_acceptance_manifest_covers_every_canonical_scenario() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
canonical_path = repository_root / "docs" / "20_K1_CONNECTION_SUPERVISION_CANON.md"
|
||||
manifest_path = repository_root / "docs" / "k1-connection-acceptance.manifest.json"
|
||||
canonical_ids = set(re.findall(r"\bCONN-\d{2}\b", canonical_path.read_text("utf-8")))
|
||||
manifest = json.loads(manifest_path.read_text("utf-8"))
|
||||
scenarios = manifest["scenarios"]
|
||||
manifest_ids = [scenario["id"] for scenario in scenarios]
|
||||
|
||||
assert manifest["schema_version"] == "missioncore.k1-connection-acceptance/v1"
|
||||
assert len(manifest_ids) == len(set(manifest_ids))
|
||||
assert set(manifest_ids) == canonical_ids
|
||||
assert all(
|
||||
scenario["status"] in {"software-covered", "partial", "planned"} for scenario in scenarios
|
||||
)
|
||||
for scenario in scenarios:
|
||||
assert isinstance(scenario["remaining"], list)
|
||||
if scenario["status"] in {"partial", "planned"}:
|
||||
assert scenario["remaining"]
|
||||
for relative_path in scenario["test_files"]:
|
||||
assert (repository_root / relative_path).is_file(), (
|
||||
scenario["id"],
|
||||
relative_path,
|
||||
)
|
||||
|
||||
by_id = {scenario["id"]: scenario for scenario in scenarios}
|
||||
assert by_id["CONN-39"] == {
|
||||
"id": "CONN-39",
|
||||
"status": "software-covered",
|
||||
"test_files": ["tests/test_xgrids_network_mutation_ledger.py"],
|
||||
"remaining": [],
|
||||
}
|
||||
assert by_id["CONN-67"] == {
|
||||
"id": "CONN-67",
|
||||
"status": "partial",
|
||||
"test_files": ["tests/test_xgrids_acquisition_lifecycle.py"],
|
||||
"remaining": [
|
||||
"real K1 repeated same-mode and cross-mode reconnect acceptance",
|
||||
],
|
||||
}
|
||||
@@ -97,6 +97,8 @@ def test_live_ingress_wire_is_self_delimiting_and_explicitly_non_authoritative()
|
||||
header_bytes = struct.unpack("!I", encoded[:4])[0]
|
||||
header = json.loads(encoded[4 : 4 + header_bytes])
|
||||
assert header["schema_version"] == LIVE_INGRESS_WIRE_SCHEMA
|
||||
assert header["session_id"] == "session-1"
|
||||
assert header["session_generation"] == 1
|
||||
assert header["payload_bytes"] == 4
|
||||
assert header["commands_enabled"] is False
|
||||
assert header["navigation_or_safety_accepted"] is False
|
||||
@@ -157,14 +159,45 @@ def test_live_ingress_new_session_discards_queued_events_from_previous_session()
|
||||
events.append(event)
|
||||
assert len(events) == 1
|
||||
assert events[0].session_id == "session-2"
|
||||
assert events[0].session_generation == 2
|
||||
assert events[0].modality == "control"
|
||||
assert events[0].payload == b'{"event":"session-start"}'
|
||||
|
||||
|
||||
def test_live_result_admission_is_bound_to_exact_session_generation() -> None:
|
||||
ingress = LivePerceptionIngress()
|
||||
ingress.begin_session("session-1")
|
||||
first_generation = ingress.snapshot()["session_generation"]
|
||||
ingress.end_session("session-1")
|
||||
ingress.begin_session("session-2")
|
||||
second_generation = ingress.snapshot()["session_generation"]
|
||||
received: list[str] = []
|
||||
|
||||
assert (
|
||||
ingress.admit_result(
|
||||
session_id="session-1",
|
||||
session_generation=first_generation,
|
||||
receiver=lambda: not received.append("stale"),
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
ingress.admit_result(
|
||||
session_id="session-2",
|
||||
session_generation=second_generation,
|
||||
receiver=lambda: not received.append("current"),
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert received == ["current"]
|
||||
|
||||
|
||||
def test_live_result_round_trip_keeps_video_mask_boxes_and_shadow_authority() -> None:
|
||||
mask = np.zeros((600, 800), dtype=np.uint8)
|
||||
mask[100:120, 200:240] = 4
|
||||
encoded = encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=12,
|
||||
source_frame_index=44,
|
||||
session_seconds=1.25,
|
||||
@@ -188,6 +221,8 @@ def test_live_result_round_trip_keeps_video_mask_boxes_and_shadow_authority() ->
|
||||
frame = decode_live_perception_result(encoded)
|
||||
|
||||
assert frame.frame_index == 12
|
||||
assert frame.session_id == "session-1"
|
||||
assert frame.session_generation == 1
|
||||
assert frame.source_frame_index == 44
|
||||
assert frame.image_jpeg == b"\xff\xd8test\xff\xd9"
|
||||
assert frame.segmentation_mask is not None
|
||||
@@ -200,6 +235,8 @@ def test_live_result_round_trip_keeps_video_mask_boxes_and_shadow_authority() ->
|
||||
def test_live_result_rejects_tampering_and_partial_cuboid() -> None:
|
||||
with pytest.raises(ValueError, match="cuboid is incomplete"):
|
||||
encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=0,
|
||||
source_frame_index=0,
|
||||
session_seconds=0.0,
|
||||
@@ -218,6 +255,8 @@ def test_live_result_rejects_tampering_and_partial_cuboid() -> None:
|
||||
delivery={"health": "degraded"},
|
||||
)
|
||||
encoded = encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=0,
|
||||
source_frame_index=0,
|
||||
session_seconds=0.0,
|
||||
|
||||
@@ -7,18 +7,24 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.compute.live_perception import (
|
||||
LivePerceptionIngress,
|
||||
encode_live_perception_result,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.live_perception_shadow import (
|
||||
build_live_perception_result_receiver,
|
||||
build_live_perception_shadow_router,
|
||||
ensure_live_shadow_token,
|
||||
)
|
||||
|
||||
|
||||
def test_shadow_token_is_stable_and_private(tmp_path: Path) -> None:
|
||||
def test_shadow_token_is_stable_and_private(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("MISSIONCORE_DATA_DIR", raising=False)
|
||||
path, token = ensure_live_shadow_token(tmp_path)
|
||||
repeated_path, repeated_token = ensure_live_shadow_token(tmp_path)
|
||||
|
||||
@@ -36,9 +42,7 @@ def test_shadow_router_exposes_only_the_exclusive_binary_stream() -> None:
|
||||
bearer_token="x" * 43,
|
||||
)
|
||||
assert len(router.routes) == 1
|
||||
assert router.routes[0].path == (
|
||||
"/api/v1/device-plugins/test-plugin/live-perception-shadow"
|
||||
)
|
||||
assert router.routes[0].path == ("/api/v1/device-plugins/test-plugin/live-perception-shadow")
|
||||
|
||||
|
||||
def test_shadow_router_accepts_only_validated_diagnostic_results_back() -> None:
|
||||
@@ -59,6 +63,8 @@ def test_shadow_router_accepts_only_validated_diagnostic_results_back() -> None:
|
||||
)
|
||||
ingress.begin_session("session-1")
|
||||
encoded = encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=0,
|
||||
source_frame_index=0,
|
||||
session_seconds=0.0,
|
||||
@@ -100,6 +106,95 @@ def test_shadow_router_accepts_only_validated_diagnostic_results_back() -> None:
|
||||
assert received == [encoded]
|
||||
|
||||
|
||||
def test_shadow_result_receiver_rejects_previous_acquisition_generation() -> None:
|
||||
ingress = LivePerceptionIngress()
|
||||
published: list[tuple[str, int]] = []
|
||||
receiver = build_live_perception_result_receiver(
|
||||
ingress,
|
||||
lambda frame: not published.append((frame.session_id, frame.session_generation)),
|
||||
)
|
||||
ingress.begin_session("session-1")
|
||||
encoded = encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=0,
|
||||
source_frame_index=0,
|
||||
session_seconds=0.0,
|
||||
captured_at_epoch_ns=1,
|
||||
image_jpeg=bytes.fromhex("ffd878ffd9"),
|
||||
segmentation_mask=None,
|
||||
objects=[],
|
||||
delivery={"health": "healthy"},
|
||||
)
|
||||
ingress.end_session("session-1")
|
||||
ingress.begin_session("session-2")
|
||||
|
||||
assert receiver(encoded) is False
|
||||
assert published == []
|
||||
snapshot = ingress.snapshot()
|
||||
assert snapshot["results_accepted"] == 0
|
||||
assert snapshot["results_rejected_stale"] == 1
|
||||
assert snapshot["results_rejected_receiver"] == 0
|
||||
|
||||
|
||||
def test_shadow_router_closes_a_worker_that_publishes_for_previous_session() -> None:
|
||||
ingress = LivePerceptionIngress()
|
||||
published: list[int] = []
|
||||
receiver = build_live_perception_result_receiver(
|
||||
ingress,
|
||||
lambda frame: not published.append(frame.frame_index),
|
||||
)
|
||||
router = build_live_perception_shadow_router(
|
||||
ingress,
|
||||
"test-plugin",
|
||||
bearer_token="x" * 43,
|
||||
result_receiver=receiver,
|
||||
)
|
||||
ingress.begin_session("session-1")
|
||||
stale = encode_live_perception_result(
|
||||
session_id="session-1",
|
||||
session_generation=1,
|
||||
frame_index=7,
|
||||
source_frame_index=7,
|
||||
session_seconds=0.0,
|
||||
captured_at_epoch_ns=1,
|
||||
image_jpeg=bytes.fromhex("ffd878ffd9"),
|
||||
segmentation_mask=None,
|
||||
objects=[],
|
||||
delivery={"health": "healthy"},
|
||||
)
|
||||
ingress.end_session("session-1")
|
||||
ingress.begin_session("session-2")
|
||||
|
||||
class StaleResultWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.headers = {"authorization": f"Bearer {'x' * 43}"}
|
||||
self.closed: list[dict[str, object]] = []
|
||||
|
||||
async def accept(self) -> None:
|
||||
return
|
||||
|
||||
async def receive(self) -> dict[str, object]:
|
||||
await asyncio.sleep(0)
|
||||
return {"type": "websocket.receive", "bytes": stale}
|
||||
|
||||
async def send_bytes(self, _payload: bytes) -> None:
|
||||
return
|
||||
|
||||
async def close(self, **values: object) -> None:
|
||||
self.closed.append(values)
|
||||
|
||||
websocket = StaleResultWebSocket()
|
||||
asyncio.run(router.routes[0].endpoint(websocket)) # type: ignore[attr-defined]
|
||||
|
||||
assert published == []
|
||||
assert any(
|
||||
item.get("code") == 1008 and item.get("reason") == "Shadow result session is stale"
|
||||
for item in websocket.closed
|
||||
)
|
||||
assert ingress.snapshot()["results_rejected_stale"] == 1
|
||||
|
||||
|
||||
def test_shadow_router_does_not_discard_ingress_while_receiving_results() -> None:
|
||||
class SlowIngress(LivePerceptionIngress):
|
||||
def take_next(
|
||||
@@ -135,9 +230,7 @@ def test_shadow_router_does_not_discard_ingress_while_receiving_results() -> Non
|
||||
payload=modality.encode(),
|
||||
)
|
||||
ingress.end_session("session-1")
|
||||
expected_events = sum(
|
||||
int(queue["depth"]) for queue in ingress.snapshot()["queues"].values()
|
||||
)
|
||||
expected_events = sum(int(queue["depth"]) for queue in ingress.snapshot()["queues"].values())
|
||||
|
||||
class DuplexFakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
@@ -172,6 +265,4 @@ def test_shadow_router_does_not_discard_ingress_while_receiving_results() -> Non
|
||||
assert len(received) == 8
|
||||
assert len(websocket.sent) == expected_events
|
||||
snapshot = ingress.snapshot()
|
||||
assert sum(
|
||||
int(queue["consumed"]) for queue in snapshot["queues"].values()
|
||||
) == expected_events
|
||||
assert sum(int(queue["consumed"]) for queue in snapshot["queues"].values()) == expected_events
|
||||
|
||||
@@ -82,6 +82,99 @@ class FakeClient:
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
class ConnectionLostAfterMessageClient(FakeClient):
|
||||
"""Emit one complete subscription/message cycle, then lose the socket."""
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
if self._step < 3: # noqa: SLF001 - deterministic MQTT test double
|
||||
return super().loop(timeout)
|
||||
assert timeout > 0
|
||||
self._step += 1 # noqa: SLF001
|
||||
return mqtt.MQTT_ERR_CONN_LOST
|
||||
|
||||
|
||||
class SubscriptionUnavailableClient(FakeClient):
|
||||
"""Accept TCP/CONNACK, then fail the transient recovery subscription."""
|
||||
|
||||
def subscribe(self, topics: Any) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||
self.subscribe_calls.append(topics)
|
||||
return mqtt.MQTT_ERR_NO_CONN, 0
|
||||
|
||||
|
||||
class SubscribedWithoutPointCloudClient(FakeClient):
|
||||
"""Reach SUBACK, emit only non-PCL reports, then lose the socket."""
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
if self._step < 2: # noqa: SLF001 - deterministic MQTT test double
|
||||
return super().loop(timeout)
|
||||
assert timeout > 0
|
||||
self._step += 1 # noqa: SLF001
|
||||
if self._step == 3: # noqa: SLF001
|
||||
assert self.on_message is not None
|
||||
message = mqtt.MQTTMessage(topic=b"lixel/application/report/heartbeat")
|
||||
message.payload = b"fresh-heartbeat"
|
||||
message.qos = 0
|
||||
self.on_message(self, None, message)
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
if self._step == 4: # noqa: SLF001
|
||||
assert self.on_message is not None
|
||||
message = mqtt.MQTTMessage(topic=b"lixel/application/report/device_status")
|
||||
message.payload = b"fresh-status"
|
||||
message.qos = 0
|
||||
self.on_message(self, None, message)
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
if self._step == 5: # noqa: SLF001
|
||||
assert self.on_message is not None
|
||||
message = mqtt.MQTTMessage(topic=b"lixel/application/report/lio_pose")
|
||||
message.payload = b"fresh-pose"
|
||||
message.qos = 0
|
||||
self.on_message(self, None, message)
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
return mqtt.MQTT_ERR_CONN_LOST
|
||||
|
||||
|
||||
class SpatialSequenceClient(FakeClient):
|
||||
def __init__(self, messages: list[tuple[str, bytes, bool]]) -> None:
|
||||
super().__init__()
|
||||
self._messages = messages
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
if self._step < 2: # noqa: SLF001 - deterministic MQTT test double
|
||||
return super().loop(timeout)
|
||||
assert timeout > 0
|
||||
self._step += 1 # noqa: SLF001
|
||||
message_index = self._step - 3 # noqa: SLF001
|
||||
if message_index < len(self._messages):
|
||||
assert self.on_message is not None
|
||||
topic, payload, retain = self._messages[message_index]
|
||||
message = mqtt.MQTTMessage(topic=topic.encode())
|
||||
message.payload = payload
|
||||
message.qos = 0
|
||||
message.retain = retain
|
||||
self.on_message(self, None, message)
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
class LateOldClientCallbackRecoveryClient(SpatialSequenceClient):
|
||||
"""Replay one late callback from the retired client before fresh data."""
|
||||
|
||||
def __init__(self, old_client: FakeClient) -> None:
|
||||
super().__init__([("RealtimePointcloud", b"fresh-new-client-pcl", False)])
|
||||
self._old_client = old_client
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
if self._step < 2: # noqa: SLF001 - deterministic MQTT test double
|
||||
return super().loop(timeout)
|
||||
if self._step == 2: # noqa: SLF001
|
||||
assert self._old_client.on_message is not None
|
||||
late = mqtt.MQTTMessage(topic=b"RealtimePointcloud")
|
||||
late.payload = b"late-old-client-pcl"
|
||||
late.qos = 0
|
||||
self._old_client.on_message(self._old_client, None, late)
|
||||
return super().loop(timeout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address", ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.4.2"])
|
||||
def test_validate_private_ipv4_accepts_only_rfc1918(address: str) -> None:
|
||||
assert validate_private_ipv4(address) == address
|
||||
@@ -96,6 +189,25 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
||||
validate_private_ipv4(address)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attempt", "expected"),
|
||||
[
|
||||
(1, 0.5),
|
||||
(2, 1.0),
|
||||
(3, 2.0),
|
||||
(4, 4.0),
|
||||
(5, 5.0),
|
||||
(1025, 5.0),
|
||||
(10**100, 5.0),
|
||||
],
|
||||
)
|
||||
def test_recovery_backoff_saturates_without_unbounded_exponentiation(
|
||||
attempt: int,
|
||||
expected: float,
|
||||
) -> None:
|
||||
assert capture_module._recovery_backoff_seconds(attempt) == expected # noqa: SLF001
|
||||
|
||||
|
||||
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
observed = []
|
||||
@@ -176,6 +288,7 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
||||
for artifact_name in (
|
||||
"mqtt.raw.k1mqtt",
|
||||
"mqtt.metadata.jsonl",
|
||||
"mqtt.recovery.jsonl",
|
||||
"mqtt.timeline.origin.json",
|
||||
"mqtt.timeline.json",
|
||||
"mqtt.summary.json",
|
||||
@@ -264,6 +377,360 @@ def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path
|
||||
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
|
||||
|
||||
|
||||
def test_guarded_recovery_keeps_one_evidence_writer_and_never_publishes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
second = FakeClient(topic="RealtimePointcloud", payload=b"after-recovery")
|
||||
clients = iter((first, second))
|
||||
losses: list[str] = []
|
||||
attempts: list[int] = []
|
||||
candidates: list[tuple[int, int]] = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "recovering-capture",
|
||||
duration_seconds=30,
|
||||
on_connection_lost=losses.append,
|
||||
recover_connection=lambda attempt: attempts.append(attempt) or "resume",
|
||||
on_recovery_point_cloud_candidate=lambda attempt, sequence: candidates.append(
|
||||
(attempt, sequence)
|
||||
),
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
frames = list(iter_capture_frames(tmp_path / "recovering-capture" / "mqtt.raw.k1mqtt"))
|
||||
assert [frame.payload for frame in frames] == [b"before-loss", b"after-recovery"]
|
||||
assert losses and "network loop failed" in losses[0]
|
||||
assert attempts == [1]
|
||||
assert candidates == [(1, 2)]
|
||||
assert summary["reconnect_enabled"] is True
|
||||
assert summary["recovery_attempts"] == 1
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
assert summary["recovery_blocked"] is False
|
||||
assert summary["publishing_enabled"] is False
|
||||
assert first.subscribe_calls and second.subscribe_calls
|
||||
|
||||
|
||||
def test_recovery_success_requires_one_exact_post_publish_confirmation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
second = FakeClient(topic="RealtimePointcloud", payload=b"after-recovery")
|
||||
clients = iter((first, second))
|
||||
confirmers: list[Callable[[int], bool]] = []
|
||||
confirmation_results: list[bool] = []
|
||||
|
||||
def on_message_recorded(message: object) -> None:
|
||||
if getattr(message, "payload", None) != b"after-recovery":
|
||||
return
|
||||
confirmer = confirmers[0]
|
||||
confirmation_results.extend(
|
||||
(
|
||||
confirmer(999), # stale/future attempt
|
||||
confirmer(1), # exact post-publication edge
|
||||
confirmer(1), # duplicate edge
|
||||
)
|
||||
)
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "confirmed-recovery",
|
||||
duration_seconds=30,
|
||||
on_message_recorded=on_message_recorded,
|
||||
recover_connection=lambda _attempt: "resume",
|
||||
on_recovery_confirmer_ready=confirmers.append,
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
assert confirmation_results == [False, True, False]
|
||||
assert summary["recovery_attempts"] == 1
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
assert summary["successful_recoveries"] == 1
|
||||
assert len(summary["recovery_gaps"]) == 1
|
||||
gap = summary["recovery_gaps"][0]
|
||||
assert gap["gap_index"] == 1
|
||||
assert gap["recovery_attempt"] == 1
|
||||
assert gap["outcome"] == "recovered"
|
||||
assert gap["ended_monotonic_ns"] >= gap["started_monotonic_ns"]
|
||||
assert gap["duration_seconds"] >= 0
|
||||
|
||||
journal_path = tmp_path / "confirmed-recovery" / "mqtt.recovery.jsonl"
|
||||
journal = [json.loads(line) for line in journal_path.read_text().splitlines()]
|
||||
assert [record["record_type"] for record in journal] == [
|
||||
"recovery_gap_started",
|
||||
"recovery_gap_ended",
|
||||
]
|
||||
assert journal[1]["gap_index"] == gap["gap_index"]
|
||||
assert journal[1]["recovery_attempt"] == gap["recovery_attempt"]
|
||||
assert journal[1]["outcome"] == gap["outcome"]
|
||||
assert summary["artifact_hashes"]["recovery_gaps_jsonl_sha256"] == hashlib.sha256(
|
||||
journal_path.read_bytes()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wake_step", [3, 4])
|
||||
def test_owner_wake_enters_same_single_recovery_loop_before_or_with_paho_loss(
|
||||
tmp_path: Path,
|
||||
wake_step: int,
|
||||
) -> None:
|
||||
first: FakeClient = (
|
||||
FakeClient(payload=b"before-owner-wake")
|
||||
if wake_step == 3
|
||||
else ConnectionLostAfterMessageClient(payload=b"before-owner-wake")
|
||||
)
|
||||
second = FakeClient(topic="RealtimePointcloud", payload=b"after-owner-wake")
|
||||
clients = iter((first, second))
|
||||
wake_consumed = False
|
||||
losses: list[str] = []
|
||||
attempts: list[int] = []
|
||||
|
||||
def consume_owner_wake() -> str | None:
|
||||
nonlocal wake_consumed
|
||||
if not wake_consumed and first._step >= wake_step: # noqa: SLF001
|
||||
wake_consumed = True
|
||||
return "camera-source-ended"
|
||||
return None
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / f"owner-wake-{wake_step}",
|
||||
duration_seconds=30,
|
||||
on_connection_lost=losses.append,
|
||||
consume_connection_recovery_request=consume_owner_wake,
|
||||
recover_connection=lambda attempt: attempts.append(attempt) or "resume",
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
frames = list(
|
||||
iter_capture_frames(tmp_path / f"owner-wake-{wake_step}" / "mqtt.raw.k1mqtt")
|
||||
)
|
||||
assert [frame.payload for frame in frames] == [
|
||||
b"before-owner-wake",
|
||||
b"after-owner-wake",
|
||||
]
|
||||
assert wake_consumed is True
|
||||
assert len(losses) == 1
|
||||
assert attempts == [1]
|
||||
assert first.disconnect_count == 1
|
||||
assert second.disconnect_count == 1
|
||||
assert summary["recovery_attempts"] == 1
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
assert summary["message_count"] == 2
|
||||
|
||||
|
||||
def test_guarded_recovery_retries_transient_subscription_handshake_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
transient = SubscriptionUnavailableClient()
|
||||
recovered_client = FakeClient(topic="RealtimePointcloud", payload=b"after-recovery")
|
||||
clients = iter((first, transient, recovered_client))
|
||||
attempts: list[int] = []
|
||||
candidates: list[tuple[int, int]] = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "recovering-subscription",
|
||||
duration_seconds=30,
|
||||
recover_connection=lambda attempt: attempts.append(attempt) or "resume",
|
||||
on_recovery_point_cloud_candidate=lambda attempt, sequence: candidates.append(
|
||||
(attempt, sequence)
|
||||
),
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
frames = list(
|
||||
iter_capture_frames(tmp_path / "recovering-subscription" / "mqtt.raw.k1mqtt")
|
||||
)
|
||||
assert [frame.payload for frame in frames] == [b"before-loss", b"after-recovery"]
|
||||
assert attempts == [1, 2]
|
||||
assert candidates == [(2, 2)]
|
||||
assert summary["recovery_attempts"] == 2
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
|
||||
|
||||
def test_guarded_recovery_suback_and_pose_without_point_cloud_stay_reconnecting(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
subscribed_only = SubscribedWithoutPointCloudClient()
|
||||
recovered_client = SpatialSequenceClient(
|
||||
[("lixel/application/report/lio_pcl", b"fresh-pcl", False)]
|
||||
)
|
||||
clients = iter((first, subscribed_only, recovered_client))
|
||||
attempts: list[int] = []
|
||||
candidates: list[tuple[int, int]] = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "suback-without-spatial-data",
|
||||
duration_seconds=30,
|
||||
recover_connection=lambda attempt: attempts.append(attempt) or "resume",
|
||||
on_recovery_point_cloud_candidate=lambda attempt, sequence: candidates.append(
|
||||
(attempt, sequence)
|
||||
),
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
assert attempts == [1, 2]
|
||||
assert candidates == [(2, 5)]
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
assert summary["message_count"] == 5
|
||||
|
||||
|
||||
def test_guarded_recovery_arms_candidate_before_enqueueing_first_fresh_point_cloud(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
recovered_client = SpatialSequenceClient(
|
||||
[
|
||||
("lixel/application/report/heartbeat", b"heartbeat", False),
|
||||
("lixel/application/report/lio_pcl", b"retained-pcl", True),
|
||||
("DeviceStatus", b"status", False),
|
||||
("RealtimePath", b"fresh-pose", False),
|
||||
("RealtimePointcloud", b"fresh-pcl", False),
|
||||
("RealtimePath", b"later-pose", False),
|
||||
]
|
||||
)
|
||||
clients = iter((first, recovered_client))
|
||||
candidates: list[tuple[int, int]] = []
|
||||
events: list[str] = []
|
||||
|
||||
def record_candidate(attempt: int, sequence: int) -> None:
|
||||
candidates.append((attempt, sequence))
|
||||
events.append(f"candidate:{attempt}:{sequence}")
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "fresh-point-cloud-recovery",
|
||||
duration_seconds=30,
|
||||
on_message_recorded=lambda message: events.append(
|
||||
f"message:{message.payload.decode()}"
|
||||
),
|
||||
recover_connection=lambda _attempt: "resume",
|
||||
on_recovery_point_cloud_candidate=record_candidate,
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
assert candidates == [(1, 6)]
|
||||
assert events.index("message:fresh-pose") < events.index("message:fresh-pcl")
|
||||
assert events.index("candidate:1:6") < events.index("message:fresh-pcl")
|
||||
assert events.count("candidate:1:6") == 1
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
assert summary["message_count"] == 7
|
||||
|
||||
|
||||
def test_guarded_recovery_ignores_late_spatial_callback_from_retired_client(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
recovered_client = LateOldClientCallbackRecoveryClient(first)
|
||||
clients = iter((first, recovered_client))
|
||||
recorded_payloads: list[bytes] = []
|
||||
candidates: list[tuple[int, int]] = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "late-old-client-callback",
|
||||
duration_seconds=30,
|
||||
on_message_recorded=lambda message: recorded_payloads.append(message.payload),
|
||||
recover_connection=lambda _attempt: "resume",
|
||||
on_recovery_point_cloud_candidate=lambda attempt, sequence: candidates.append(
|
||||
(attempt, sequence)
|
||||
),
|
||||
_client_factory=lambda: cast(mqtt.Client, next(clients)),
|
||||
)
|
||||
|
||||
assert candidates == [(1, 2)]
|
||||
assert recorded_payloads == [b"before-loss", b"fresh-new-client-pcl"]
|
||||
assert summary["message_count"] == 2
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_point_cloud_candidates"] == 1
|
||||
|
||||
|
||||
def test_failed_resubscribe_consumes_resume_before_later_standby(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient(payload=b"before-loss")
|
||||
transient = SubscriptionUnavailableClient()
|
||||
clients = iter((first, transient))
|
||||
factory_calls = 0
|
||||
|
||||
def factory() -> mqtt.Client:
|
||||
nonlocal factory_calls
|
||||
factory_calls += 1
|
||||
return cast(mqtt.Client, next(clients))
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "recovery-standby-after-resubscribe-failure",
|
||||
duration_seconds=30,
|
||||
recover_connection=lambda attempt: "resume" if attempt == 1 else "standby",
|
||||
_client_factory=factory,
|
||||
)
|
||||
|
||||
assert factory_calls == 2
|
||||
assert summary["stop_reason"] == "recovery_standby"
|
||||
assert summary["recovery_attempts"] == 2
|
||||
assert summary["successful_recoveries"] == 0
|
||||
|
||||
|
||||
def test_guarded_recovery_can_stay_blocked_until_local_owner_finishes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = ConnectionLostAfterMessageClient()
|
||||
factory_calls = 0
|
||||
stop_checks = 0
|
||||
|
||||
def factory() -> mqtt.Client:
|
||||
nonlocal factory_calls
|
||||
factory_calls += 1
|
||||
return cast(mqtt.Client, first)
|
||||
|
||||
def should_stop() -> bool:
|
||||
nonlocal stop_checks
|
||||
stop_checks += 1
|
||||
return stop_checks >= 6
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "blocked-capture",
|
||||
duration_seconds=30,
|
||||
should_stop=should_stop,
|
||||
recover_connection=lambda _attempt: "blocked",
|
||||
_client_factory=factory,
|
||||
)
|
||||
|
||||
assert factory_calls == 1
|
||||
assert summary["stop_reason"] == "external_stop"
|
||||
assert summary["recovery_attempts"] == 1
|
||||
assert summary["successful_recoveries"] == 0
|
||||
assert summary["recovery_blocked"] is True
|
||||
|
||||
|
||||
def test_guarded_recovery_standby_is_truthful_non_error_completion(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "standby-capture",
|
||||
duration_seconds=30,
|
||||
recover_connection=lambda _attempt: "standby",
|
||||
_client_factory=lambda: cast(mqtt.Client, ConnectionLostAfterMessageClient()),
|
||||
)
|
||||
|
||||
assert summary["stop_reason"] == "recovery_standby"
|
||||
assert summary["message_count"] == 1
|
||||
assert summary["recovery_attempts"] == 1
|
||||
assert summary["successful_recoveries"] == 0
|
||||
|
||||
|
||||
def test_owner_seals_session_clock_after_all_producers_stop(tmp_path: Path) -> None:
|
||||
capture_dir = tmp_path / "capture"
|
||||
summary = capture_mqtt(
|
||||
|
||||
@@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||
)
|
||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||
assert plugin["metadata"]["version"] == "0.6.0"
|
||||
assert plugin["metadata"]["version"] == "0.7.5"
|
||||
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||
{
|
||||
@@ -125,7 +125,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
} <= action_ids
|
||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"pluginVersion": "0.6.0",
|
||||
"pluginVersion": "0.7.5",
|
||||
"id": "xgrids.lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"displayName": "XGRIDS LixelKity K1",
|
||||
|
||||
+730
-22
@@ -20,19 +20,49 @@ from pydantic import ValidationError
|
||||
|
||||
import k1link.web.device_plugin_composition as plugin_composition
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
ACTION_ACQUISITION_ABORT,
|
||||
ACTION_ACQUISITION_PREPARE,
|
||||
ACTION_ACQUISITION_START,
|
||||
ACTION_ACQUISITION_STOP,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
|
||||
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
|
||||
ACTION_CONFIGURED_ENDPOINT_PROBE,
|
||||
ACTION_CONNECTION_MODE_SELECT,
|
||||
ACTION_CONNECTION_RECONFIGURE_PREPARE,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_PHYSICAL_COMMAND_RECONCILE,
|
||||
ACTION_PHYSICAL_COMMAND_REOPEN_RETIRED_RECONCILIATION,
|
||||
ACTION_PHYSICAL_COMMAND_RETIRE_UNAVAILABLE,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
XGRIDS_K1_PLUGIN_VERSION,
|
||||
AbortAcquisitionRequest,
|
||||
BleScanRequest,
|
||||
CloseApplicationControlSessionRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConfiguredEndpointProbeRequest,
|
||||
ConnectionVerificationError,
|
||||
ConnectionVerifyRequest,
|
||||
ConnectRequest,
|
||||
DesiredConnectionModeRequest,
|
||||
EnterApplicationWorkspaceRequest,
|
||||
NetworkProvisioningConflict,
|
||||
OpenApplicationControlSessionRequest,
|
||||
PrepareAcquisitionRequest,
|
||||
PrepareConnectionReconfigurationRequest,
|
||||
ReconcilePhysicalCommandRequest,
|
||||
ReopenRetiredPhysicalCommandReconciliationRequest,
|
||||
RetireUnavailablePhysicalCommandRequest,
|
||||
SnapshotRuntimeConflict,
|
||||
StartAcquisitionRequest,
|
||||
StopAcquisitionRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
)
|
||||
@@ -57,6 +87,21 @@ from k1link.web.plugin_runtime import (
|
||||
class FakeXgridsService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
self.scan_loop: asyncio.AbstractEventLoop | None = None
|
||||
self.verify_loop: asyncio.AbstractEventLoop | None = None
|
||||
self.snapshot_runtime_id = "snapshot-runtime-test"
|
||||
self.bind_calls = 0
|
||||
|
||||
def require_snapshot_runtime_id(self, expected_snapshot_runtime_id: str) -> None:
|
||||
if expected_snapshot_runtime_id != self.snapshot_runtime_id:
|
||||
raise SnapshotRuntimeConflict()
|
||||
|
||||
def bind_runtime_event_loop(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop | None = None,
|
||||
) -> None:
|
||||
del loop
|
||||
self.bind_calls += 1
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
self.calls.append(("state", None))
|
||||
@@ -66,21 +111,60 @@ class FakeXgridsService:
|
||||
self.calls.append(("calibration", None))
|
||||
return {"status": "available", "snapshot_id": "fixture-snapshot"}
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||
self.calls.append(("scan", duration_seconds))
|
||||
async def scan_ble(self, request: BleScanRequest) -> dict[str, Any]:
|
||||
self.scan_loop = asyncio.get_running_loop()
|
||||
self.calls.append(("scan", request))
|
||||
return {"phase": "idle", "devices": []}
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
self.calls.append(("connect", request))
|
||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
|
||||
def verify_connection(
|
||||
def select_connection_mode(
|
||||
self,
|
||||
request: DesiredConnectionModeRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("mode-select", request))
|
||||
return {
|
||||
"phase": "idle",
|
||||
"desired_connection_mode": request.connection_mode,
|
||||
"desired_connection_mode_revision": request.expected_revision + 1,
|
||||
}
|
||||
|
||||
async def prepare_connection_reconfiguration(
|
||||
self,
|
||||
request: PrepareConnectionReconfigurationRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("reconfigure", request))
|
||||
return {
|
||||
"phase": "idle",
|
||||
"connection_reconfiguration": {
|
||||
"revision": request.expected_reconfiguration_revision + 1,
|
||||
"intent": request.intent,
|
||||
},
|
||||
}
|
||||
|
||||
async def verify_connection(
|
||||
self,
|
||||
request: ConnectionVerifyRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.verify_loop = asyncio.get_running_loop()
|
||||
self.calls.append(("verify", request))
|
||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
|
||||
async def probe_configured_endpoint(
|
||||
self,
|
||||
request: ConfiguredEndpointProbeRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("endpoint-probe", request))
|
||||
return {
|
||||
"phase": "idle",
|
||||
"configured_endpoint_probe": {
|
||||
"status": "reachable",
|
||||
"ble_operation_performed": False,
|
||||
},
|
||||
}
|
||||
|
||||
def start_live(
|
||||
self,
|
||||
project_name: str,
|
||||
@@ -101,10 +185,87 @@ class FakeXgridsService:
|
||||
self.calls.append(("stop", None))
|
||||
return {"phase": "idle"}
|
||||
|
||||
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
|
||||
self.calls.append(("prepare", request))
|
||||
return {"phase": "connected", "acquisition": {"state": "prepared"}}
|
||||
|
||||
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
|
||||
self.calls.append(("start", request))
|
||||
return {"phase": "starting_live"}
|
||||
|
||||
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
|
||||
self.calls.append(("acquisition-stop", request))
|
||||
return {"phase": "stopping"}
|
||||
|
||||
def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]:
|
||||
self.calls.append(("abort", request))
|
||||
return {"phase": "idle"}
|
||||
|
||||
def open_application_control_session(
|
||||
self,
|
||||
request: OpenApplicationControlSessionRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("control-open", request))
|
||||
return {"phase": "connected"}
|
||||
|
||||
def enter_application_workspace(
|
||||
self,
|
||||
request: EnterApplicationWorkspaceRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("control-enter", request))
|
||||
return {"phase": "connected"}
|
||||
|
||||
def close_application_control_session(
|
||||
self,
|
||||
request: CloseApplicationControlSessionRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("control-close", request))
|
||||
return {"phase": "connected"}
|
||||
|
||||
def reconcile_physical_command(
|
||||
self,
|
||||
request: ReconcilePhysicalCommandRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("physical-reconcile", request))
|
||||
return {"phase": "connected"}
|
||||
|
||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
self.calls.append(("viewer", request))
|
||||
return {"phase": "idle", "viewer_settings": request.model_dump()}
|
||||
|
||||
def retire_unavailable_physical_command(
|
||||
self,
|
||||
request: RetireUnavailablePhysicalCommandRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("physical-retire", request))
|
||||
return {
|
||||
"phase": "idle",
|
||||
"physical_command": {
|
||||
"status": "resolved",
|
||||
"physical_outcome": "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
def reopen_retired_physical_command_reconciliation(
|
||||
self,
|
||||
request: ReopenRetiredPhysicalCommandReconciliationRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("physical-reopen", request))
|
||||
return {
|
||||
"phase": "idle",
|
||||
"physical_command": {
|
||||
"status": "unresolved",
|
||||
"requires_reconciliation": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_fenced(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
**(payload or {}),
|
||||
"expected_snapshot_runtime_id": "snapshot-runtime-test",
|
||||
}
|
||||
|
||||
|
||||
def _in_process_runtime(
|
||||
adapter: Any,
|
||||
@@ -151,9 +312,7 @@ def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
|
||||
|
||||
def test_calibration_snapshot_action_calls_the_read_only_service_method() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
@@ -169,22 +328,21 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non
|
||||
|
||||
def test_connection_verify_action_accepts_read_only_adoption_request() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
{
|
||||
_snapshot_fenced({
|
||||
"device_id": "test-ble-transport",
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
},
|
||||
"expected_discovery_generation": 0,
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -200,15 +358,13 @@ def test_connection_verify_action_accepts_read_only_adoption_request() -> None:
|
||||
|
||||
def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
{},
|
||||
_snapshot_fenced(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -219,6 +375,548 @@ def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> No
|
||||
assert request.compatibility_attestation is None
|
||||
|
||||
|
||||
def test_connection_mode_select_delegates_exact_cas_payload() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_MODE_SELECT,
|
||||
_snapshot_fenced({
|
||||
"connection_mode": "quick-connect",
|
||||
"expected_revision": 7,
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["desired_connection_mode"] == "quick-connect"
|
||||
assert result["desired_connection_mode_revision"] == 8
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "mode-select"
|
||||
assert isinstance(request, DesiredConnectionModeRequest)
|
||||
assert request.connection_mode == "quick-connect"
|
||||
assert request.expected_revision == 7
|
||||
|
||||
|
||||
def test_connection_scenario_reset_dispatches_before_runtime_loop_binding() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_MODE_SELECT,
|
||||
_snapshot_fenced(
|
||||
{
|
||||
"connection_mode": "bridge",
|
||||
"expected_revision": 3,
|
||||
"reset_scenario": True,
|
||||
"reset_id": "op-reset-dispatch-local-only-01",
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["desired_connection_mode_revision"] == 4
|
||||
assert service.bind_calls == 0
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "mode-select"
|
||||
assert isinstance(request, DesiredConnectionModeRequest)
|
||||
assert request.reset_scenario is True
|
||||
assert request.reset_id == "op-reset-dispatch-local-only-01"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("intent", "intent_id"),
|
||||
[("select-device", None), ("change-network", None), ("cancel", "intent-9")],
|
||||
)
|
||||
def test_connection_reconfigure_action_delegates_exact_stable_cas_payload(
|
||||
intent: str,
|
||||
intent_id: str | None,
|
||||
) -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
payload = {
|
||||
"intent": intent,
|
||||
"expected_reconfiguration_revision": 9,
|
||||
"expected_reconfiguration_intent_id": intent_id,
|
||||
"expected_desired_mode_revision": 4,
|
||||
"expected_active_binding_key": "a" * 64 if intent_id is None else None,
|
||||
}
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_RECONFIGURE_PREPARE,
|
||||
_snapshot_fenced(payload),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["connection_reconfiguration"] == {
|
||||
"revision": 10,
|
||||
"intent": intent,
|
||||
}
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "reconfigure"
|
||||
assert isinstance(request, PrepareConnectionReconfigurationRequest)
|
||||
assert request.model_dump(mode="json") == payload
|
||||
|
||||
|
||||
def test_configured_endpoint_probe_action_is_separate_from_ble_verify() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONFIGURED_ENDPOINT_PROBE,
|
||||
_snapshot_fenced(
|
||||
{"operation_id": "op-00000000-0000-4000-8000-000000000652"}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["configured_endpoint_probe"] == {
|
||||
"status": "reachable",
|
||||
"ble_operation_performed": False,
|
||||
}
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "endpoint-probe"
|
||||
assert isinstance(request, ConfiguredEndpointProbeRequest)
|
||||
assert request.operation_id == "op-00000000-0000-4000-8000-000000000652"
|
||||
|
||||
|
||||
def test_physical_retirement_action_delegates_exact_confirmed_cas_payload() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
payload = {
|
||||
"retirement_id": "retirement-browser-stable-id",
|
||||
"expected_operation_id": "physical-stop-persisted",
|
||||
"expected_revision": 17,
|
||||
"expected_transport_ref": "F89438FA-55ED-85AD-EED7-734AC84746D8",
|
||||
"operator_confirmed": True,
|
||||
"reason": "device-permanently-unavailable-or-replaced",
|
||||
}
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_PHYSICAL_COMMAND_RETIRE_UNAVAILABLE,
|
||||
_snapshot_fenced(payload),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["physical_command"] == {
|
||||
"status": "resolved",
|
||||
"physical_outcome": "unknown",
|
||||
}
|
||||
assert service.bind_calls == 0
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "physical-retire"
|
||||
assert isinstance(request, RetireUnavailablePhysicalCommandRequest)
|
||||
assert request.model_dump(mode="json") == payload
|
||||
|
||||
|
||||
def test_physical_reopen_action_is_runtime_fenced_exact_and_never_binds_loop() -> None:
|
||||
service = FakeXgridsService()
|
||||
adapter = XgridsK1PluginFacade(service)
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(adapter)])
|
||||
payload = {
|
||||
"reopening_id": "reopening-browser-stable-id",
|
||||
"expected_revision": 18,
|
||||
"expected_retirement_id": "retirement-browser-stable-id",
|
||||
"expected_transport_ref": "f89438fa-55ed-85ad-eed7-734ac84746d8",
|
||||
"expected_discovery_generation": 7,
|
||||
"expected_desired_mode": "bridge",
|
||||
"expected_desired_mode_revision": 4,
|
||||
"operator_confirmed": True,
|
||||
"reason": "device-returned-for-explicit-reconciliation",
|
||||
}
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_PHYSICAL_COMMAND_REOPEN_RETIRED_RECONCILIATION,
|
||||
_snapshot_fenced(payload),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["physical_command"] == {
|
||||
"status": "unresolved",
|
||||
"requires_reconciliation": True,
|
||||
}
|
||||
assert service.bind_calls == 0
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "physical-reopen"
|
||||
assert isinstance(request, ReopenRetiredPhysicalCommandReconciliationRequest)
|
||||
assert request.model_dump(mode="json") == payload
|
||||
assert ACTION_PHYSICAL_COMMAND_REOPEN_RETIRED_RECONCILIATION in adapter.action_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason_code",
|
||||
[
|
||||
"physical-command-reconciliation-reopen-stale-checkpoint",
|
||||
"network-provisioning-idempotency-operation-mismatch",
|
||||
"reconciliation-target-mode-mismatch",
|
||||
"reconciliation-target-physical-recovery-mismatch",
|
||||
"fresh-ble-candidate-required",
|
||||
"physical-command-recovery-target-not-observed",
|
||||
"network-provision-operation-active",
|
||||
"control-local-retirement-pending",
|
||||
"device-calibration-read-active",
|
||||
"acquisition-active",
|
||||
"acquisition-cleanup-pending",
|
||||
"acquisition-start-operation-active",
|
||||
"acquisition-stop-operation-active",
|
||||
"local-runtime-active",
|
||||
"control-session-not-admissible-for-network-change",
|
||||
"k1-lifecycle-process-lease-control-owned",
|
||||
"k1-lifecycle-process-lease-active",
|
||||
],
|
||||
)
|
||||
def test_physical_reopen_expected_conflicts_are_http_409(reason_code: str) -> None:
|
||||
class ReopenConflictService(FakeXgridsService):
|
||||
def reopen_retired_physical_command_reconciliation(
|
||||
self,
|
||||
request: ReopenRetiredPhysicalCommandReconciliationRequest,
|
||||
) -> dict[str, Any]:
|
||||
del request
|
||||
raise NetworkProvisioningConflict(
|
||||
"reopen checkpoint is no longer executable",
|
||||
reason_code=reason_code,
|
||||
)
|
||||
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(ReopenConflictService()))]
|
||||
)
|
||||
payload = {
|
||||
"reopening_id": "reopening-http-conflict",
|
||||
"expected_revision": 18,
|
||||
"expected_retirement_id": "retirement-http-conflict",
|
||||
"expected_transport_ref": "f89438fa-55ed-85ad-eed7-734ac84746d8",
|
||||
"expected_discovery_generation": 7,
|
||||
"expected_desired_mode": "bridge",
|
||||
"expected_desired_mode_revision": 4,
|
||||
"operator_confirmed": True,
|
||||
"reason": "device-returned-for-explicit-reconciliation",
|
||||
}
|
||||
|
||||
with pytest.raises(PluginExecutionError) as raised:
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_PHYSICAL_COMMAND_REOPEN_RETIRED_RECONCILIATION,
|
||||
_snapshot_fenced(payload),
|
||||
)
|
||||
)
|
||||
|
||||
assert raised.value.http_status_code == 409
|
||||
assert raised.value.reason_code == reason_code
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action_id",
|
||||
[
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_CONNECTION_MODE_SELECT,
|
||||
ACTION_CONNECTION_RECONFIGURE_PREPARE,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
ACTION_CONFIGURED_ENDPOINT_PROBE,
|
||||
ACTION_ACQUISITION_PREPARE,
|
||||
ACTION_ACQUISITION_START,
|
||||
ACTION_ACQUISITION_STOP,
|
||||
ACTION_ACQUISITION_ABORT,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
|
||||
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
|
||||
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
|
||||
ACTION_PHYSICAL_COMMAND_RECONCILE,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_PHYSICAL_COMMAND_RETIRE_UNAVAILABLE,
|
||||
ACTION_PHYSICAL_COMMAND_REOPEN_RETIRED_RECONCILIATION,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"runtime_fence",
|
||||
[None, "snapshot-runtime-stale-browser"],
|
||||
)
|
||||
def test_snapshot_fenced_actions_reject_stale_browser_before_service_or_io(
|
||||
action_id: str,
|
||||
runtime_fence: str | None,
|
||||
) -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
payload = (
|
||||
{}
|
||||
if runtime_fence is None
|
||||
else {"expected_snapshot_runtime_id": runtime_fence}
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError) as raised:
|
||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
|
||||
|
||||
assert raised.value.http_status_code == 409
|
||||
assert raised.value.reason_code == "snapshot-runtime-conflict"
|
||||
assert service.bind_calls == 0
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_lifecycle_ui_payload_contract_passes_backend_validation_exactly() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
compatibility = {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
}
|
||||
payloads = (
|
||||
(
|
||||
ACTION_ACQUISITION_PREPARE,
|
||||
{
|
||||
"operation_id": "op-00000000-0000-4000-8000-000000000201",
|
||||
"idempotency_key": (
|
||||
"acquisition.prepare:op-00000000-0000-4000-8000-000000000201"
|
||||
),
|
||||
"project_name": "CONTRACT01",
|
||||
"mount_type": "handheld",
|
||||
"gnss_mode": "none",
|
||||
"compatibility_attestation": compatibility,
|
||||
"expected_control_session_generation": 7,
|
||||
"expected_control_state_revision": 11,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_ACQUISITION_START,
|
||||
{
|
||||
"operation_id": "op-00000000-0000-4000-8000-000000000202",
|
||||
"idempotency_key": (
|
||||
"acquisition.start:op-00000000-0000-4000-8000-000000000202"
|
||||
),
|
||||
"acquisition_id": "acquisition-contract",
|
||||
"expected_control_session_generation": 7,
|
||||
"expected_control_state_revision": 12,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_ACQUISITION_STOP,
|
||||
{
|
||||
"operation_id": "op-00000000-0000-4000-8000-000000000203",
|
||||
"idempotency_key": (
|
||||
"acquisition.stop:op-00000000-0000-4000-8000-000000000203"
|
||||
),
|
||||
"acquisition_id": "acquisition-contract",
|
||||
"mode": "graceful",
|
||||
"expected_control_session_generation": 7,
|
||||
"expected_control_state_revision": 13,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_ACQUISITION_ABORT,
|
||||
{
|
||||
"operation_id": "op-00000000-0000-4000-8000-000000000204",
|
||||
"idempotency_key": (
|
||||
"acquisition.abort:op-00000000-0000-4000-8000-000000000204"
|
||||
),
|
||||
"acquisition_id": "acquisition-contract",
|
||||
"expected_control_session_generation": 7,
|
||||
"expected_control_state_revision": 14,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
|
||||
{
|
||||
"operator_present": True,
|
||||
"owner_controlled_device": True,
|
||||
"lixelgo_closed": True,
|
||||
"battery_storage_confirmed": True,
|
||||
"expected_physical_state_confirmed": True,
|
||||
"timezone_name": "Europe/Moscow",
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
|
||||
{
|
||||
"operator_confirmed": True,
|
||||
"expected_session_generation": 7,
|
||||
"expected_state_revision": 15,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
|
||||
{
|
||||
"expected_session_generation": 7,
|
||||
"expected_state_revision": 16,
|
||||
},
|
||||
),
|
||||
(
|
||||
ACTION_PHYSICAL_COMMAND_RECONCILE,
|
||||
{
|
||||
"reconciliation_id": "reconciliation-contract",
|
||||
"expected_session_generation": 7,
|
||||
"expected_state_revision": 17,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
for action_id, payload in payloads:
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
action_id,
|
||||
_snapshot_fenced(payload),
|
||||
)
|
||||
)
|
||||
|
||||
assert [call[0] for call in service.calls] == [
|
||||
"prepare",
|
||||
"start",
|
||||
"acquisition-stop",
|
||||
"abort",
|
||||
"control-open",
|
||||
"control-enter",
|
||||
"control-close",
|
||||
"physical-reconcile",
|
||||
]
|
||||
for _, request in service.calls[:4]:
|
||||
assert request.operation_id is not None
|
||||
assert request.idempotency_key.endswith(request.operation_id)
|
||||
|
||||
|
||||
def test_connection_verify_expected_state_is_not_reported_as_bad_gateway() -> None:
|
||||
class AddressUnavailableService(FakeXgridsService):
|
||||
async def verify_connection(
|
||||
self,
|
||||
request: ConnectionVerifyRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del request
|
||||
raise ConnectionVerificationError(
|
||||
"K1 не сообщил адрес общей локальной сети",
|
||||
reason_code="connection-verify-address-unavailable",
|
||||
)
|
||||
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(AddressUnavailableService()))]
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError) as raised:
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
_snapshot_fenced(),
|
||||
)
|
||||
)
|
||||
|
||||
assert raised.value.http_status_code == 409
|
||||
assert raised.value.reason_code == "connection-verify-address-unavailable"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reason_code", "expected_status"),
|
||||
[
|
||||
("ble-runtime-busy", 409),
|
||||
("ble-runtime-cleanup-pending", 409),
|
||||
("provisioning-already-running", 409),
|
||||
("connection-verify-mqtt-unreachable", 409),
|
||||
("connection-verify-lease-changed", 409),
|
||||
("connection-verify-resolved-apply-target-mismatch", 409),
|
||||
("configured-endpoint-topology-corrupt", 409),
|
||||
("connection-mode-selection-lifecycle-busy", 409),
|
||||
("connection-mode-selection-physical-state-unsafe", 409),
|
||||
("connection-mode-selection-control-state-unsafe", 409),
|
||||
("connection-mode-switch-acquisition-changed", 409),
|
||||
("connection-scenario-reset-pending", 409),
|
||||
("connection-scenario-reset-lifecycle-timeout", 409),
|
||||
("acquisition-start-lifecycle-busy", 409),
|
||||
("application-control-process-lease-unavailable", 409),
|
||||
("ble-runtime-owner-loop-conflict", 503),
|
||||
("ble-runtime-restart-required", 503),
|
||||
("connection-verify-exact-uuid-scan-timeout", 504),
|
||||
("ble-discovery-timeout", 504),
|
||||
("ble-status-read-timeout", 504),
|
||||
("ble-provisioning-timeout", 504),
|
||||
("ble-ap-enable-timeout", 504),
|
||||
("network-not-found", 504),
|
||||
("host-wifi-operation-timeout", 504),
|
||||
("physical-command-reconciliation-proof-timeout", 504),
|
||||
("physical-command-reconciliation-control-adoption-timeout", 504),
|
||||
("keychain-authorization-required", 409),
|
||||
("profile-unavailable", 409),
|
||||
("profile-credential-source-mismatch", 409),
|
||||
("wifi-interface-unavailable", 503),
|
||||
],
|
||||
)
|
||||
def test_ble_runtime_failure_preserves_actionable_http_class(
|
||||
reason_code: str,
|
||||
expected_status: int,
|
||||
) -> None:
|
||||
class ClassifiedRuntimeError(RuntimeError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("classified BLE failure")
|
||||
self.reason_code = reason_code
|
||||
|
||||
class FailingScanService(FakeXgridsService):
|
||||
async def scan_ble(self, request: BleScanRequest) -> dict[str, Any]:
|
||||
del request
|
||||
raise ClassifiedRuntimeError()
|
||||
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(FailingScanService()))]
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError) as raised:
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
_snapshot_fenced({"duration_seconds": 6}),
|
||||
)
|
||||
)
|
||||
|
||||
assert raised.value.http_status_code == expected_status
|
||||
assert raised.value.reason_code == reason_code
|
||||
|
||||
|
||||
def test_connection_verify_reuses_the_discovery_event_loop() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
|
||||
async def scenario() -> asyncio.AbstractEventLoop:
|
||||
loop = asyncio.get_running_loop()
|
||||
await dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
_snapshot_fenced({"duration_seconds": 6}),
|
||||
)
|
||||
await dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
_snapshot_fenced({
|
||||
"device_id": "test-ble-transport",
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
"expected_discovery_generation": 0,
|
||||
}),
|
||||
)
|
||||
return loop
|
||||
|
||||
dispatcher_loop = asyncio.run(scenario())
|
||||
|
||||
assert service.scan_loop is dispatcher_loop
|
||||
assert service.verify_loop is dispatcher_loop
|
||||
|
||||
|
||||
def test_repository_runtime_composition_exactly_matches_catalog() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
environment = load_installed_device_plugins(repository_root)
|
||||
@@ -437,12 +1135,16 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
{"duration_seconds": 6},
|
||||
_snapshot_fenced({"duration_seconds": 6}),
|
||||
)
|
||||
)
|
||||
|
||||
assert state == {"phase": "idle", "devices": []}
|
||||
assert service.calls == [("scan", 6.0)]
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "scan"
|
||||
assert isinstance(request, BleScanRequest)
|
||||
assert request.duration_seconds == 6.0
|
||||
|
||||
|
||||
def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
|
||||
@@ -496,12 +1198,12 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
{
|
||||
_snapshot_fenced({
|
||||
"device_id": "id",
|
||||
"ssid": "network",
|
||||
"password": "x" * 24,
|
||||
"extra": True,
|
||||
},
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -529,7 +1231,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
|
||||
"replay",
|
||||
),
|
||||
(ACTION_STREAM_STOP, {}, "stop"),
|
||||
(ACTION_STREAM_STOP, _snapshot_fenced(), "stop"),
|
||||
(
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
{
|
||||
@@ -571,7 +1273,13 @@ def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
|
||||
dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
|
||||
event_loop_thread = threading.get_ident()
|
||||
|
||||
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_STREAM_STOP,
|
||||
_snapshot_fenced(),
|
||||
)
|
||||
)
|
||||
|
||||
assert service.thread_id is not None
|
||||
assert service.thread_id != event_loop_thread
|
||||
@@ -649,6 +1357,6 @@ def test_dispatcher_rejects_uncorrelated_transport_result() -> None:
|
||||
DevicePluginDispatcher([runtime]).invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_STREAM_STOP,
|
||||
{},
|
||||
_snapshot_fenced(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_lazy_app_import_in_child_keeps_mutable_k1_state_out_of_repository() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
data_root = Path(os.environ["MISSIONCORE_DATA_DIR"]).resolve()
|
||||
evidence_root = Path(os.environ["MISSIONCORE_EVIDENCE_DIR"]).resolve()
|
||||
legacy_root = Path(os.environ["MISSIONCORE_LEGACY_SESSIONS_DIR"]).resolve()
|
||||
assert data_root != repository_root / ".runtime" / "mission-core"
|
||||
assert evidence_root != repository_root / ".runtime" / "mission-core" / "evidence"
|
||||
assert legacy_root != repository_root / "sessions"
|
||||
|
||||
script = """
|
||||
import importlib
|
||||
import json
|
||||
|
||||
module = importlib.import_module("k1link.web.app")
|
||||
contribution = module.plugin_environment._contributions[0]
|
||||
runtime = contribution.runtime
|
||||
service = runtime._adapter.service
|
||||
print(json.dumps({
|
||||
"session_data": str(module.session_store.data_dir),
|
||||
"service_evidence": str(service.evidence_root),
|
||||
"shadow_token": str(service.live_perception_token_path),
|
||||
"archive_roots": [str(item.root) for item in module.plugin_environment.observation_archives],
|
||||
}))
|
||||
module.plugin_environment.close()
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=repository_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
document = json.loads(completed.stdout.strip().splitlines()[-1])
|
||||
|
||||
assert Path(document["session_data"]).resolve() == data_root
|
||||
assert Path(document["service_evidence"]).resolve() == evidence_root
|
||||
assert Path(document["shadow_token"]).resolve().is_relative_to(data_root)
|
||||
assert {Path(item).resolve() for item in document["archive_roots"]} == {
|
||||
legacy_root,
|
||||
evidence_root,
|
||||
}
|
||||
+176
-10
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -25,6 +27,8 @@ from k1link.viewer.rerun_bridge import (
|
||||
_select_available_grpc_port,
|
||||
)
|
||||
|
||||
rerun_bridge_module = sys.modules["k1link.viewer.rerun_bridge"]
|
||||
|
||||
|
||||
class FakeRecording:
|
||||
def __init__(self) -> None:
|
||||
@@ -65,11 +69,73 @@ class DisconnectFailureRecording(FakeRecording):
|
||||
raise RuntimeError("synthetic disconnect failure")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def socket_free_rerun_port_selector(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep FakeRecording tests independent of host TCP bind permission."""
|
||||
|
||||
monkeypatch.setattr(
|
||||
rerun_bridge_module,
|
||||
"_select_available_grpc_port",
|
||||
lambda preferred_port, **_kwargs: preferred_port,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_owner_recovery_wake_is_generation_fenced_and_coalesced() -> None:
|
||||
runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
||||
with runtime._lock: # noqa: SLF001 - bounded producer-state unit seam
|
||||
runtime._producer_generation = 7 # noqa: SLF001
|
||||
runtime._source_mode = "live" # noqa: SLF001
|
||||
runtime._phase = "live" # noqa: SLF001
|
||||
runtime._source_ready = True # noqa: SLF001
|
||||
runtime._connection_recovery_enabled = True # noqa: SLF001
|
||||
|
||||
assert (
|
||||
runtime.request_connection_recovery(
|
||||
"camera-source-ended",
|
||||
expected_generation=6,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert runtime.request_connection_recovery(
|
||||
"camera-source-ended",
|
||||
expected_generation=7,
|
||||
)
|
||||
assert runtime.request_connection_recovery(
|
||||
"mqtt_network_loop_failed",
|
||||
expected_generation=7,
|
||||
)
|
||||
snapshot = runtime.snapshot()
|
||||
assert snapshot["phase"] == "reconnecting"
|
||||
assert snapshot["connection_recovery"]["reason_code"] == "camera-source-ended"
|
||||
assert runtime._consume_connection_recovery_request(generation=6) is None # noqa: SLF001
|
||||
assert ( # noqa: SLF001
|
||||
runtime._consume_connection_recovery_request(generation=7)
|
||||
== "camera-source-ended"
|
||||
)
|
||||
assert runtime._consume_connection_recovery_request(generation=7) is None # noqa: SLF001
|
||||
|
||||
with runtime._lock: # noqa: SLF001
|
||||
runtime._phase = "live" # noqa: SLF001
|
||||
runtime._source_ready = True # noqa: SLF001
|
||||
assert runtime.request_connection_recovery(
|
||||
"host-route-unavailable",
|
||||
expected_generation=7,
|
||||
)
|
||||
runtime.stop()
|
||||
assert runtime.snapshot()["phase"] == "idle"
|
||||
assert runtime._consume_connection_recovery_request(generation=7) is None # noqa: SLF001
|
||||
|
||||
|
||||
def test_rerun_port_selection_skips_a_recording_still_held_by_a_viewer(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as occupied:
|
||||
occupied.bind(("0.0.0.0", 0))
|
||||
try:
|
||||
occupied.bind(("0.0.0.0", 0))
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||
pytest.skip(f"host sandbox denies TCP bind: errno={exc.errno}")
|
||||
raise
|
||||
occupied.listen()
|
||||
preferred_port = int(occupied.getsockname()[1])
|
||||
|
||||
@@ -92,6 +158,82 @@ def test_rerun_port_selection_skips_a_recording_still_held_by_a_viewer(
|
||||
bridge.close()
|
||||
|
||||
|
||||
def test_rerun_port_selection_retries_only_address_in_use(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempts: list[int] = []
|
||||
|
||||
class Probe:
|
||||
def __enter__(self) -> Probe:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def bind(self, address: tuple[str, int]) -> None:
|
||||
attempts.append(address[1])
|
||||
if len(attempts) < 3:
|
||||
raise OSError(errno.EADDRINUSE, "synthetic address in use")
|
||||
|
||||
monkeypatch.setattr(rerun_bridge_module.socket, "socket", lambda *_args: Probe())
|
||||
|
||||
assert _select_available_grpc_port(9876, search_span=4) == 9878
|
||||
assert attempts == [9876, 9877, 9878]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_number", [errno.EPERM, errno.EACCES])
|
||||
def test_rerun_port_selection_reports_permission_denial_immediately(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error_number: int,
|
||||
) -> None:
|
||||
attempts: list[int] = []
|
||||
|
||||
class Probe:
|
||||
def __enter__(self) -> Probe:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def bind(self, address: tuple[str, int]) -> None:
|
||||
attempts.append(address[1])
|
||||
raise OSError(error_number, "synthetic permission denial")
|
||||
|
||||
monkeypatch.setattr(rerun_bridge_module.socket, "socket", lambda *_args: Probe())
|
||||
|
||||
with pytest.raises(PermissionError, match="Permission denied.*9876") as error:
|
||||
_select_available_grpc_port(9876, search_span=4)
|
||||
|
||||
assert error.value.errno == error_number
|
||||
assert attempts == [9876]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_number", [errno.EADDRNOTAVAIL, errno.EIO])
|
||||
def test_rerun_port_selection_does_not_misclassify_unexpected_socket_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error_number: int,
|
||||
) -> None:
|
||||
attempts: list[int] = []
|
||||
|
||||
class Probe:
|
||||
def __enter__(self) -> Probe:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def bind(self, address: tuple[str, int]) -> None:
|
||||
attempts.append(address[1])
|
||||
raise OSError(error_number, "synthetic unexpected bind failure")
|
||||
|
||||
monkeypatch.setattr(rerun_bridge_module.socket, "socket", lambda *_args: Probe())
|
||||
|
||||
with pytest.raises(RuntimeError, match="Could not probe.*9876"):
|
||||
_select_available_grpc_port(9876, search_span=4)
|
||||
|
||||
assert attempts == [9876]
|
||||
|
||||
|
||||
def _message(
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
@@ -129,7 +271,9 @@ def _envelope(
|
||||
return envelope
|
||||
|
||||
|
||||
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||
def test_legacy_points_and_pose_are_logged_to_rerun(
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
|
||||
@@ -169,7 +313,9 @@ def test_live_blueprint_follows_stream_time_without_frontend_cursor_writes() ->
|
||||
assert panel.state == "hidden"
|
||||
|
||||
|
||||
def test_live_perception_logs_original_mask_2d_distance_and_3d_cuboid() -> None:
|
||||
def test_live_perception_logs_original_mask_2d_distance_and_3d_cuboid(
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
mask = np.zeros((600, 800), dtype=np.uint8)
|
||||
@@ -177,6 +323,8 @@ def test_live_perception_logs_original_mask_2d_distance_and_3d_cuboid() -> None:
|
||||
|
||||
bridge.process_perception(
|
||||
LivePerceptionResultFrame(
|
||||
session_id="test-live-perception-session",
|
||||
session_generation=1,
|
||||
frame_index=3,
|
||||
source_frame_index=30,
|
||||
session_seconds=1.0,
|
||||
@@ -206,7 +354,9 @@ def test_live_perception_logs_original_mask_2d_distance_and_3d_cuboid() -> None:
|
||||
assert "/world/perception/boxes3d" in paths
|
||||
|
||||
|
||||
def test_constructor_disconnects_recording_after_partial_setup_failure() -> None:
|
||||
def test_constructor_disconnects_recording_after_partial_setup_failure(
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
recording = BlueprintFailureRecording()
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic blueprint failure"):
|
||||
@@ -215,7 +365,9 @@ def test_constructor_disconnects_recording_after_partial_setup_failure() -> None
|
||||
assert recording.disconnected is True
|
||||
|
||||
|
||||
def test_fast_replay_trajectory_sampling_uses_source_time() -> None:
|
||||
def test_fast_replay_trajectory_sampling_uses_source_time(
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
base_time_ns = 1_784_124_315_000_000_000
|
||||
@@ -245,7 +397,9 @@ def test_fast_replay_trajectory_sampling_uses_source_time() -> None:
|
||||
bridge.close()
|
||||
|
||||
|
||||
def test_bad_frame_is_rejected_before_rerun_without_publishing() -> None:
|
||||
def test_bad_frame_is_rejected_before_rerun_without_publishing(
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
|
||||
@@ -296,7 +450,10 @@ def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
|
||||
assert custom_over_rgb.tolist() == [[16, 32, 48], [16, 32, 48]]
|
||||
|
||||
|
||||
def test_runtime_owns_fresh_bridge_for_each_sequential_session(tmp_path: Path) -> None:
|
||||
def test_runtime_owns_fresh_bridge_for_each_sequential_session(
|
||||
tmp_path: Path,
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
point_topic = "RealtimePointcloud"
|
||||
pose_topic = "RealtimePath"
|
||||
@@ -380,7 +537,10 @@ def test_runtime_owns_fresh_bridge_for_each_sequential_session(tmp_path: Path) -
|
||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||
|
||||
|
||||
def test_runtime_reports_bridge_close_failure_instead_of_false_idle(tmp_path: Path) -> None:
|
||||
def test_runtime_reports_bridge_close_failure_instead_of_false_idle(
|
||||
tmp_path: Path,
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
topic = b"RealtimePointcloud"
|
||||
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
@@ -423,7 +583,10 @@ def test_runtime_reports_bridge_close_failure_instead_of_false_idle(tmp_path: Pa
|
||||
runtime.close()
|
||||
|
||||
|
||||
def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> None:
|
||||
def test_close_during_blocked_factory_closes_the_late_bridge(
|
||||
tmp_path: Path,
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
topic = b"RealtimePointcloud"
|
||||
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
@@ -476,7 +639,10 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
|
||||
|
||||
def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) -> None:
|
||||
def test_stop_fails_closed_when_runtime_thread_misses_deadline(
|
||||
tmp_path: Path,
|
||||
socket_free_rerun_port_selector: None,
|
||||
) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
topic = b"RealtimePointcloud"
|
||||
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
|
||||
@@ -173,6 +173,8 @@ def test_evidence_root_is_private_and_configurable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
monkeypatch.delenv("MISSIONCORE_EVIDENCE_DIR", raising=False)
|
||||
monkeypatch.delenv("MISSIONCORE_DATA_DIR", raising=False)
|
||||
assert (
|
||||
resolve_missioncore_evidence_dir(repository)
|
||||
== (repository / ".runtime" / "mission-core" / "evidence" / "sessions").resolve()
|
||||
|
||||
@@ -130,11 +130,16 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
def test_live_viewer_diagnostic_endpoint_accepts_only_bounded_events(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
router = build_viewer_diagnostics_router()
|
||||
expected_build = "/assets/index-abcdefgh.js"
|
||||
router = build_viewer_diagnostics_router(expected_ui_build_id=lambda: expected_build)
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/live-diagnostics", "POST")
|
||||
event = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v1",
|
||||
schema_version="missioncore.live-viewer-diagnostic/v2",
|
||||
event_code="live_receiver_stalled",
|
||||
ui_build_id=expected_build,
|
||||
document_instance_id="00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id="00000000-0000-4000-8000-000000000002",
|
||||
lifecycle_generation=4,
|
||||
failure_stage="receiver-stalled",
|
||||
stream_id="acquisition-123",
|
||||
backend_activity_sequence=8_572,
|
||||
@@ -150,8 +155,12 @@ def test_live_viewer_diagnostic_endpoint_accepts_only_bounded_events(
|
||||
response = endpoint(event)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert response.headers["x-missioncore-ui-build"] == expected_build
|
||||
assert "event=live_receiver_stalled" in caplog.text
|
||||
assert caplog.records[-1].failure_stage == "receiver-stalled"
|
||||
assert caplog.records[-1].document_instance_id == event.document_instance_id
|
||||
assert caplog.records[-1].viewer_instance_id == event.viewer_instance_id
|
||||
assert caplog.records[-1].lifecycle_generation == 4
|
||||
with pytest.raises(ValidationError):
|
||||
LiveViewerDiagnosticEvent.model_validate(
|
||||
{
|
||||
@@ -160,9 +169,91 @@ def test_live_viewer_diagnostic_endpoint_accepts_only_bounded_events(
|
||||
}
|
||||
)
|
||||
fallback = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v1",
|
||||
schema_version="missioncore.live-viewer-diagnostic/v2",
|
||||
event_code="live_receiver_active_store_admitted",
|
||||
ui_build_id=expected_build,
|
||||
document_instance_id="00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id="00000000-0000-4000-8000-000000000002",
|
||||
lifecycle_generation=4,
|
||||
stream_id="acquisition-123",
|
||||
backend_activity_sequence=8_573,
|
||||
)
|
||||
assert fallback.failure_stage is None
|
||||
|
||||
|
||||
def test_live_viewer_diagnostic_rejects_stale_build_before_logging(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
expected_build = "/assets/index-ijklmnop.js"
|
||||
router = build_viewer_diagnostics_router(expected_ui_build_id=lambda: expected_build)
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/live-diagnostics", "POST")
|
||||
event = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v2",
|
||||
event_code="live_receiver_error",
|
||||
ui_build_id="/assets/index-abcdefgh.js",
|
||||
document_instance_id="00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id="00000000-0000-4000-8000-000000000002",
|
||||
lifecycle_generation=1,
|
||||
)
|
||||
|
||||
with caplog.at_level(
|
||||
logging.INFO,
|
||||
logger="k1link.device_plugins.xgrids_k1.viewer_receiver",
|
||||
):
|
||||
response = endpoint(event)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.headers["x-missioncore-ui-build"] == expected_build
|
||||
assert "Mission Core live Rerun receiver diagnostic" not in caplog.text
|
||||
|
||||
|
||||
def test_live_viewer_client_contract_is_no_store_and_exact_build() -> None:
|
||||
expected_build = "/assets/index-abcdefgh.js"
|
||||
router = build_viewer_diagnostics_router(expected_ui_build_id=lambda: expected_build)
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/client-contract", "GET")
|
||||
|
||||
response = endpoint()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["x-missioncore-ui-build"] == expected_build
|
||||
assert json.loads(response.body) == {
|
||||
"schema_version": "missioncore.live-viewer-client-contract/v1",
|
||||
"status": "ready",
|
||||
"ui_build_id": expected_build,
|
||||
"diagnostic_schema_version": "missioncore.live-viewer-diagnostic/v2",
|
||||
}
|
||||
|
||||
|
||||
def test_live_viewer_client_contract_no_dist_is_retryable_without_reload_header() -> None:
|
||||
router = build_viewer_diagnostics_router(expected_ui_build_id=lambda: None)
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/client-contract", "GET")
|
||||
|
||||
response = endpoint()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "x-missioncore-ui-build" not in response.headers
|
||||
assert json.loads(response.body) == {
|
||||
"schema_version": "missioncore.live-viewer-client-contract/v1",
|
||||
"status": "frontend-build-unavailable",
|
||||
}
|
||||
|
||||
|
||||
def test_development_viewer_diagnostics_do_not_reload_against_dist_build() -> None:
|
||||
router = build_viewer_diagnostics_router(
|
||||
expected_ui_build_id=lambda: "/assets/index-abcdefgh.js",
|
||||
)
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/live-diagnostics", "POST")
|
||||
event = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v2",
|
||||
event_code="live_receiver_active_store_admitted",
|
||||
ui_build_id="development",
|
||||
document_instance_id="00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id="00000000-0000-4000-8000-000000000002",
|
||||
lifecycle_generation=1,
|
||||
)
|
||||
|
||||
response = endpoint(event)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
@@ -13,8 +13,14 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async def fake_scan(duration_seconds: float) -> dict[str, Any]:
|
||||
async def fake_scan(
|
||||
duration_seconds: float,
|
||||
*,
|
||||
on_admitted: object,
|
||||
) -> dict[str, Any]:
|
||||
assert duration_seconds == 6.0
|
||||
assert callable(on_admitted)
|
||||
on_admitted()
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
|
||||
@@ -5,9 +5,39 @@ import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import WebSocketDisconnect
|
||||
|
||||
import k1link.web.app as app_module
|
||||
from k1link.device_plugins.xgrids_k1.facade import XGRIDS_K1_PLUGIN_ID
|
||||
from k1link.web.app import INVALID_REQUEST_DETAIL, app
|
||||
from k1link.web.plugin_runtime import PluginExecutionError
|
||||
|
||||
|
||||
class _StateDispatcher:
|
||||
async def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
action_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
del plugin_id, action_id, payload
|
||||
return {"phase": "idle"}
|
||||
|
||||
|
||||
class _FailingSendWebSocket:
|
||||
def __init__(self, failure: Exception) -> None:
|
||||
self.failure = failure
|
||||
self.accepted = False
|
||||
|
||||
async def accept(self) -> None:
|
||||
self.accepted = True
|
||||
|
||||
async def send_json(self, payload: dict[str, Any]) -> None:
|
||||
del payload
|
||||
raise self.failure
|
||||
|
||||
async def close(self, *, code: int, reason: str) -> None:
|
||||
del code, reason
|
||||
|
||||
|
||||
async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]:
|
||||
@@ -80,6 +110,21 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
}
|
||||
if wrap_input:
|
||||
state_status, state_response = asyncio.run(
|
||||
_post_json(
|
||||
(
|
||||
f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/"
|
||||
"state.read"
|
||||
),
|
||||
{"input": {}},
|
||||
)
|
||||
)
|
||||
assert state_status == 200
|
||||
snapshot_runtime_id = json.loads(state_response)["state"][
|
||||
"snapshot_runtime_id"
|
||||
]
|
||||
action_input["expected_snapshot_runtime_id"] = snapshot_runtime_id
|
||||
payload = {"input": action_input} if wrap_input else action_input
|
||||
|
||||
status_code, response_text = asyncio.run(_post_json(path, payload))
|
||||
@@ -89,3 +134,75 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
|
||||
assert sensitive_value not in response_text
|
||||
assert sensitive_value[:32] not in response_text
|
||||
assert "input_value" not in response_text
|
||||
|
||||
|
||||
def test_legacy_ble_scan_requires_an_explicit_snapshot_runtime_header() -> None:
|
||||
status_code, response_text = asyncio.run(
|
||||
_post_json("/api/ble/scan", {"duration_seconds": 1})
|
||||
)
|
||||
|
||||
assert status_code == 422
|
||||
assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL}
|
||||
|
||||
|
||||
def test_plugin_expected_state_preserves_its_non_gateway_http_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ExpectedStateDispatcher:
|
||||
async def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
action_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
del plugin_id, action_id, payload
|
||||
raise PluginExecutionError(
|
||||
"K1 не сообщил адрес общей локальной сети",
|
||||
http_status_code=409,
|
||||
reason_code="connection-verify-address-unavailable",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(app_module, "plugin_dispatcher", ExpectedStateDispatcher())
|
||||
|
||||
status_code, response_text = asyncio.run(
|
||||
_post_json(
|
||||
"/api/v1/device-plugins/test.plugin/actions/connection.verify",
|
||||
{"input": {}},
|
||||
)
|
||||
)
|
||||
|
||||
assert status_code == 409
|
||||
assert json.loads(response_text) == {"detail": "K1 не сообщил адрес общей локальной сети"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
[
|
||||
WebSocketDisconnect(code=1001),
|
||||
RuntimeError("handler is closed"),
|
||||
RuntimeError(
|
||||
"unable to perform operation on <TCPTransport closed=True>; "
|
||||
"the handler is closed"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_device_plugin_events_treats_proven_transport_disconnect_as_completion(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
failure: Exception,
|
||||
) -> None:
|
||||
websocket = _FailingSendWebSocket(failure)
|
||||
monkeypatch.setattr(app_module, "plugin_dispatcher", _StateDispatcher())
|
||||
|
||||
asyncio.run(app_module.device_plugin_events(websocket, "test.plugin"))
|
||||
|
||||
assert websocket.accepted is True
|
||||
|
||||
|
||||
def test_device_plugin_events_propagates_arbitrary_send_runtime_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
websocket = _FailingSendWebSocket(RuntimeError("plugin state serialization failed"))
|
||||
monkeypatch.setattr(app_module, "plugin_dispatcher", _StateDispatcher())
|
||||
|
||||
with pytest.raises(RuntimeError, match="plugin state serialization failed"):
|
||||
asyncio.run(app_module.device_plugin_events(websocket, "test.plugin"))
|
||||
|
||||
+931
-17
File diff suppressed because it is too large
Load Diff
+28209
-502
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakGATTProtocolError
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
|
||||
@@ -15,20 +17,25 @@ from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
build_ap_activation_frame,
|
||||
is_ap_ready_status,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
||||
BleRuntimeBusy,
|
||||
bind_ble_runtime_owner_loop,
|
||||
ble_runtime_snapshot,
|
||||
configure_ble_runtime_process_lease,
|
||||
reset_ble_runtime_arbiter_for_tests,
|
||||
wait_for_ble_runtime_idle,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import WifiStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_runtime_handle_lease() -> Iterator[None]:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
def reset_runtime_handle_lease(tmp_path: Path) -> Iterator[None]:
|
||||
scanner_module.reset_runtime_handles_for_tests()
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
configure_ble_runtime_process_lease(tmp_path)
|
||||
yield
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
scanner_module.reset_runtime_handles_for_tests()
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
|
||||
|
||||
def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None:
|
||||
@@ -70,7 +77,7 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
retained_handle = BLEDevice(device_id, "XGR-K1", details=object())
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
@@ -95,19 +102,39 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
asyncio.run(
|
||||
ap_module.activate_device_ap_once(
|
||||
async def scenario() -> SelectedHandleObserved:
|
||||
owner_epoch = bind_ble_runtime_owner_loop()
|
||||
captured = scanner_module.CapturedDiscoveredDevice(
|
||||
device=retained_handle,
|
||||
macos_uuid=device_id,
|
||||
owner_epoch=owner_epoch,
|
||||
)
|
||||
scanner_module.pin_connected_device_handle(
|
||||
captured,
|
||||
device_session_id="device-session-a",
|
||||
)
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
await ap_module.activate_device_ap_once(
|
||||
device_id,
|
||||
timeout_seconds=1.0,
|
||||
captured_device=captured,
|
||||
)
|
||||
assert (
|
||||
scanner_module.connected_device_capture(
|
||||
device_id,
|
||||
device_session_id="device-session-a",
|
||||
)
|
||||
is None
|
||||
)
|
||||
return caught.value
|
||||
|
||||
error = asyncio.run(scenario())
|
||||
|
||||
assert rediscovery_calls == []
|
||||
assert client_calls == [retained_handle]
|
||||
assert caught.value.operation_stage == "connect" # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
assert error.operation_stage == "connect" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_ap_activation_does_not_fallback_when_fresh_scan_omits_device(
|
||||
@@ -150,7 +177,7 @@ def test_ap_activation_write_error_keeps_type_and_adds_safe_gatt_facts(
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=ap_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=ap_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
properties=["write-without-response", "write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
@@ -206,5 +233,136 @@ def test_ap_activation_write_error_keeps_type_and_adds_safe_gatt_facts(
|
||||
assert error.operation_stage == "gatt-write" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
assert error.resolved_write_mode == "without_response" # type: ignore[attr-defined]
|
||||
assert error.write_characteristic_properties == ( # type: ignore[attr-defined]
|
||||
"write",
|
||||
"write-without-response",
|
||||
)
|
||||
assert error.max_write_without_response_size == 512 # type: ignore[attr-defined]
|
||||
assert error.frame_length == FRAME_LENGTH # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x03 # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "WRITE_NOT_PERMITTED" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_ap_activation_keeps_same_client_alive_through_caller_handoff(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = BLEDevice(device_id, "XGR-K1", details=object())
|
||||
service = SimpleNamespace(uuid=ap_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=ap_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=ap_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=ap_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=ap_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
entered_clients = 0
|
||||
exited_clients = 0
|
||||
writes: list[tuple[bytes, bool]] = []
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == ap_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == ap_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == ap_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class ReadyClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
self.is_connected = False
|
||||
self._write_completed = False
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
nonlocal entered_clients
|
||||
entered_clients += 1
|
||||
self.is_connected = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
nonlocal exited_clients
|
||||
exited_clients += 1
|
||||
self.is_connected = False
|
||||
|
||||
async def read_gatt_char(self, _characteristic: object) -> bytes:
|
||||
if not self._write_completed:
|
||||
return bytes(52)
|
||||
ready = bytearray(52)
|
||||
mode = b"WIFI_AP"
|
||||
ready[0] = len(mode)
|
||||
ready[1 : 1 + len(mode)] = mode
|
||||
ready[33] = 4
|
||||
ready[34:38] = bytes((192, 168, 56, 1))
|
||||
ready[50] = 1
|
||||
ready[51] = 1
|
||||
return bytes(ready)
|
||||
|
||||
async def write_gatt_char(
|
||||
self,
|
||||
_characteristic: object,
|
||||
value: bytes,
|
||||
*,
|
||||
response: bool,
|
||||
) -> None:
|
||||
writes.append((bytes(value), response))
|
||||
self._write_completed = True
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", ReadyClient)
|
||||
|
||||
async def scenario() -> None:
|
||||
owner_epoch = bind_ble_runtime_owner_loop()
|
||||
captured = scanner_module.CapturedDiscoveredDevice(
|
||||
device=retained_handle,
|
||||
macos_uuid=device_id,
|
||||
owner_epoch=owner_epoch,
|
||||
)
|
||||
scanner_module.pin_connected_device_handle(
|
||||
captured,
|
||||
device_session_id="device-session-a",
|
||||
)
|
||||
async with ap_module.device_ap_activation_session(
|
||||
device_id,
|
||||
timeout_seconds=0.1,
|
||||
poll_interval_seconds=0.01,
|
||||
captured_device=captured,
|
||||
) as result:
|
||||
assert result["ready_observed"] is True
|
||||
assert entered_clients == 1
|
||||
assert exited_clients == 0
|
||||
assert ble_runtime_snapshot()["active_operation_kind"] == "ap-enable"
|
||||
with pytest.raises(BleRuntimeBusy) as busy:
|
||||
await scanner_module.scan(0.01)
|
||||
assert busy.value.active_operation_kind == "ap-enable"
|
||||
# This represents the host CoreWLAN association window: the setup
|
||||
# deadline is over, but the exact same BLE client must remain alive.
|
||||
await asyncio.sleep(0.02)
|
||||
assert exited_clients == 0
|
||||
assert scanner_module.connected_device_recovery_snapshot(
|
||||
device_id,
|
||||
device_session_id="device-session-a",
|
||||
)["gatt_validated_recently"] is True
|
||||
|
||||
assert exited_clients == 1
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert len(writes) == 1
|
||||
payload, response = writes[0]
|
||||
assert len(payload) == FRAME_LENGTH
|
||||
assert payload[:COMMAND_OFFSET] == bytes(COMMAND_OFFSET)
|
||||
assert payload[COMMAND_OFFSET] == ENABLE_AP_COMMAND
|
||||
assert response is True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Collection, Sequence
|
||||
from collections.abc import Callable, Collection, Sequence
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
@@ -20,6 +20,9 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
ApplicationMqttTransportError,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
@@ -120,7 +123,19 @@ class SyntheticAcceptanceTransport:
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
|
||||
dispatch_admission_commit: Callable[[], None] | None = None,
|
||||
) -> dict[str, bytes]:
|
||||
if (
|
||||
dispatch_admission_deadline_reached is not None
|
||||
and dispatch_admission_deadline_reached()
|
||||
):
|
||||
raise ApplicationMqttTransportError(
|
||||
"control command dispatch deadline expired before publish admission",
|
||||
reason_code="physical-command-dispatch-deadline-expired",
|
||||
)
|
||||
if dispatch_admission_commit is not None:
|
||||
dispatch_admission_commit()
|
||||
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||
responses: dict[str, bytes] = {}
|
||||
modeling_operation = next(
|
||||
@@ -357,6 +372,114 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
||||
|
||||
|
||||
def test_stop_deadline_expiring_during_dispatch_validation_consumes_no_permit_or_publish() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
authority,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
|
||||
)
|
||||
executor.run_project_prompt_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
|
||||
)
|
||||
start_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
),
|
||||
project_name="SAFE_PROJECT",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
executor.execute_canonical_start(
|
||||
start_command,
|
||||
build_canonical_post_start_observation(authority, binding),
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
permit=PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||
checkpoint=executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
lambda: True,
|
||||
),
|
||||
)
|
||||
executor.maintain_active_until_stop_requested(lambda: True)
|
||||
stop_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
)
|
||||
)
|
||||
)
|
||||
stop_permit = PhysicalAcceptancePermit(_checklist(ModelingAction.STOP))
|
||||
expired = False
|
||||
|
||||
def slow_dispatch_validation() -> None:
|
||||
nonlocal expired
|
||||
expired = True
|
||||
|
||||
with pytest.raises(ApplicationMqttTransportError) as raised:
|
||||
executor.execute_canonical_stop(
|
||||
stop_command,
|
||||
stop_permit,
|
||||
dispatch_guard=slow_dispatch_validation,
|
||||
dispatch_admission_deadline_reached=lambda: expired,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "physical-command-dispatch-deadline-expired"
|
||||
assert stop_permit.snapshot()["consumed"] is False
|
||||
assert transport.stop_emitted is False
|
||||
assert executor.snapshot()["stop_attempted"] is False
|
||||
assert executor.snapshot()["dialogue_stage"] == "stop-requested"
|
||||
|
||||
|
||||
def test_read_only_inspection_publishes_only_ordinal_one_device_info() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
binding = executor.run_read_only_inspection_stage(orchestrator)
|
||||
|
||||
assert binding.vendor_device_id == VENDOR_DEVICE_ID
|
||||
assert transport.batches == [("bootstrap:1:DeviceInfoRequest",)]
|
||||
outbound = {operation for batch in transport.batches for operation in batch}
|
||||
assert outbound == {"bootstrap:1:DeviceInfoRequest"}
|
||||
assert not any("DeviceConfig" in operation for operation in outbound)
|
||||
assert not any("ModelingStatus" in operation for operation in outbound)
|
||||
assert not any(operation.startswith("modeling:") for operation in outbound)
|
||||
assert executor.snapshot()["dialogue_stage"] == "inspection-ready"
|
||||
|
||||
completed = executor.complete_connection_stage(
|
||||
orchestrator,
|
||||
expected_binding=binding,
|
||||
)
|
||||
|
||||
assert completed == binding
|
||||
assert [len(batch) for batch in transport.batches] == [1, 5]
|
||||
assert transport.batches[1] == (
|
||||
"bootstrap:2:ModelingStatusRequest",
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
"bootstrap:4:DeviceConfigRequest",
|
||||
"bootstrap:5:DeviceInfoRequest",
|
||||
"bootstrap:6:GetRtkAdvanceRequest",
|
||||
)
|
||||
|
||||
|
||||
def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
|
||||
class NeverInitializedTransport(SyntheticAcceptanceTransport):
|
||||
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||
|
||||
@@ -11,11 +11,28 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
ApplicationAuthorityLoadError,
|
||||
MacOSKeychainApplicationAuthorityLoader,
|
||||
MacOSKeychainApplicationAuthorityProvisioner,
|
||||
_keychain_authority_reason_code,
|
||||
)
|
||||
|
||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_reason"),
|
||||
[
|
||||
(-25308, "keychain-authorization-required"),
|
||||
(-25293, "keychain-authorization-denied"),
|
||||
(-128, "keychain-authorization-cancelled"),
|
||||
(-25300, "application_authority_unavailable"),
|
||||
],
|
||||
)
|
||||
def test_keychain_osstatus_is_reduced_to_redacted_failure_class(
|
||||
status: int,
|
||||
expected_reason: str,
|
||||
) -> None:
|
||||
assert _keychain_authority_reason_code(status) == expected_reason
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_runtime_authority_loads_through_security_framework_without_subprocess(
|
||||
_system: object,
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
|
||||
APPLICATION_CONTROL_LOCK_FILENAME,
|
||||
ApplicationControlProcessLease,
|
||||
ApplicationControlProcessLeaseError,
|
||||
ApplicationControlProcessLeaseReleaseAmbiguous,
|
||||
ApplicationControlProcessLeaseUnavailable,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
||||
BleOperationHardTimeout,
|
||||
BleOperationProgress,
|
||||
BleRuntimeProcessLeaseBorrowInvalid,
|
||||
ble_runtime_snapshot,
|
||||
borrow_ble_runtime_process_lease,
|
||||
configure_ble_runtime_process_lease,
|
||||
reset_ble_runtime_arbiter_for_tests,
|
||||
run_ble_operation,
|
||||
wait_for_ble_runtime_idle,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.connection_supervisor import EndpointTarget
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
OpenApplicationControlSessionRequest,
|
||||
XgridsK1CompatibilityService,
|
||||
_recover_physical_command_after_process_restart,
|
||||
)
|
||||
from k1link.web.device_lifecycle import OperationJournal
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_process_ble_runtime() -> None:
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
yield
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
|
||||
|
||||
def _configure_private_data(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> tuple[Path, Path]:
|
||||
data_dir = tmp_path / "private-data"
|
||||
repository_root = tmp_path / "repository"
|
||||
repository_root.mkdir()
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(data_dir))
|
||||
return repository_root, data_dir
|
||||
|
||||
|
||||
def _hold_process_lease(
|
||||
repository_root: str,
|
||||
data_dir: str,
|
||||
ready: multiprocessing.synchronize.Event,
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
lease = ApplicationControlProcessLease.acquire(Path(repository_root))
|
||||
ready.set()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
lease.release()
|
||||
|
||||
|
||||
def test_lease_is_nonblocking_private_and_reusable_after_release(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure_private_data(tmp_path, monkeypatch)
|
||||
|
||||
first = ApplicationControlProcessLease.acquire(repository_root)
|
||||
lock_path = data_dir / "xgrids-k1" / APPLICATION_CONTROL_LOCK_FILENAME
|
||||
assert first.path == lock_path
|
||||
assert lock_path.is_file()
|
||||
assert lock_path.stat().st_mode & 0o777 == 0o600
|
||||
assert lock_path.parent.stat().st_mode & 0o777 == 0o700
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable) as busy:
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
assert busy.value.reason_code == "application-control-process-lease-unavailable"
|
||||
|
||||
first.release()
|
||||
first.release()
|
||||
with ApplicationControlProcessLease.acquire(repository_root) as second:
|
||||
assert second.path == lock_path
|
||||
assert lock_path.exists()
|
||||
|
||||
|
||||
def test_release_success_is_idempotent(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
unlock_calls = 0
|
||||
close_calls = 0
|
||||
original_flock = fcntl.flock
|
||||
original_close = os.close
|
||||
|
||||
def count_flock(descriptor: int, operation: int) -> None:
|
||||
nonlocal unlock_calls
|
||||
if descriptor == lease._descriptor and operation == fcntl.LOCK_UN: # noqa: SLF001
|
||||
unlock_calls += 1
|
||||
original_flock(descriptor, operation)
|
||||
|
||||
def count_close(descriptor: int) -> None:
|
||||
nonlocal close_calls
|
||||
if descriptor == lease._descriptor: # noqa: SLF001
|
||||
close_calls += 1
|
||||
original_close(descriptor)
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", count_flock)
|
||||
monkeypatch.setattr(os, "close", count_close)
|
||||
|
||||
first = lease.release()
|
||||
second = lease.release()
|
||||
|
||||
assert first.disposition == "released"
|
||||
assert first.unlock_error_code is None
|
||||
assert first.close_error_code is None
|
||||
assert second.disposition == "already-released"
|
||||
assert lease.release_state == "released"
|
||||
assert (unlock_calls, close_calls) == (1, 1)
|
||||
|
||||
|
||||
def test_release_unlock_success_close_error_is_terminal_released(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
descriptor = lease._descriptor # noqa: SLF001
|
||||
original_close = os.close
|
||||
close_calls = 0
|
||||
|
||||
def fail_close(candidate: int) -> None:
|
||||
nonlocal close_calls
|
||||
if candidate == descriptor:
|
||||
close_calls += 1
|
||||
raise OSError(5, "synthetic close failure")
|
||||
original_close(candidate)
|
||||
|
||||
monkeypatch.setattr(os, "close", fail_close)
|
||||
outcome = lease.release()
|
||||
|
||||
assert outcome.disposition == "released"
|
||||
assert outcome.unlock_error_code is None
|
||||
assert outcome.close_error_code == "OSError:5"
|
||||
assert lease.release_state == "released"
|
||||
assert lease.release().disposition == "already-released"
|
||||
assert close_calls == 1
|
||||
with pytest.raises(ApplicationControlProcessLeaseError):
|
||||
lease.duplicate_descriptor_for_child()
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
with (
|
||||
pytest.raises(BleRuntimeProcessLeaseBorrowInvalid),
|
||||
borrow_ble_runtime_process_lease(lease),
|
||||
):
|
||||
pass
|
||||
with ApplicationControlProcessLease.acquire(repository_root):
|
||||
pass
|
||||
original_close(descriptor)
|
||||
|
||||
|
||||
def test_release_unlock_error_close_success_is_terminal_released(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
descriptor = lease._descriptor # noqa: SLF001
|
||||
original_flock = fcntl.flock
|
||||
|
||||
def fail_unlock(candidate: int, operation: int) -> None:
|
||||
if candidate == descriptor and operation == fcntl.LOCK_UN:
|
||||
raise OSError(5, "synthetic unlock failure")
|
||||
original_flock(candidate, operation)
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", fail_unlock)
|
||||
outcome = lease.release()
|
||||
|
||||
assert outcome.disposition == "released"
|
||||
assert outcome.unlock_error_code == "OSError:5"
|
||||
assert outcome.close_error_code is None
|
||||
assert lease.release_state == "released"
|
||||
with ApplicationControlProcessLease.acquire(repository_root):
|
||||
pass
|
||||
|
||||
|
||||
def test_release_unlock_and_close_error_quarantines_without_retry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
descriptor = lease._descriptor # noqa: SLF001
|
||||
lock_identity = lease.path.stat().st_dev, lease.path.stat().st_ino
|
||||
original_flock = fcntl.flock
|
||||
original_close = os.close
|
||||
unlock_calls = 0
|
||||
close_calls = 0
|
||||
|
||||
def fail_unlock(candidate: int, operation: int) -> None:
|
||||
nonlocal unlock_calls
|
||||
if candidate == descriptor and operation == fcntl.LOCK_UN:
|
||||
unlock_calls += 1
|
||||
raise OSError(5, "synthetic unlock failure")
|
||||
original_flock(candidate, operation)
|
||||
|
||||
def fail_close(candidate: int) -> None:
|
||||
nonlocal close_calls
|
||||
if candidate == descriptor:
|
||||
close_calls += 1
|
||||
raise OSError(5, "synthetic close failure")
|
||||
original_close(candidate)
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", fail_unlock)
|
||||
monkeypatch.setattr(os, "close", fail_close)
|
||||
try:
|
||||
with pytest.raises(ApplicationControlProcessLeaseReleaseAmbiguous):
|
||||
lease.release()
|
||||
with pytest.raises(ApplicationControlProcessLeaseReleaseAmbiguous):
|
||||
lease.release()
|
||||
assert lease.release_state == "ambiguous"
|
||||
assert (unlock_calls, close_calls) == (1, 1)
|
||||
with pytest.raises(ApplicationControlProcessLeaseError):
|
||||
lease.duplicate_descriptor_for_child()
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
with (
|
||||
pytest.raises(BleRuntimeProcessLeaseBorrowInvalid),
|
||||
borrow_ble_runtime_process_lease(lease),
|
||||
):
|
||||
pass
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
assert (lease.path.stat().st_dev, lease.path.stat().st_ino) == lock_identity
|
||||
finally:
|
||||
monkeypatch.setattr(fcntl, "flock", original_flock)
|
||||
monkeypatch.setattr(os, "close", original_close)
|
||||
original_flock(descriptor, fcntl.LOCK_UN)
|
||||
original_close(descriptor)
|
||||
|
||||
|
||||
def test_ble_admission_rejects_borrow_token_quarantined_after_creation(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
descriptor = lease._descriptor # noqa: SLF001
|
||||
original_flock = fcntl.flock
|
||||
original_close = os.close
|
||||
operation_calls = 0
|
||||
|
||||
def fail_unlock(candidate: int, operation: int) -> None:
|
||||
if candidate == descriptor and operation == fcntl.LOCK_UN:
|
||||
raise OSError(5, "synthetic unlock failure")
|
||||
original_flock(candidate, operation)
|
||||
|
||||
def fail_close(candidate: int) -> None:
|
||||
if candidate == descriptor:
|
||||
raise OSError(5, "synthetic close failure")
|
||||
original_close(candidate)
|
||||
|
||||
async def operation(_progress: BleOperationProgress) -> None:
|
||||
nonlocal operation_calls
|
||||
operation_calls += 1
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", fail_unlock)
|
||||
monkeypatch.setattr(os, "close", fail_close)
|
||||
try:
|
||||
with borrow_ble_runtime_process_lease(lease):
|
||||
with pytest.raises(ApplicationControlProcessLeaseReleaseAmbiguous):
|
||||
lease.release()
|
||||
with pytest.raises(BleRuntimeProcessLeaseBorrowInvalid):
|
||||
asyncio.run(
|
||||
run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=1.0,
|
||||
operation=operation,
|
||||
)
|
||||
)
|
||||
assert operation_calls == 0
|
||||
assert ble_runtime_snapshot()["active_operation_kind"] is None
|
||||
finally:
|
||||
monkeypatch.setattr(fcntl, "flock", original_flock)
|
||||
monkeypatch.setattr(os, "close", original_close)
|
||||
original_flock(descriptor, fcntl.LOCK_UN)
|
||||
original_close(descriptor)
|
||||
|
||||
|
||||
def test_process_crash_releases_os_ownership_without_deleting_lock_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure_private_data(tmp_path, monkeypatch)
|
||||
context = multiprocessing.get_context("spawn")
|
||||
ready = context.Event()
|
||||
process = context.Process(
|
||||
target=_hold_process_lease,
|
||||
args=(str(repository_root), str(data_dir), ready),
|
||||
)
|
||||
process.start()
|
||||
try:
|
||||
assert ready.wait(timeout=10.0)
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
finally:
|
||||
process.terminate()
|
||||
process.join(timeout=10.0)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join(timeout=10.0)
|
||||
|
||||
lock_path = data_dir / "xgrids-k1" / APPLICATION_CONTROL_LOCK_FILENAME
|
||||
assert lock_path.exists()
|
||||
with ApplicationControlProcessLease.acquire(repository_root):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="camera flock inheritance is POSIX-only")
|
||||
def test_inherited_camera_descriptor_survives_parent_crash_until_child_exits(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
ready_path = tmp_path / "camera-child-ready"
|
||||
release_path = tmp_path / "camera-child-release"
|
||||
owner = ApplicationControlProcessLease.acquire(repository_root)
|
||||
inherited_descriptor = owner.duplicate_descriptor_for_child()
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
parent_descriptor_closed = False
|
||||
try:
|
||||
child = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import pathlib,sys,time;"
|
||||
"ready=pathlib.Path(sys.argv[1]);"
|
||||
"release=pathlib.Path(sys.argv[2]);"
|
||||
"ready.write_text('ready');"
|
||||
"\nwhile not release.exists(): time.sleep(0.01)"
|
||||
),
|
||||
str(ready_path),
|
||||
str(release_path),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
pass_fds=(inherited_descriptor,),
|
||||
start_new_session=True,
|
||||
)
|
||||
os.close(inherited_descriptor)
|
||||
inherited_descriptor = -1
|
||||
deadline = time.monotonic() + 5.0
|
||||
while not ready_path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert ready_path.exists()
|
||||
|
||||
# A process crash closes descriptors without explicitly unlocking the
|
||||
# shared flock description. Model that exact boundary; the child now
|
||||
# owns the only remaining duplicate inherited through exec.
|
||||
os.close(owner._descriptor) # noqa: SLF001
|
||||
owner._released = True # noqa: SLF001
|
||||
parent_descriptor_closed = True
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
|
||||
release_path.touch()
|
||||
assert child.wait(timeout=5.0) == 0
|
||||
with ApplicationControlProcessLease.acquire(repository_root):
|
||||
pass
|
||||
finally:
|
||||
if inherited_descriptor >= 0:
|
||||
os.close(inherited_descriptor)
|
||||
if not parent_descriptor_closed:
|
||||
owner.release()
|
||||
release_path.touch(exist_ok=True)
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
child.wait(timeout=5.0)
|
||||
|
||||
|
||||
def test_unsafe_existing_lock_file_fails_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure_private_data(tmp_path, monkeypatch)
|
||||
data_dir.mkdir(mode=0o700)
|
||||
lock_dir = data_dir / "xgrids-k1"
|
||||
lock_dir.mkdir(mode=0o700)
|
||||
lock_path = lock_dir / APPLICATION_CONTROL_LOCK_FILENAME
|
||||
lock_path.write_text("unsafe", encoding="utf-8")
|
||||
lock_path.chmod(0o644)
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseError, match="private regular file"):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
assert lock_path.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_unsafe_existing_lock_directory_fails_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure_private_data(tmp_path, monkeypatch)
|
||||
data_dir.mkdir(mode=0o700)
|
||||
lock_dir = data_dir / "xgrids-k1"
|
||||
lock_dir.mkdir(mode=0o755)
|
||||
# Earlier security-oriented tests may legitimately tighten the process
|
||||
# umask. The fixture must still create the exact unsafe mode it claims to
|
||||
# exercise instead of silently becoming 0700 under that inherited umask.
|
||||
lock_dir.chmod(0o755)
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseError, match="directory is not private"):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
|
||||
|
||||
def test_symlink_lock_file_fails_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure_private_data(tmp_path, monkeypatch)
|
||||
data_dir.mkdir(mode=0o700)
|
||||
lock_dir = data_dir / "xgrids-k1"
|
||||
lock_dir.mkdir(mode=0o700)
|
||||
target = tmp_path / "unrelated-private-file"
|
||||
target.touch(mode=0o600)
|
||||
(lock_dir / APPLICATION_CONTROL_LOCK_FILENAME).symlink_to(target)
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseError, match="opened safely"):
|
||||
ApplicationControlProcessLease.acquire(repository_root)
|
||||
|
||||
|
||||
def _bare_service(repository_root: Path) -> XgridsK1CompatibilityService:
|
||||
service = object.__new__(XgridsK1CompatibilityService)
|
||||
service.repository_root = repository_root
|
||||
service._lock = threading.Lock() # noqa: SLF001
|
||||
service._acquisition_lifecycle_gate = threading.RLock() # noqa: SLF001
|
||||
service._acquisition_lifecycle_admission = threading.Condition() # noqa: SLF001
|
||||
service._acquisition_lifecycle_reader_depth = threading.local() # noqa: SLF001
|
||||
service._acquisition_lifecycle_writer_token = None # noqa: SLF001
|
||||
service._acquisition_lifecycle_writer_thread_id = None # noqa: SLF001
|
||||
service._k1_lifecycle_transition_gate = threading.Lock() # noqa: SLF001
|
||||
service._k1_command_dispatch_gate = threading.Lock() # noqa: SLF001
|
||||
service._k1_process_lease_gate = threading.Lock() # noqa: SLF001
|
||||
service._connection_monitor_contact_gate = threading.Lock() # noqa: SLF001
|
||||
service._application_control_process_lease = None # noqa: SLF001
|
||||
service._application_control_process_lease_holders = set() # noqa: SLF001
|
||||
service._application_control_process_lease_quarantine = None # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
def test_facade_holds_lease_until_worker_and_socket_are_retired(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
owner = _bare_service(repository_root)
|
||||
peer = _bare_service(repository_root)
|
||||
|
||||
owner._acquire_application_control_process_lease() # noqa: SLF001
|
||||
owner._reconcile_application_control_process_lease( # noqa: SLF001
|
||||
{"state": "failed", "can_open": False}
|
||||
)
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
owner._reconcile_application_control_process_lease( # noqa: SLF001
|
||||
{"state": "failed", "can_open": True}
|
||||
)
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
peer._release_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
|
||||
def test_network_and_control_share_one_process_lease_until_both_retire(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
owner = _bare_service(repository_root)
|
||||
peer = _bare_service(repository_root)
|
||||
|
||||
owner._acquire_application_control_process_lease() # noqa: SLF001
|
||||
owner._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
owner._release_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
peer._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
|
||||
owner._release_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
peer._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
peer._release_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
|
||||
|
||||
def test_facade_rejects_new_holder_when_active_pointer_is_not_owned(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
owner = _bare_service(repository_root)
|
||||
lease = ApplicationControlProcessLease.acquire(repository_root)
|
||||
lease.release()
|
||||
owner._application_control_process_lease = lease # noqa: SLF001
|
||||
owner._application_control_process_lease_holders = {"control"} # noqa: SLF001
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
owner._acquire_k1_lifecycle_process_lease("camera") # noqa: SLF001
|
||||
|
||||
assert owner._application_control_process_lease is lease # noqa: SLF001
|
||||
assert owner._application_control_process_lease_holders == {"control"} # noqa: SLF001
|
||||
|
||||
|
||||
def test_facade_quarantines_ambiguous_final_release_and_never_retries_descriptor(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
owner = _bare_service(repository_root)
|
||||
owner._acquire_k1_lifecycle_process_lease("camera") # noqa: SLF001
|
||||
lease = owner._application_control_process_lease # noqa: SLF001
|
||||
assert lease is not None
|
||||
descriptor = lease._descriptor # noqa: SLF001
|
||||
original_flock = fcntl.flock
|
||||
original_close = os.close
|
||||
unlock_calls = 0
|
||||
close_calls = 0
|
||||
|
||||
def fail_unlock(candidate: int, operation: int) -> None:
|
||||
nonlocal unlock_calls
|
||||
if candidate == descriptor and operation == fcntl.LOCK_UN:
|
||||
unlock_calls += 1
|
||||
raise OSError(5, "synthetic unlock failure")
|
||||
original_flock(candidate, operation)
|
||||
|
||||
def fail_close(candidate: int) -> None:
|
||||
nonlocal close_calls
|
||||
if candidate == descriptor:
|
||||
close_calls += 1
|
||||
raise OSError(5, "synthetic close failure")
|
||||
original_close(candidate)
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", fail_unlock)
|
||||
monkeypatch.setattr(os, "close", fail_close)
|
||||
try:
|
||||
with pytest.raises(ApplicationControlProcessLeaseReleaseAmbiguous):
|
||||
owner._release_k1_lifecycle_process_lease("camera") # noqa: SLF001
|
||||
assert owner._application_control_process_lease is None # noqa: SLF001
|
||||
assert owner._application_control_process_lease_holders == set() # noqa: SLF001
|
||||
quarantine = owner._application_control_process_lease_quarantine # noqa: SLF001
|
||||
assert quarantine == (
|
||||
lease,
|
||||
"application-control-process-lease-release-ambiguous",
|
||||
)
|
||||
|
||||
owner._release_k1_lifecycle_process_lease("camera") # noqa: SLF001
|
||||
assert (unlock_calls, close_calls) == (1, 1)
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
owner._acquire_k1_lifecycle_process_lease("camera") # noqa: SLF001
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
owner._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
assert (unlock_calls, close_calls) == (1, 1)
|
||||
finally:
|
||||
monkeypatch.setattr(fcntl, "flock", original_flock)
|
||||
monkeypatch.setattr(os, "close", original_close)
|
||||
original_flock(descriptor, fcntl.LOCK_UN)
|
||||
original_close(descriptor)
|
||||
|
||||
|
||||
def test_network_process_lease_blocks_other_process_control_owner(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
network_owner = _bare_service(repository_root)
|
||||
control_peer = _bare_service(repository_root)
|
||||
|
||||
network_owner._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
control_peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
network_owner._release_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "expected_after_release"),
|
||||
[("start", True), ("stop", False)],
|
||||
)
|
||||
def test_physical_prepared_recovery_waits_for_exclusive_lifecycle_lease_and_preserves_stop(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
action: str,
|
||||
expected_after_release: bool,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
live_owner = ApplicationControlProcessLease.acquire(repository_root)
|
||||
resolutions: list[tuple[str, str]] = []
|
||||
|
||||
class PreparedLedger:
|
||||
@staticmethod
|
||||
def snapshot() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
status="unresolved",
|
||||
record=SimpleNamespace(
|
||||
operation_id="physical-prepared-by-live-owner",
|
||||
stage="prepared",
|
||||
action=action,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve(operation_id: str, *, resolution: str) -> None:
|
||||
resolutions.append((operation_id, resolution))
|
||||
|
||||
ledger = PreparedLedger()
|
||||
_recover_physical_command_after_process_restart(
|
||||
repository_root,
|
||||
physical_ledger=ledger, # type: ignore[arg-type]
|
||||
)
|
||||
assert resolutions == []
|
||||
|
||||
live_owner.release()
|
||||
_recover_physical_command_after_process_restart(
|
||||
repository_root,
|
||||
physical_ledger=ledger, # type: ignore[arg-type]
|
||||
)
|
||||
assert resolutions == (
|
||||
[("physical-prepared-by-live-owner", "not-dispatched")]
|
||||
if expected_after_release
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
def test_detached_ble_cleanup_retains_cross_process_lifecycle_lease(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
owner = _bare_service(repository_root)
|
||||
peer = _bare_service(repository_root)
|
||||
|
||||
async def scenario() -> None:
|
||||
cleanup_entered = asyncio.Event()
|
||||
cleanup_release = asyncio.Event()
|
||||
|
||||
async def stubborn_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cleanup_entered.set()
|
||||
await cleanup_release.wait()
|
||||
raise
|
||||
|
||||
owner._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
lease = owner._application_control_process_lease # noqa: SLF001
|
||||
assert lease is not None
|
||||
with (
|
||||
borrow_ble_runtime_process_lease(lease),
|
||||
pytest.raises(BleOperationHardTimeout),
|
||||
):
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=stubborn_cleanup,
|
||||
)
|
||||
await cleanup_entered.wait()
|
||||
owner._release_network_process_lease_after_ble_cleanup() # noqa: SLF001
|
||||
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
cleanup_release.set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
peer._release_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_failed_ble_cleanup_keeps_cross_process_lease_until_process_restart(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
owner = _bare_service(repository_root)
|
||||
peer = _bare_service(repository_root)
|
||||
|
||||
async def scenario() -> None:
|
||||
cleanup_entered = asyncio.Event()
|
||||
cleanup_failure = asyncio.Event()
|
||||
|
||||
async def failed_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
cleanup_entered.set()
|
||||
await cleanup_failure.wait()
|
||||
raise RuntimeError("synthetic native cleanup failure") from exc
|
||||
|
||||
owner._acquire_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
lease = owner._application_control_process_lease # noqa: SLF001
|
||||
assert lease is not None
|
||||
with (
|
||||
borrow_ble_runtime_process_lease(lease),
|
||||
pytest.raises(BleOperationHardTimeout),
|
||||
):
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=failed_cleanup,
|
||||
)
|
||||
await cleanup_entered.wait()
|
||||
owner._release_network_process_lease_after_ble_cleanup() # noqa: SLF001
|
||||
cleanup_failure.set()
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
assert ble_runtime_snapshot()["poisoned"] is True
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
|
||||
try:
|
||||
asyncio.run(scenario())
|
||||
finally:
|
||||
# Production releases this flock only through process exit. The test
|
||||
# simulates that boundary explicitly so it cannot contaminate peers.
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
owner._release_k1_lifecycle_process_lease("network") # noqa: SLF001
|
||||
|
||||
|
||||
def test_explicit_open_failure_releases_newly_acquired_facade_lease(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _ = _configure_private_data(tmp_path, monkeypatch)
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
service = _bare_service(repository_root)
|
||||
service._acquisition_lifecycle_gate = threading.RLock() # noqa: SLF001
|
||||
service._provisioning_active = False # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "known-session" # noqa: SLF001
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._compatibility_attestation = { # noqa: SLF001
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
}
|
||||
service._acquisition = None # noqa: SLF001
|
||||
service._operations = OperationJournal() # noqa: SLF001
|
||||
service._application_control = SimpleNamespace(disarm=lambda: None) # noqa: SLF001
|
||||
service._physical_command_coordinator = SimpleNamespace( # type: ignore[assignment] # noqa: SLF001
|
||||
snapshot=lambda: {"status": "empty", "record": None}
|
||||
)
|
||||
|
||||
target = EndpointTarget("192.168.1.20", 1883)
|
||||
service._connection_supervisor = SimpleNamespace( # type: ignore[assignment] # noqa: SLF001
|
||||
snapshot=lambda: SimpleNamespace(
|
||||
intent=SimpleNamespace(intent_id="intent-1", requested_mode="bridge"),
|
||||
device_network=SimpleNamespace(
|
||||
state="applied",
|
||||
intent_id="intent-1",
|
||||
transport_ref="test-ble-transport",
|
||||
connection_mode="bridge",
|
||||
target=target,
|
||||
),
|
||||
endpoint=SimpleNamespace(
|
||||
target=target,
|
||||
tcp_state="reachable",
|
||||
host_path_epoch=1,
|
||||
),
|
||||
host_path=SimpleNamespace(epoch=1),
|
||||
)
|
||||
)
|
||||
service._reuse_or_recover_control_target = lambda: ( # type: ignore[method-assign] # noqa: SLF001
|
||||
target.ipv4,
|
||||
{"device_write_performed": False},
|
||||
)
|
||||
service._validate_application_connection_path = lambda _binding: None # type: ignore[method-assign] # noqa: SLF001
|
||||
|
||||
class FailingSession:
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"state": "idle", "can_open": True}
|
||||
|
||||
def open(self, **_: object) -> None:
|
||||
raise RuntimeError("synthetic pre-worker open failure")
|
||||
|
||||
service._application_control_session = FailingSession() # type: ignore[assignment] # noqa: SLF001
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic pre-worker open failure"):
|
||||
service.open_application_control_session(
|
||||
OpenApplicationControlSessionRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
)
|
||||
|
||||
peer = _bare_service(repository_root)
|
||||
peer._acquire_application_control_process_lease() # noqa: SLF001
|
||||
peer._release_application_control_process_lease() # noqa: SLF001
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,823 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
|
||||
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
|
||||
ApplicationControlProcessLease,
|
||||
ApplicationControlProcessLeaseUnavailable,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
||||
BleOperationHardTimeout,
|
||||
BleOperationProgress,
|
||||
BleRuntimeBusy,
|
||||
BleRuntimeOwnerLoopConflict,
|
||||
BleRuntimePoisoned,
|
||||
BleRuntimeProcessLeaseNotConfigured,
|
||||
bind_ble_runtime_owner_loop,
|
||||
ble_runtime_snapshot,
|
||||
borrow_ble_runtime_process_lease,
|
||||
configure_ble_runtime_process_lease,
|
||||
defer_until_ble_runtime_idle,
|
||||
reset_ble_runtime_arbiter_for_tests,
|
||||
run_ble_operation,
|
||||
run_ble_operation_session,
|
||||
wait_for_ble_runtime_idle,
|
||||
)
|
||||
|
||||
|
||||
def _hold_external_process_lease(
|
||||
repository_root: str,
|
||||
data_dir: str,
|
||||
ready: Any,
|
||||
release: Any,
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
lease = ApplicationControlProcessLease.acquire(Path(repository_root))
|
||||
ready.set()
|
||||
try:
|
||||
release.wait(timeout=10.0)
|
||||
finally:
|
||||
lease.release()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configured_process_ble_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[Path]:
|
||||
scanner_module.reset_runtime_handles_for_tests()
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
repository_root = tmp_path / "repository"
|
||||
repository_root.mkdir()
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
configure_ble_runtime_process_lease(repository_root)
|
||||
yield repository_root
|
||||
scanner_module.reset_runtime_handles_for_tests()
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
|
||||
|
||||
def test_direct_low_level_ble_call_is_blocked_by_external_process_owner(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
context = multiprocessing.get_context("spawn")
|
||||
ready = context.Event()
|
||||
release = context.Event()
|
||||
process = context.Process(
|
||||
target=_hold_external_process_lease,
|
||||
args=(
|
||||
str(configured_process_ble_runtime),
|
||||
os.environ["MISSIONCORE_DATA_DIR"],
|
||||
ready,
|
||||
release,
|
||||
),
|
||||
)
|
||||
process.start()
|
||||
operation_called = False
|
||||
|
||||
async def operation(_progress: BleOperationProgress) -> None:
|
||||
nonlocal operation_called
|
||||
operation_called = True
|
||||
|
||||
try:
|
||||
assert ready.wait(timeout=10.0)
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
asyncio.run(
|
||||
run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=operation,
|
||||
)
|
||||
)
|
||||
assert operation_called is False
|
||||
finally:
|
||||
release.set()
|
||||
process.join(timeout=10.0)
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=10.0)
|
||||
|
||||
assert process.exitcode == 0
|
||||
|
||||
|
||||
def test_borrowed_external_process_lease_avoids_double_acquire(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
external_lease = ApplicationControlProcessLease.acquire(
|
||||
configured_process_ble_runtime
|
||||
)
|
||||
try:
|
||||
with borrow_ble_runtime_process_lease(external_lease):
|
||||
result = asyncio.run(
|
||||
run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_value("borrowed"),
|
||||
)
|
||||
)
|
||||
assert result == "borrowed"
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(configured_process_ble_runtime)
|
||||
finally:
|
||||
external_lease.release()
|
||||
|
||||
with ApplicationControlProcessLease.acquire(configured_process_ble_runtime):
|
||||
pass
|
||||
|
||||
|
||||
def test_session_owns_os_lease_until_native_context_exit(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
@asynccontextmanager
|
||||
async def ready_session(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
yield "ready"
|
||||
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.1,
|
||||
operation=ready_session,
|
||||
) as value:
|
||||
assert value == "ready"
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(configured_process_ble_runtime)
|
||||
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
with ApplicationControlProcessLease.acquire(configured_process_ble_runtime):
|
||||
pass
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("contender", ["status-read", "wifi-provision"])
|
||||
def test_scan_lease_rejects_wifi_entrypoints_process_wide(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
contender: str,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
scan_entered = asyncio.Event()
|
||||
release_scan = asyncio.Event()
|
||||
resolution_attempted = False
|
||||
|
||||
async def blocked_discover(**_kwargs: object) -> dict[str, object]:
|
||||
scan_entered.set()
|
||||
await release_scan.wait()
|
||||
return {}
|
||||
|
||||
async def forbidden_resolution(*_args: object, **_kwargs: object) -> None:
|
||||
nonlocal resolution_attempted
|
||||
resolution_attempted = True
|
||||
raise AssertionError("busy contender must not touch CoreBluetooth")
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", blocked_discover)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
forbidden_resolution,
|
||||
)
|
||||
scan_task = asyncio.create_task(scanner_module.scan(1.0))
|
||||
await scan_entered.wait()
|
||||
try:
|
||||
if contender == "status-read":
|
||||
with pytest.raises(BleRuntimeBusy) as raised:
|
||||
await wifi_module.read_wifi_status_once(
|
||||
"synthetic-device",
|
||||
timeout_seconds=0.1,
|
||||
)
|
||||
else:
|
||||
with pytest.raises(BleRuntimeBusy) as raised:
|
||||
await wifi_module.provision_wifi_once(
|
||||
"synthetic-device",
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=0.1,
|
||||
)
|
||||
assert raised.value.reason_code == "ble-runtime-busy"
|
||||
assert raised.value.active_operation_kind == "scan"
|
||||
assert resolution_attempted is False
|
||||
finally:
|
||||
release_scan.set()
|
||||
await scan_task
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_hard_deadline_returns_before_cleanup_and_quarantines_reentry() -> None:
|
||||
async def scenario() -> None:
|
||||
cancellation_observed = asyncio.Event()
|
||||
cleanup_release = asyncio.Event()
|
||||
|
||||
async def stubborn_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cancellation_observed.set()
|
||||
await cleanup_release.wait()
|
||||
raise
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
with pytest.raises(BleOperationHardTimeout) as timed_out:
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=stubborn_cleanup,
|
||||
)
|
||||
elapsed = loop.time() - started
|
||||
assert elapsed < 0.25
|
||||
assert timed_out.value.reason_code == "ble-status-read-timeout"
|
||||
await cancellation_observed.wait()
|
||||
assert ble_runtime_snapshot()["cleanup_pending"] is True
|
||||
|
||||
with pytest.raises(BleRuntimeBusy) as busy:
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert busy.value.reason_code == "ble-runtime-cleanup-pending"
|
||||
|
||||
cleanup_release.set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
result = await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_value("recovered"),
|
||||
)
|
||||
assert result == "recovered"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_idle_callback_runs_synchronously_once_when_runtime_is_idle() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
disposition = defer_until_ble_runtime_idle(lambda: calls.append("released"))
|
||||
|
||||
assert disposition == "released"
|
||||
assert calls == ["released"]
|
||||
|
||||
|
||||
def test_idle_callback_waits_for_detached_cleanup_and_runs_once(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
cancellation_observed = asyncio.Event()
|
||||
cleanup_release = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
async def stubborn_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cancellation_observed.set()
|
||||
await cleanup_release.wait()
|
||||
raise
|
||||
|
||||
with pytest.raises(BleOperationHardTimeout):
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=stubborn_cleanup,
|
||||
)
|
||||
await cancellation_observed.wait()
|
||||
|
||||
disposition = defer_until_ble_runtime_idle(lambda: calls.append("released"))
|
||||
assert disposition == "deferred"
|
||||
assert calls == []
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(configured_process_ble_runtime)
|
||||
|
||||
cleanup_release.set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
assert calls == ["released"]
|
||||
await asyncio.sleep(0)
|
||||
assert calls == ["released"]
|
||||
with ApplicationControlProcessLease.acquire(configured_process_ble_runtime):
|
||||
pass
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_idle_callback_is_retained_forever_when_cleanup_poisoned(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
async def scenario() -> None:
|
||||
cancellation_observed = asyncio.Event()
|
||||
fail_cleanup = asyncio.Event()
|
||||
calls: list[str] = []
|
||||
|
||||
async def failing_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
cancellation_observed.set()
|
||||
await fail_cleanup.wait()
|
||||
raise RuntimeError("synthetic native cleanup failure") from exc
|
||||
|
||||
with pytest.raises(BleOperationHardTimeout):
|
||||
await run_ble_operation(
|
||||
"wifi-provision",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=failing_cleanup,
|
||||
)
|
||||
await cancellation_observed.wait()
|
||||
assert defer_until_ble_runtime_idle(lambda: calls.append("before-poison")) == "deferred"
|
||||
|
||||
fail_cleanup.set()
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
assert ble_runtime_snapshot()["poisoned"] is True
|
||||
assert calls == []
|
||||
assert (
|
||||
defer_until_ble_runtime_idle(lambda: calls.append("after-poison"))
|
||||
== "poisoned"
|
||||
)
|
||||
assert await wait_for_ble_runtime_idle(timeout_seconds=0.01) is False
|
||||
assert calls == []
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(configured_process_ble_runtime)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_test_reset_releases_poisoned_owned_lock_and_clears_configuration(
|
||||
configured_process_ble_runtime: Path,
|
||||
) -> None:
|
||||
async def poison_runtime() -> None:
|
||||
async def failing_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
raise RuntimeError("synthetic cleanup failure") from exc
|
||||
|
||||
with pytest.raises(BleOperationHardTimeout):
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=failing_cleanup,
|
||||
)
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
assert ble_runtime_snapshot()["poisoned"] is True
|
||||
|
||||
asyncio.run(poison_runtime())
|
||||
with pytest.raises(ApplicationControlProcessLeaseUnavailable):
|
||||
ApplicationControlProcessLease.acquire(configured_process_ble_runtime)
|
||||
|
||||
reset_ble_runtime_arbiter_for_tests()
|
||||
with ApplicationControlProcessLease.acquire(configured_process_ble_runtime):
|
||||
pass
|
||||
with pytest.raises(BleRuntimeProcessLeaseNotConfigured):
|
||||
asyncio.run(
|
||||
run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_idle_callback_registration_racing_release_executes_exactly_once() -> None:
|
||||
async def race_once() -> None:
|
||||
operation_entered = asyncio.Event()
|
||||
operation_release = asyncio.Event()
|
||||
registration_gate = threading.Barrier(2)
|
||||
registration_finished = threading.Event()
|
||||
registration_errors: list[BaseException] = []
|
||||
dispositions: list[str] = []
|
||||
calls = 0
|
||||
calls_lock = threading.Lock()
|
||||
|
||||
async def operation(_progress: BleOperationProgress) -> None:
|
||||
operation_entered.set()
|
||||
await operation_release.wait()
|
||||
|
||||
operation_task = asyncio.create_task(
|
||||
run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=1.0,
|
||||
operation=operation,
|
||||
)
|
||||
)
|
||||
await operation_entered.wait()
|
||||
|
||||
def callback() -> None:
|
||||
nonlocal calls
|
||||
with calls_lock:
|
||||
calls += 1
|
||||
|
||||
def register() -> None:
|
||||
try:
|
||||
registration_gate.wait()
|
||||
dispositions.append(defer_until_ble_runtime_idle(callback))
|
||||
except BaseException as exc:
|
||||
registration_errors.append(exc)
|
||||
finally:
|
||||
registration_finished.set()
|
||||
|
||||
registration_thread = threading.Thread(target=register, daemon=True)
|
||||
registration_thread.start()
|
||||
registration_gate.wait()
|
||||
operation_release.set()
|
||||
await operation_task
|
||||
while not registration_finished.is_set():
|
||||
await asyncio.sleep(0)
|
||||
registration_thread.join(timeout=0.1)
|
||||
assert registration_thread.is_alive() is False
|
||||
assert registration_errors == []
|
||||
assert dispositions in (["deferred"], ["released"])
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
assert calls == 1
|
||||
|
||||
async def scenario() -> None:
|
||||
for _ in range(32):
|
||||
await race_once()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_failed_deferred_idle_callback_poisons_and_retains_ownership() -> None:
|
||||
async def scenario() -> None:
|
||||
operation_entered = asyncio.Event()
|
||||
operation_release = asyncio.Event()
|
||||
callback_calls = 0
|
||||
later_calls: list[str] = []
|
||||
|
||||
async def operation(_progress: BleOperationProgress) -> None:
|
||||
operation_entered.set()
|
||||
await operation_release.wait()
|
||||
|
||||
def failed_release() -> None:
|
||||
nonlocal callback_calls
|
||||
callback_calls += 1
|
||||
raise RuntimeError("synthetic external lease release failure")
|
||||
|
||||
operation_task = asyncio.create_task(
|
||||
run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=1.0,
|
||||
operation=operation,
|
||||
)
|
||||
)
|
||||
await operation_entered.wait()
|
||||
assert defer_until_ble_runtime_idle(failed_release) == "deferred"
|
||||
operation_release.set()
|
||||
await operation_task
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
snapshot = ble_runtime_snapshot()
|
||||
assert callback_calls == 1
|
||||
assert snapshot["poisoned"] is True
|
||||
assert snapshot["cleanup_pending"] is True
|
||||
assert snapshot["active_operation_kind"] == "status-read"
|
||||
assert (
|
||||
defer_until_ble_runtime_idle(lambda: later_calls.append("unsafe-release"))
|
||||
== "poisoned"
|
||||
)
|
||||
assert later_calls == []
|
||||
with pytest.raises(BleRuntimePoisoned):
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_hard_deadline_cleanup_failure_poison_blocks_next_ble_lease() -> None:
|
||||
async def scenario() -> None:
|
||||
async def failing_cancel_cleanup(_progress: BleOperationProgress) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError as exc:
|
||||
raise RuntimeError("synthetic disconnect failure") from exc
|
||||
|
||||
with pytest.raises(BleOperationHardTimeout):
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.01,
|
||||
operation=failing_cancel_cleanup,
|
||||
)
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
snapshot = ble_runtime_snapshot()
|
||||
assert snapshot["cleanup_pending"] is True
|
||||
assert snapshot["poisoned"] is True
|
||||
with pytest.raises(BleRuntimePoisoned):
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_setup_deadline_returns_before_noncooperative_cleanup() -> None:
|
||||
async def scenario() -> None:
|
||||
cancellation_observed = asyncio.Event()
|
||||
cleanup_release = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def blocked_session(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
yield "unreachable"
|
||||
except asyncio.CancelledError:
|
||||
cancellation_observed.set()
|
||||
await cleanup_release.wait()
|
||||
raise
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
with pytest.raises(BleOperationHardTimeout) as timed_out:
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.01,
|
||||
operation=blocked_session,
|
||||
):
|
||||
raise AssertionError("blocked setup must never yield")
|
||||
assert loop.time() - started < 0.25
|
||||
assert timed_out.value.reason_code == "ble-ap-enable-timeout"
|
||||
await cancellation_observed.wait()
|
||||
assert ble_runtime_snapshot()["cleanup_pending"] is True
|
||||
|
||||
with pytest.raises(BleRuntimeBusy) as busy:
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert busy.value.reason_code == "ble-runtime-cleanup-pending"
|
||||
assert busy.value.active_operation_kind == "ap-enable"
|
||||
|
||||
cleanup_release.set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_setup_cleanup_failure_requires_runtime_restart() -> None:
|
||||
async def scenario() -> None:
|
||||
@asynccontextmanager
|
||||
async def failing_setup_cleanup(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
yield "unreachable"
|
||||
except asyncio.CancelledError as exc:
|
||||
raise RuntimeError("synthetic setup teardown failure") from exc
|
||||
|
||||
with pytest.raises(BleOperationHardTimeout):
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.01,
|
||||
operation=failing_setup_cleanup,
|
||||
):
|
||||
raise AssertionError("blocked setup must never yield")
|
||||
|
||||
for _ in range(100):
|
||||
if ble_runtime_snapshot()["poisoned"]:
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
snapshot = ble_runtime_snapshot()
|
||||
assert snapshot["cleanup_pending"] is True
|
||||
assert snapshot["poisoned"] is True
|
||||
with pytest.raises(BleRuntimePoisoned) as poisoned:
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert poisoned.value.reason_code == "ble-runtime-restart-required"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_setup_deadline_stops_after_ready_while_lease_stays_held() -> None:
|
||||
async def scenario() -> None:
|
||||
cleanup_completed = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def ready_session(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
yield "ready"
|
||||
finally:
|
||||
cleanup_completed.set()
|
||||
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.01,
|
||||
operation=ready_session,
|
||||
) as value:
|
||||
assert value == "ready"
|
||||
await asyncio.sleep(0.05)
|
||||
assert ble_runtime_snapshot()["active_operation_kind"] == "ap-enable"
|
||||
with pytest.raises(BleRuntimeBusy) as busy:
|
||||
await run_ble_operation(
|
||||
"status-read",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert busy.value.reason_code == "ble-runtime-busy"
|
||||
|
||||
assert cleanup_completed.is_set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_cleanup_deadline_detaches_without_releasing_process_lease() -> None:
|
||||
async def scenario() -> None:
|
||||
cleanup_entered = asyncio.Event()
|
||||
cleanup_release = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def stubborn_cleanup_session(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
yield "ready"
|
||||
finally:
|
||||
cleanup_entered.set()
|
||||
await cleanup_release.wait()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.1,
|
||||
hard_cleanup_timeout_seconds=0.01,
|
||||
operation=stubborn_cleanup_session,
|
||||
) as value:
|
||||
assert value == "ready"
|
||||
assert loop.time() - started < 0.25
|
||||
await cleanup_entered.wait()
|
||||
assert ble_runtime_snapshot()["cleanup_pending"] is True
|
||||
|
||||
with pytest.raises(BleRuntimeBusy) as busy:
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert busy.value.reason_code == "ble-runtime-cleanup-pending"
|
||||
|
||||
cleanup_release.set()
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_cleanup_failure_requires_runtime_restart() -> None:
|
||||
async def scenario() -> None:
|
||||
@asynccontextmanager
|
||||
async def failing_cleanup_session(
|
||||
_progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
try:
|
||||
yield "ready"
|
||||
finally:
|
||||
raise RuntimeError("synthetic native disconnect failure")
|
||||
|
||||
with pytest.raises(RuntimeError, match="disconnect failure"):
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=0.1,
|
||||
operation=failing_cleanup_session,
|
||||
) as value:
|
||||
assert value == "ready"
|
||||
|
||||
await asyncio.sleep(0)
|
||||
snapshot = ble_runtime_snapshot()
|
||||
assert snapshot["cleanup_pending"] is True
|
||||
assert snapshot["poisoned"] is True
|
||||
# Dispatchers may still bind non-BLE safety/state actions on the same
|
||||
# owner loop; only a new BLE lease is prohibited until process restart.
|
||||
assert bind_ble_runtime_owner_loop() == snapshot["owner_epoch"]
|
||||
with pytest.raises(BleRuntimePoisoned) as poisoned:
|
||||
await run_ble_operation(
|
||||
"scan",
|
||||
hard_timeout_seconds=0.1,
|
||||
operation=lambda _progress: _completed_none(),
|
||||
)
|
||||
assert poisoned.value.reason_code == "ble-runtime-restart-required"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_caller_cancellation_preserves_ble_write_progress() -> None:
|
||||
async def scenario() -> None:
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def blocked_write(progress: BleOperationProgress) -> None:
|
||||
progress.operation_stage = "gatt-write"
|
||||
progress.device_write_attempted = True
|
||||
progress.device_write_confirmed = True
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_ble_operation(
|
||||
"wifi-provision",
|
||||
hard_timeout_seconds=5.0,
|
||||
operation=blocked_write,
|
||||
)
|
||||
)
|
||||
await entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError) as raised:
|
||||
await task
|
||||
assert raised.value.operation_stage == "gatt-write" # type: ignore[attr-defined]
|
||||
assert raised.value.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert raised.value.device_write_confirmed is True # type: ignore[attr-defined]
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_session_body_cancellation_preserves_ble_write_progress() -> None:
|
||||
async def scenario() -> None:
|
||||
body_entered = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def ready_after_write(
|
||||
progress: BleOperationProgress,
|
||||
) -> AsyncIterator[str]:
|
||||
progress.operation_stage = "status-poll"
|
||||
progress.device_write_attempted = True
|
||||
progress.device_write_confirmed = True
|
||||
yield "ready"
|
||||
|
||||
async def caller() -> None:
|
||||
async with run_ble_operation_session(
|
||||
"ap-enable",
|
||||
hard_setup_timeout_seconds=1.0,
|
||||
operation=ready_after_write,
|
||||
):
|
||||
body_entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
task = asyncio.create_task(caller())
|
||||
await body_entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError) as raised:
|
||||
await task
|
||||
assert raised.value.operation_stage == "status-poll" # type: ignore[attr-defined]
|
||||
assert raised.value.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert raised.value.device_write_confirmed is True # type: ignore[attr-defined]
|
||||
assert await wait_for_ble_runtime_idle()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_live_owner_loop_cannot_be_replaced() -> None:
|
||||
first_loop = asyncio.new_event_loop()
|
||||
second_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
first_epoch = bind_ble_runtime_owner_loop(first_loop)
|
||||
with pytest.raises(BleRuntimeOwnerLoopConflict) as raised:
|
||||
bind_ble_runtime_owner_loop(second_loop)
|
||||
assert raised.value.reason_code == "ble-runtime-owner-loop-conflict"
|
||||
assert ble_runtime_snapshot()["owner_epoch"] == first_epoch
|
||||
finally:
|
||||
first_loop.close()
|
||||
second_loop.close()
|
||||
|
||||
|
||||
async def _completed_none() -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _completed_value(value: str) -> str:
|
||||
return value
|
||||
+1192
-40
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import device_identity_pin_store as store_module
|
||||
from k1link.device_plugins.xgrids_k1.device_identity_pin_store import (
|
||||
DEVICE_IDENTITY_PIN_FILENAME,
|
||||
DEVICE_IDENTITY_PIN_LOCK_FILENAME,
|
||||
DEVICE_IDENTITY_PIN_MAX_BYTES,
|
||||
DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
DeviceIdentityPin,
|
||||
DeviceIdentityPinMismatch,
|
||||
DeviceIdentityPinStore,
|
||||
DeviceIdentityPinStoreCorrupt,
|
||||
)
|
||||
|
||||
TRANSPORT_A = "A161D9D5-C352-1069-D430-5FB0BC13F7F9"
|
||||
TRANSPORT_B = "B262E0E6-D463-2170-E541-6FC1CD24A8EA"
|
||||
VENDOR_DEVICE_A = "K1-DEVICE-0001"
|
||||
VENDOR_DEVICE_B = "K1-DEVICE-0002"
|
||||
PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||
|
||||
|
||||
def _configure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> tuple[Path, Path]:
|
||||
repository_root = tmp_path / "repository"
|
||||
repository_root.mkdir()
|
||||
data_dir = tmp_path / "private-data"
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(data_dir))
|
||||
return repository_root, data_dir
|
||||
|
||||
|
||||
def _store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeviceIdentityPinStore:
|
||||
repository_root, _data_dir = _configure(tmp_path, monkeypatch)
|
||||
return DeviceIdentityPinStore(repository_root)
|
||||
|
||||
|
||||
def _pin(
|
||||
store: DeviceIdentityPinStore,
|
||||
*,
|
||||
transport_ref: str = TRANSPORT_A,
|
||||
vendor_device_id: str = VENDOR_DEVICE_A,
|
||||
compatibility_profile_id: str = PROFILE_ID,
|
||||
) -> None:
|
||||
store.pin_or_match(
|
||||
transport_ref=transport_ref,
|
||||
vendor_device_id=vendor_device_id,
|
||||
compatibility_profile_id=compatibility_profile_id,
|
||||
)
|
||||
|
||||
|
||||
def test_first_contact_is_private_secret_free_and_survives_restart(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure(tmp_path, monkeypatch)
|
||||
store = DeviceIdentityPinStore(repository_root)
|
||||
|
||||
decision = store.pin_or_match(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=VENDOR_DEVICE_A,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
)
|
||||
|
||||
assert decision.created is True
|
||||
assert decision.revision == 1
|
||||
assert decision.pin == DeviceIdentityPin(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=VENDOR_DEVICE_A,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
)
|
||||
assert store.path == data_dir / "xgrids-k1" / DEVICE_IDENTITY_PIN_FILENAME
|
||||
assert stat.S_IMODE(data_dir.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(store.path.parent.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
|
||||
assert store.path.stat().st_nlink == 1
|
||||
lock_path = store.path.parent / DEVICE_IDENTITY_PIN_LOCK_FILENAME
|
||||
assert stat.S_IMODE(lock_path.stat().st_mode) == 0o600
|
||||
assert lock_path.stat().st_nlink == 1
|
||||
|
||||
document = json.loads(store.path.read_text(encoding="utf-8"))
|
||||
assert document == {
|
||||
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
"revision": 1,
|
||||
"pins": [decision.pin.as_dict()],
|
||||
}
|
||||
assert set(document) == {"schema_version", "revision", "pins"}
|
||||
assert set(document["pins"][0]) == {
|
||||
"transport_ref",
|
||||
"vendor_device_id",
|
||||
"compatibility_profile_id",
|
||||
}
|
||||
serialized = store.path.read_text(encoding="utf-8").casefold()
|
||||
assert "ssid" not in serialized
|
||||
assert "password" not in serialized
|
||||
assert "credential" not in serialized
|
||||
assert "secret" not in serialized
|
||||
assert "ipv4" not in serialized
|
||||
|
||||
restarted = DeviceIdentityPinStore(repository_root)
|
||||
snapshot = restarted.snapshot()
|
||||
assert snapshot.status == "available"
|
||||
assert snapshot.revision == 1
|
||||
assert snapshot.pins == (decision.pin,)
|
||||
assert snapshot.for_transport(TRANSPORT_A) == decision.pin
|
||||
assert snapshot.as_public_dict() == {
|
||||
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
"status": "available",
|
||||
"revision": 1,
|
||||
"pin_count": 1,
|
||||
"reason_code": None,
|
||||
}
|
||||
assert VENDOR_DEVICE_A not in str(snapshot.as_public_dict())
|
||||
|
||||
|
||||
def test_same_exact_identity_is_read_only_and_does_not_bump_revision(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_pin(store)
|
||||
before = store.path.read_bytes()
|
||||
before_identity = (store.path.stat().st_dev, store.path.stat().st_ino)
|
||||
|
||||
decision = store.pin_or_match(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=VENDOR_DEVICE_A,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
)
|
||||
|
||||
assert decision.created is False
|
||||
assert decision.revision == 1
|
||||
assert store.path.read_bytes() == before
|
||||
assert (store.path.stat().st_dev, store.path.stat().st_ino) == before_identity
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vendor_device_id", "compatibility_profile_id"),
|
||||
[
|
||||
(VENDOR_DEVICE_B, PROFILE_ID),
|
||||
(VENDOR_DEVICE_A, "xgrids.lixelkity-k1.incompatible.v9"),
|
||||
(VENDOR_DEVICE_B, "xgrids.lixelkity-k1.incompatible.v9"),
|
||||
],
|
||||
)
|
||||
def test_mismatch_is_typed_and_never_overwrites_first_contact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
vendor_device_id: str,
|
||||
compatibility_profile_id: str,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_pin(store)
|
||||
before = store.path.read_bytes()
|
||||
|
||||
with pytest.raises(DeviceIdentityPinMismatch) as raised:
|
||||
store.pin_or_match(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=vendor_device_id,
|
||||
compatibility_profile_id=compatibility_profile_id,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "device-identity-pin-mismatch"
|
||||
assert raised.value.transport_ref == TRANSPORT_A
|
||||
assert raised.value.expected_vendor_device_id == VENDOR_DEVICE_A
|
||||
assert raised.value.observed_vendor_device_id == vendor_device_id
|
||||
assert store.path.read_bytes() == before
|
||||
assert store.snapshot().revision == 1
|
||||
|
||||
|
||||
def test_multiple_transport_mappings_are_canonical_and_restart_safe(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_pin(
|
||||
store,
|
||||
transport_ref=TRANSPORT_B,
|
||||
vendor_device_id=VENDOR_DEVICE_B,
|
||||
)
|
||||
_pin(store)
|
||||
|
||||
snapshot = DeviceIdentityPinStore(tmp_path / "repository").snapshot()
|
||||
assert snapshot.revision == 2
|
||||
assert [pin.transport_ref for pin in snapshot.pins] == [TRANSPORT_A, TRANSPORT_B]
|
||||
document = json.loads(store.path.read_text(encoding="utf-8"))
|
||||
assert [pin["transport_ref"] for pin in document["pins"]] == [
|
||||
TRANSPORT_A,
|
||||
TRANSPORT_B,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
(
|
||||
b'{"schema_version":"missioncore.xgrids-k1-device-identity-pins/v1",'
|
||||
b'"schema_version":"missioncore.xgrids-k1-device-identity-pins/v1"}\n'
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.xgrids-k1-device-identity-pins/v999",
|
||||
"revision": 1,
|
||||
"pins": [
|
||||
{
|
||||
"transport_ref": TRANSPORT_A,
|
||||
"vendor_device_id": VENDOR_DEVICE_A,
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
}
|
||||
],
|
||||
}
|
||||
).encode(),
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
"revision": 1,
|
||||
"pins": [
|
||||
{
|
||||
"transport_ref": TRANSPORT_A,
|
||||
"vendor_device_id": VENDOR_DEVICE_A,
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
}
|
||||
],
|
||||
"ssid": "must-not-be-stored",
|
||||
}
|
||||
).encode(),
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
"revision": 2,
|
||||
"pins": [
|
||||
{
|
||||
"transport_ref": TRANSPORT_A,
|
||||
"vendor_device_id": VENDOR_DEVICE_A,
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
}
|
||||
],
|
||||
}
|
||||
).encode(),
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
|
||||
"revision": 2,
|
||||
"pins": [
|
||||
{
|
||||
"transport_ref": TRANSPORT_A,
|
||||
"vendor_device_id": VENDOR_DEVICE_A,
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
},
|
||||
{
|
||||
"transport_ref": TRANSPORT_A,
|
||||
"vendor_device_id": VENDOR_DEVICE_B,
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
},
|
||||
],
|
||||
}
|
||||
).encode(),
|
||||
b"{" + b"x" * DEVICE_IDENTITY_PIN_MAX_BYTES + b"}",
|
||||
],
|
||||
)
|
||||
def test_corruption_fails_closed_without_overwrite(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure(tmp_path, monkeypatch)
|
||||
parent = data_dir / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700, parents=True)
|
||||
data_dir.chmod(0o700)
|
||||
parent.chmod(0o700)
|
||||
path = parent / DEVICE_IDENTITY_PIN_FILENAME
|
||||
path.write_bytes(payload)
|
||||
path.chmod(0o600)
|
||||
|
||||
store = DeviceIdentityPinStore(repository_root)
|
||||
assert store.snapshot().status == "corrupt"
|
||||
before = path.read_bytes()
|
||||
with pytest.raises(DeviceIdentityPinStoreCorrupt):
|
||||
_pin(store)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_symlink_nonregular_hardlink_and_nonprivate_file_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure(tmp_path, monkeypatch)
|
||||
parent = data_dir / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700, parents=True)
|
||||
data_dir.chmod(0o700)
|
||||
parent.chmod(0o700)
|
||||
path = parent / DEVICE_IDENTITY_PIN_FILENAME
|
||||
target = tmp_path / "outside.json"
|
||||
target.write_text("{}", encoding="utf-8")
|
||||
target.chmod(0o600)
|
||||
path.symlink_to(target)
|
||||
|
||||
assert DeviceIdentityPinStore(repository_root).snapshot().status == "corrupt"
|
||||
path.unlink()
|
||||
|
||||
path.mkdir(mode=0o700)
|
||||
assert DeviceIdentityPinStore(repository_root).snapshot().status == "corrupt"
|
||||
path.rmdir()
|
||||
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
hardlink = tmp_path / "second-link.json"
|
||||
os.link(path, hardlink)
|
||||
assert DeviceIdentityPinStore(repository_root).snapshot().status == "corrupt"
|
||||
hardlink.unlink()
|
||||
path.unlink()
|
||||
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
path.chmod(0o644)
|
||||
assert DeviceIdentityPinStore(repository_root).snapshot().status == "corrupt"
|
||||
|
||||
|
||||
def test_nonprivate_directory_and_unsafe_lock_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, data_dir = _configure(tmp_path, monkeypatch)
|
||||
data_dir.mkdir(mode=0o755)
|
||||
data_dir.chmod(0o755)
|
||||
with pytest.raises(DeviceIdentityPinStoreCorrupt, match="permissions"):
|
||||
DeviceIdentityPinStore(repository_root)
|
||||
|
||||
data_dir.chmod(0o700)
|
||||
parent = data_dir / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700)
|
||||
lock_path = parent / DEVICE_IDENTITY_PIN_LOCK_FILENAME
|
||||
lock_path.write_bytes(b"not-empty")
|
||||
lock_path.chmod(0o600)
|
||||
with pytest.raises(DeviceIdentityPinStoreCorrupt, match="stable private"):
|
||||
DeviceIdentityPinStore(repository_root)
|
||||
|
||||
lock_path.unlink()
|
||||
target = tmp_path / "unrelated-private-file"
|
||||
target.touch(mode=0o600)
|
||||
lock_path.symlink_to(target)
|
||||
with pytest.raises(DeviceIdentityPinStoreCorrupt, match="opened safely"):
|
||||
DeviceIdentityPinStore(repository_root)
|
||||
|
||||
|
||||
def test_invalid_inputs_are_rejected_without_creating_a_pin_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_pin(store, transport_ref="../../other-device")
|
||||
with pytest.raises(ValueError):
|
||||
_pin(store, vendor_device_id="K1 DEVICE WITH SPACES")
|
||||
with pytest.raises(ValueError):
|
||||
_pin(store, compatibility_profile_id="profile\npassword")
|
||||
|
||||
assert store.snapshot().status == "empty"
|
||||
assert store.path.exists() is False
|
||||
|
||||
|
||||
def test_atomic_publication_fsyncs_file_and_parent_and_cleans_temp_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
real_fsync = store_module.os.fsync
|
||||
fsync_kinds: list[str] = []
|
||||
|
||||
def observe_fsync(descriptor: int) -> None:
|
||||
mode = store_module.os.fstat(descriptor).st_mode
|
||||
fsync_kinds.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(store_module.os, "fsync", observe_fsync)
|
||||
_pin(store)
|
||||
|
||||
assert "file" in fsync_kinds
|
||||
assert fsync_kinds[-1] == "directory"
|
||||
assert not list(store.path.parent.glob(f".{DEVICE_IDENTITY_PIN_FILENAME}.*.tmp"))
|
||||
|
||||
|
||||
def test_failed_atomic_replace_preserves_previous_mapping(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_pin(store)
|
||||
before = store.path.read_bytes()
|
||||
|
||||
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||
raise OSError("injected replace failure")
|
||||
|
||||
monkeypatch.setattr(store_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="injected replace failure"):
|
||||
_pin(
|
||||
store,
|
||||
transport_ref=TRANSPORT_B,
|
||||
vendor_device_id=VENDOR_DEVICE_B,
|
||||
)
|
||||
assert store.path.read_bytes() == before
|
||||
assert not list(store.path.parent.glob(f".{DEVICE_IDENTITY_PIN_FILENAME}.*.tmp"))
|
||||
|
||||
|
||||
def test_two_instances_serialize_first_contact_and_mismatch_without_overwrite(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_root, _data_dir = _configure(tmp_path, monkeypatch)
|
||||
first = DeviceIdentityPinStore(repository_root)
|
||||
second = DeviceIdentityPinStore(repository_root)
|
||||
real_write = store_module._write_private_json_atomic
|
||||
first_write_entered = threading.Event()
|
||||
release_first_write = threading.Event()
|
||||
second_started = threading.Event()
|
||||
second_finished = threading.Event()
|
||||
write_count = 0
|
||||
count_lock = threading.Lock()
|
||||
results: dict[str, object] = {}
|
||||
|
||||
def blocked_first_write(
|
||||
path: Path,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> None:
|
||||
nonlocal write_count
|
||||
with count_lock:
|
||||
write_count += 1
|
||||
should_block = write_count == 1
|
||||
if should_block:
|
||||
first_write_entered.set()
|
||||
assert release_first_write.wait(timeout=5)
|
||||
real_write(path, payload, data_dir=data_dir)
|
||||
|
||||
def run_first() -> None:
|
||||
results["first"] = first.pin_or_match(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=VENDOR_DEVICE_A,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
)
|
||||
|
||||
def run_second() -> None:
|
||||
second_started.set()
|
||||
try:
|
||||
second.pin_or_match(
|
||||
transport_ref=TRANSPORT_A,
|
||||
vendor_device_id=VENDOR_DEVICE_B,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
)
|
||||
except DeviceIdentityPinMismatch as exc:
|
||||
results["second"] = exc
|
||||
finally:
|
||||
second_finished.set()
|
||||
|
||||
monkeypatch.setattr(store_module, "_write_private_json_atomic", blocked_first_write)
|
||||
first_thread = threading.Thread(target=run_first, daemon=True)
|
||||
second_thread = threading.Thread(target=run_second, daemon=True)
|
||||
first_thread.start()
|
||||
assert first_write_entered.wait(timeout=5)
|
||||
second_thread.start()
|
||||
assert second_started.wait(timeout=5)
|
||||
try:
|
||||
assert second_finished.wait(timeout=0.2) is False
|
||||
finally:
|
||||
release_first_write.set()
|
||||
first_thread.join(timeout=5)
|
||||
second_thread.join(timeout=5)
|
||||
|
||||
assert first_thread.is_alive() is False
|
||||
assert second_thread.is_alive() is False
|
||||
assert results["first"].created is True # type: ignore[union-attr]
|
||||
assert isinstance(results["second"], DeviceIdentityPinMismatch)
|
||||
snapshot = DeviceIdentityPinStore(repository_root).snapshot()
|
||||
assert snapshot.revision == 1
|
||||
assert snapshot.for_transport(TRANSPORT_A) is not None
|
||||
assert snapshot.for_transport(TRANSPORT_A).vendor_device_id == VENDOR_DEVICE_A # type: ignore[union-attr]
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from bleak.exc import (
|
||||
BleakBluetoothNotAvailableError,
|
||||
BleakBluetoothNotAvailableReason,
|
||||
)
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.host_diagnostics import (
|
||||
host_diagnostic_for_exception,
|
||||
host_diagnostic_for_reason,
|
||||
host_diagnostics_for_reasons,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.network_mutation_ledger import (
|
||||
NetworkMutationLedgerSnapshot,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reason_code", "expected_code", "expected_domain", "expected_action"),
|
||||
[
|
||||
(
|
||||
"ble-permission-denied",
|
||||
"host.bluetooth.permission-denied",
|
||||
"corebluetooth",
|
||||
"grant-bluetooth-permission",
|
||||
),
|
||||
(
|
||||
"ble-adapter-powered-off",
|
||||
"host.bluetooth.adapter-powered-off",
|
||||
"corebluetooth",
|
||||
"power-on-bluetooth",
|
||||
),
|
||||
(
|
||||
"ble-adapter-unavailable",
|
||||
"host.bluetooth.adapter-unavailable",
|
||||
"corebluetooth",
|
||||
"restore-bluetooth-adapter",
|
||||
),
|
||||
(
|
||||
"ble-provisioning-timeout",
|
||||
"host.bluetooth.operation-timeout",
|
||||
"corebluetooth",
|
||||
"explicit-retry",
|
||||
),
|
||||
(
|
||||
"corewlan-authorization-denied",
|
||||
"host.wifi.permission-denied",
|
||||
"corewlan",
|
||||
"grant-wifi-permission",
|
||||
),
|
||||
(
|
||||
"wifi-interface-inactive",
|
||||
"host.wifi.adapter-powered-off",
|
||||
"corewlan",
|
||||
"power-on-wifi",
|
||||
),
|
||||
(
|
||||
"wifi-interface-unavailable",
|
||||
"host.wifi.interface-unavailable",
|
||||
"corewlan",
|
||||
"restore-wifi-interface",
|
||||
),
|
||||
(
|
||||
"network-not-found",
|
||||
"host.wifi.ssid-unavailable",
|
||||
"corewlan",
|
||||
"join-expected-network",
|
||||
),
|
||||
(
|
||||
"keychain-authorization-required",
|
||||
"host.keychain.interaction-required",
|
||||
"keychain",
|
||||
"unlock-or-authorize-keychain",
|
||||
),
|
||||
(
|
||||
"keychain-authorization-denied",
|
||||
"host.keychain.permission-denied",
|
||||
"keychain",
|
||||
"review-keychain-access",
|
||||
),
|
||||
(
|
||||
"keychain-access-failed",
|
||||
"host.keychain.unavailable",
|
||||
"keychain",
|
||||
"unlock-or-authorize-keychain",
|
||||
),
|
||||
(
|
||||
"host-route-unavailable",
|
||||
"host.route.unavailable",
|
||||
"route",
|
||||
"inspect-host-route",
|
||||
),
|
||||
(
|
||||
"tcp-connection-refused",
|
||||
"host.tcp.connection-refused",
|
||||
"tcp",
|
||||
"verify-broker-endpoint",
|
||||
),
|
||||
(
|
||||
"tcp-connection-timeout",
|
||||
"host.tcp.connection-timeout",
|
||||
"tcp",
|
||||
"verify-broker-endpoint",
|
||||
),
|
||||
(
|
||||
"mqtt_connection_timeout",
|
||||
"host.mqtt.connection-timeout",
|
||||
"mqtt",
|
||||
"verify-broker-endpoint",
|
||||
),
|
||||
(
|
||||
"mqtt_network_loop_failed",
|
||||
"host.mqtt.transport-unavailable",
|
||||
"mqtt",
|
||||
"verify-broker-endpoint",
|
||||
),
|
||||
(
|
||||
"network-mutation-ledger-corrupt",
|
||||
"host.filesystem.ledger-unavailable",
|
||||
"filesystem",
|
||||
"inspect-local-storage",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_reviewed_host_reason_codes_map_to_typed_redacted_diagnostics(
|
||||
reason_code: str,
|
||||
expected_code: str,
|
||||
expected_domain: str,
|
||||
expected_action: str,
|
||||
) -> None:
|
||||
diagnostic = host_diagnostic_for_reason(reason_code)
|
||||
|
||||
assert diagnostic is not None
|
||||
document = diagnostic.as_dict()
|
||||
assert document["schema_version"] == "missioncore.host-failure-diagnostic/v1"
|
||||
assert document["code"] == expected_code
|
||||
assert document["domain"] == expected_domain
|
||||
assert document["operator_action"] == expected_action
|
||||
assert document["automatic_retry"] is False
|
||||
assert document["redacted"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("private_message", "expected_code"),
|
||||
[
|
||||
(
|
||||
"CoreBluetooth not authorized; private device UUID 1111",
|
||||
"host.bluetooth.permission-denied",
|
||||
),
|
||||
(
|
||||
"CBManagerStatePoweredOff for private adapter record",
|
||||
"host.bluetooth.adapter-powered-off",
|
||||
),
|
||||
(
|
||||
"No Bluetooth adapter; private host path /Users/operator",
|
||||
"host.bluetooth.adapter-unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_corebluetooth_string_classification_never_reflects_private_details(
|
||||
private_message: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
diagnostic = host_diagnostic_for_exception(
|
||||
RuntimeError(private_message),
|
||||
boundary="corebluetooth",
|
||||
)
|
||||
|
||||
assert diagnostic is not None
|
||||
document = diagnostic.as_dict()
|
||||
assert document["code"] == expected_code
|
||||
assert private_message not in json.dumps(document)
|
||||
assert "1111" not in json.dumps(document)
|
||||
assert "/Users/operator" not in json.dumps(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reason", "expected_code"),
|
||||
[
|
||||
(
|
||||
BleakBluetoothNotAvailableReason.DENIED_BY_USER,
|
||||
"host.bluetooth.permission-denied",
|
||||
),
|
||||
(
|
||||
BleakBluetoothNotAvailableReason.POWERED_OFF,
|
||||
"host.bluetooth.adapter-powered-off",
|
||||
),
|
||||
(
|
||||
BleakBluetoothNotAvailableReason.NO_BLUETOOTH,
|
||||
"host.bluetooth.adapter-unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_structured_bleak_adapter_failure_maps_without_message_reflection(
|
||||
reason: BleakBluetoothNotAvailableReason,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
private_message = "private CoreBluetooth registry record"
|
||||
diagnostic = host_diagnostic_for_exception(
|
||||
BleakBluetoothNotAvailableError(private_message, reason),
|
||||
boundary="corebluetooth",
|
||||
)
|
||||
|
||||
assert diagnostic is not None
|
||||
assert diagnostic.code == expected_code
|
||||
assert private_message not in json.dumps(diagnostic.as_dict())
|
||||
|
||||
|
||||
def test_builtin_host_errors_require_an_explicit_boundary() -> None:
|
||||
assert host_diagnostic_for_exception(PermissionError("private")) is None
|
||||
assert host_diagnostic_for_exception(TimeoutError("private")) is None
|
||||
assert host_diagnostic_for_exception(ConnectionRefusedError("private")) is None
|
||||
|
||||
filesystem = host_diagnostic_for_exception(
|
||||
PermissionError("private ledger path"),
|
||||
boundary="filesystem",
|
||||
)
|
||||
tcp = host_diagnostic_for_exception(
|
||||
TimeoutError("private broker address"),
|
||||
boundary="tcp",
|
||||
)
|
||||
mqtt = host_diagnostic_for_exception(
|
||||
ConnectionRefusedError("private broker address"),
|
||||
boundary="mqtt",
|
||||
)
|
||||
|
||||
assert filesystem is not None
|
||||
assert filesystem.code == "host.filesystem.permission-denied"
|
||||
assert tcp is not None
|
||||
assert tcp.code == "host.tcp.connection-timeout"
|
||||
assert mqtt is not None
|
||||
assert mqtt.code == "host.mqtt.connection-refused"
|
||||
|
||||
|
||||
def test_unknown_reason_is_not_reflected_and_duplicate_diagnostics_are_collapsed() -> None:
|
||||
private_reason = "private-ssid-or-ledger-path"
|
||||
assert host_diagnostic_for_reason(private_reason) is None
|
||||
|
||||
diagnostics = host_diagnostics_for_reasons(
|
||||
"host-route-unavailable",
|
||||
"host-path-unavailable",
|
||||
private_reason,
|
||||
)
|
||||
|
||||
assert [diagnostic.code for diagnostic in diagnostics] == ["host.route.unavailable"]
|
||||
assert private_reason not in json.dumps([item.as_dict() for item in diagnostics])
|
||||
|
||||
|
||||
def test_operation_error_projects_nested_diagnostic_without_raw_exception_text() -> None:
|
||||
from k1link.device_plugins.xgrids_k1.facade import _operation_error
|
||||
|
||||
private_message = "CoreBluetooth not authorized for UUID private-device-17"
|
||||
error = _operation_error(
|
||||
RuntimeError(private_message),
|
||||
category="transport",
|
||||
side_effect_status="none",
|
||||
host_boundary="corebluetooth",
|
||||
)
|
||||
|
||||
assert error["code"] == "RuntimeError"
|
||||
assert error["host_diagnostic"] == {
|
||||
"schema_version": "missioncore.host-failure-diagnostic/v1",
|
||||
"code": "host.bluetooth.permission-denied",
|
||||
"domain": "corebluetooth",
|
||||
"impact": "discovery",
|
||||
"operator_action": "grant-bluetooth-permission",
|
||||
"automatic_retry": False,
|
||||
"redacted": True,
|
||||
}
|
||||
assert private_message not in json.dumps(error)
|
||||
assert "private-device-17" not in json.dumps(error)
|
||||
|
||||
|
||||
def test_control_session_failure_projects_keychain_diagnostic_without_message_reflection() -> None:
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
_application_control_session_public_snapshot,
|
||||
)
|
||||
|
||||
private_message = "private Keychain item label and account"
|
||||
document = _application_control_session_public_snapshot(
|
||||
{
|
||||
"state": "failed",
|
||||
"failure": {
|
||||
"reason_code": "keychain-authorization-required",
|
||||
"message": private_message,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
failure = document["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["host_diagnostic"] == {
|
||||
"schema_version": "missioncore.host-failure-diagnostic/v1",
|
||||
"code": "host.keychain.interaction-required",
|
||||
"domain": "keychain",
|
||||
"impact": "control",
|
||||
"operator_action": "unlock-or-authorize-keychain",
|
||||
"automatic_retry": False,
|
||||
"redacted": True,
|
||||
}
|
||||
assert private_message not in json.dumps(failure["host_diagnostic"])
|
||||
|
||||
|
||||
def test_corrupt_ledger_projection_exposes_only_typed_storage_diagnostic() -> None:
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
_network_mutation_ledger_public_snapshot,
|
||||
)
|
||||
|
||||
document = _network_mutation_ledger_public_snapshot(
|
||||
NetworkMutationLedgerSnapshot(
|
||||
status="corrupt",
|
||||
record=None,
|
||||
reason_code="network-mutation-ledger-corrupt",
|
||||
)
|
||||
)
|
||||
|
||||
assert document["mutation_allowed"] is False
|
||||
assert document["diagnostic"] == {
|
||||
"schema_version": "missioncore.host-failure-diagnostic/v1",
|
||||
"code": "host.filesystem.ledger-unavailable",
|
||||
"domain": "filesystem",
|
||||
"impact": "durable-safety",
|
||||
"operator_action": "inspect-local-storage",
|
||||
"automatic_retry": False,
|
||||
"redacted": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("socket_error", "expected_reason", "expected_code"),
|
||||
[
|
||||
(
|
||||
ConnectionRefusedError("private broker address refused"),
|
||||
"tcp-connection-refused",
|
||||
"host.tcp.connection-refused",
|
||||
),
|
||||
(
|
||||
TimeoutError("private broker address timed out"),
|
||||
"tcp-connection-timeout",
|
||||
"host.tcp.connection-timeout",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_broker_tcp_probe_preserves_only_refused_or_timeout_class(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
socket_error: OSError,
|
||||
expected_reason: str,
|
||||
expected_code: str,
|
||||
) -> None:
|
||||
from k1link.device_plugins.xgrids_k1.facade import _probe_control_endpoint_socket
|
||||
|
||||
def fail_connect(*_args: object, **_kwargs: object) -> socket.socket:
|
||||
raise socket_error
|
||||
|
||||
monkeypatch.setattr(socket, "create_connection", fail_connect)
|
||||
result = _probe_control_endpoint_socket("192.168.68.52")
|
||||
diagnostic = host_diagnostic_for_reason(result.reason_code)
|
||||
|
||||
assert result.reachable is False
|
||||
assert result.reason_code == expected_reason
|
||||
assert diagnostic is not None
|
||||
assert diagnostic.code == expected_code
|
||||
assert "private broker" not in json.dumps(diagnostic.as_dict())
|
||||
+696
-260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,781 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import network_mutation_ledger as ledger_module
|
||||
from k1link.device_plugins.xgrids_k1.network_mutation_ledger import (
|
||||
NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA,
|
||||
NETWORK_MUTATION_LEDGER_SCHEMA,
|
||||
NetworkMutationBlocked,
|
||||
NetworkMutationLedger,
|
||||
NetworkMutationLedgerCorrupt,
|
||||
NetworkMutationTransitionError,
|
||||
NetworkStatusEvidence,
|
||||
PreviousConnectionEvidence,
|
||||
)
|
||||
|
||||
OPERATION_ID = "op-11111111-1111-4111-8111-111111111111"
|
||||
SECOND_OPERATION_ID = "op-22222222-2222-4222-8222-222222222222"
|
||||
TRANSPORT_REF = "A161D9D5-C352-1069-D430-5FB0BC13F7F9"
|
||||
|
||||
|
||||
def _clock() -> datetime:
|
||||
return datetime(2026, 8, 6, 12, 30, tzinfo=UTC)
|
||||
|
||||
|
||||
def _baseline() -> NetworkStatusEvidence:
|
||||
return NetworkStatusEvidence(
|
||||
mode="WIFI_AP",
|
||||
ipv4="192.168.56.1",
|
||||
status_code=1,
|
||||
reserved=1,
|
||||
)
|
||||
|
||||
|
||||
def _target() -> NetworkStatusEvidence:
|
||||
return NetworkStatusEvidence(
|
||||
mode="WIFI_CLIENT",
|
||||
ipv4="192.168.68.50",
|
||||
status_code=1,
|
||||
reserved=0,
|
||||
)
|
||||
|
||||
|
||||
def _later_target() -> NetworkStatusEvidence:
|
||||
return NetworkStatusEvidence(
|
||||
mode="WIFI_CLIENT",
|
||||
ipv4="192.168.68.51",
|
||||
status_code=1,
|
||||
reserved=0,
|
||||
)
|
||||
|
||||
|
||||
def _ledger(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> NetworkMutationLedger:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
return NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
|
||||
|
||||
def _prepare(ledger: NetworkMutationLedger, *, operation_id: str = OPERATION_ID) -> None:
|
||||
ledger.prepare(
|
||||
operation_id=operation_id,
|
||||
transport_ref=TRANSPORT_REF,
|
||||
intended_mode="bridge",
|
||||
write_mode="with_response",
|
||||
baseline_status=_baseline(),
|
||||
previous_connection=PreviousConnectionEvidence(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
mode="quick-connect",
|
||||
ipv4="192.168.56.1",
|
||||
device_session_id="device-session-11111111-1111-4111-8111-111111111111",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ledger_uses_private_data_path_and_secret_free_schema(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
|
||||
expected_data_dir = tmp_path / "private-data"
|
||||
assert ledger.path == expected_data_dir / "xgrids-k1" / "network-mutation.json"
|
||||
assert stat.S_IMODE(expected_data_dir.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(ledger.path.parent.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(ledger.path.stat().st_mode) == 0o600
|
||||
|
||||
document = json.loads(ledger.path.read_text(encoding="utf-8"))
|
||||
assert document["schema_version"] == NETWORK_MUTATION_LEDGER_SCHEMA
|
||||
assert set(document) == {
|
||||
"schema_version",
|
||||
"revision",
|
||||
"operation_id",
|
||||
"transport_ref",
|
||||
"intended_mode",
|
||||
"stage",
|
||||
"write_mode",
|
||||
"baseline_status",
|
||||
"previous_connection",
|
||||
"write_confirmed",
|
||||
"last_observation",
|
||||
"resolution",
|
||||
"created_at_utc",
|
||||
"updated_at_utc",
|
||||
}
|
||||
assert set(document["previous_connection"]) == {
|
||||
"transport_ref",
|
||||
"mode",
|
||||
"ipv4",
|
||||
"device_session_id",
|
||||
}
|
||||
serialized = ledger.path.read_text(encoding="utf-8").casefold()
|
||||
assert "ssid" not in serialized
|
||||
assert "password" not in serialized
|
||||
assert "credential" not in serialized
|
||||
assert "payload" not in serialized
|
||||
|
||||
|
||||
def test_unresolved_record_survives_restart_and_resolved_record_allows_next_write(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
|
||||
restarted = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
snapshot = restarted.snapshot()
|
||||
assert snapshot.status == "unresolved"
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.stage == "prepared"
|
||||
assert snapshot.record.revision == 1
|
||||
assert snapshot.mutation_allowed is False
|
||||
with pytest.raises(NetworkMutationBlocked):
|
||||
restarted.require_mutation_allowed()
|
||||
|
||||
dispatching = restarted.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
assert dispatching.stage == "dispatching"
|
||||
assert dispatching.revision == 2
|
||||
observing = restarted.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=True,
|
||||
observation=_target(),
|
||||
)
|
||||
assert observing.stage == "observing"
|
||||
assert observing.revision == 3
|
||||
resolved = restarted.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=observing.revision,
|
||||
resolution="target-observed",
|
||||
)
|
||||
assert resolved.stage == "resolved"
|
||||
assert resolved.resolution == "target-observed"
|
||||
assert resolved.revision == 4
|
||||
|
||||
after_resolution_restart = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
final_snapshot = after_resolution_restart.snapshot()
|
||||
assert final_snapshot.status == "resolved"
|
||||
assert final_snapshot.mutation_allowed is True
|
||||
after_resolution_restart.require_mutation_allowed()
|
||||
next_record = after_resolution_restart.prepare(
|
||||
operation_id=SECOND_OPERATION_ID,
|
||||
transport_ref=TRANSPORT_REF,
|
||||
intended_mode="quick-connect",
|
||||
write_mode="with_response",
|
||||
baseline_status=_target(),
|
||||
)
|
||||
assert next_record.revision == 5
|
||||
assert next_record.stage == "prepared"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", ["interrupted", "superseded"])
|
||||
def test_dispatched_session_can_be_terminalized_as_audit_without_status_proof(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
resolution: str,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
dispatching = ledger.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
|
||||
terminal = ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
resolution=resolution, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert terminal.stage == "resolved"
|
||||
assert terminal.resolution == resolution
|
||||
assert terminal.last_observation is None
|
||||
assert ledger.snapshot().mutation_allowed is True
|
||||
|
||||
|
||||
def test_wall_clock_rollback_never_corrupts_serialized_network_stages(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
ticks = iter(datetime(2026, 8, 6, hour, tzinfo=UTC) for hour in (12, 11, 10, 9))
|
||||
ledger = NetworkMutationLedger(
|
||||
tmp_path / "repository",
|
||||
clock=lambda: next(ticks),
|
||||
)
|
||||
|
||||
def assert_restart(*, stage: str, revision: int) -> None:
|
||||
snapshot = NetworkMutationLedger(tmp_path / "repository", clock=_clock).snapshot()
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.stage == stage
|
||||
assert snapshot.record.revision == revision
|
||||
assert snapshot.record.created_at_utc == "2026-08-06T12:00:00.000Z"
|
||||
assert snapshot.record.updated_at_utc == "2026-08-06T12:00:00.000Z"
|
||||
|
||||
_prepare(ledger)
|
||||
assert_restart(stage="prepared", revision=1)
|
||||
dispatching = ledger.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
assert_restart(stage="dispatching", revision=2)
|
||||
ledger.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=True,
|
||||
observation=_target(),
|
||||
)
|
||||
assert_restart(stage="observing", revision=3)
|
||||
ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=3,
|
||||
resolution="target-observed",
|
||||
)
|
||||
assert_restart(stage="resolved", revision=4)
|
||||
|
||||
|
||||
def test_dispatch_persist_failure_reaches_caller_before_ble_side_effect(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
before = ledger.path.read_bytes()
|
||||
ble_write_called = False
|
||||
|
||||
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||
raise OSError("injected pre-dispatch persistence failure")
|
||||
|
||||
def ble_caller() -> None:
|
||||
nonlocal ble_write_called
|
||||
ledger.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
ble_write_called = True
|
||||
|
||||
monkeypatch.setattr(ledger_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="pre-dispatch persistence failure"):
|
||||
ble_caller()
|
||||
|
||||
assert ble_write_called is False
|
||||
assert ledger.path.read_bytes() == before
|
||||
snapshot = NetworkMutationLedger(tmp_path / "repository", clock=_clock).snapshot()
|
||||
assert snapshot.status == "unresolved"
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.stage == "prepared"
|
||||
|
||||
|
||||
def test_uncertain_dispatch_confirmation_refsyncs_exact_current_record(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
prepared = ledger.prepare(
|
||||
operation_id=OPERATION_ID,
|
||||
transport_ref=TRANSPORT_REF,
|
||||
intended_mode="bridge",
|
||||
write_mode="with_response",
|
||||
baseline_status=_baseline(),
|
||||
)
|
||||
dispatching = ledger.mark_dispatching(
|
||||
OPERATION_ID,
|
||||
expected_revision=prepared.revision,
|
||||
)
|
||||
fsynced_directories: list[Path] = []
|
||||
real_fsync_directory = ledger_module._fsync_directory
|
||||
|
||||
def observe_fsync(path: Path) -> None:
|
||||
fsynced_directories.append(path)
|
||||
real_fsync_directory(path)
|
||||
|
||||
monkeypatch.setattr(ledger_module, "_fsync_directory", observe_fsync)
|
||||
|
||||
confirmed = ledger.confirm_dispatching_after_uncertain_return(
|
||||
OPERATION_ID,
|
||||
expected_prepared=prepared,
|
||||
)
|
||||
|
||||
assert confirmed == dispatching
|
||||
assert confirmed.revision == prepared.revision + 1
|
||||
assert confirmed.stage == "dispatching"
|
||||
assert fsynced_directories == [ledger.path.parent]
|
||||
restarted = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
assert restarted.snapshot().record == dispatching
|
||||
|
||||
|
||||
@pytest.mark.parametrize("drift", ["predecessor", "current"])
|
||||
def test_uncertain_dispatch_confirmation_rejects_predecessor_or_current_drift(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
drift: str,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
prepared = ledger.prepare(
|
||||
operation_id=OPERATION_ID,
|
||||
transport_ref=TRANSPORT_REF,
|
||||
intended_mode="bridge",
|
||||
write_mode="with_response",
|
||||
baseline_status=_baseline(),
|
||||
)
|
||||
dispatching = ledger.mark_dispatching(
|
||||
OPERATION_ID,
|
||||
expected_revision=prepared.revision,
|
||||
)
|
||||
expected = prepared
|
||||
if drift == "predecessor":
|
||||
expected = replace(prepared, transport_ref="different-k1")
|
||||
else:
|
||||
ledger.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=False,
|
||||
)
|
||||
before = ledger.path.read_bytes()
|
||||
|
||||
with pytest.raises(
|
||||
NetworkMutationTransitionError,
|
||||
match="does not match its predecessor",
|
||||
):
|
||||
ledger.confirm_dispatching_after_uncertain_return(
|
||||
OPERATION_ID,
|
||||
expected_prepared=expected,
|
||||
)
|
||||
|
||||
assert ledger.path.read_bytes() == before
|
||||
|
||||
|
||||
def test_secret_free_legacy_v1_record_migrates_to_v2_without_changing_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
before = json.loads(ledger.path.read_text(encoding="utf-8"))
|
||||
before["schema_version"] = NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA
|
||||
ledger.path.write_text(
|
||||
json.dumps(before, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ledger.path.chmod(0o600)
|
||||
|
||||
restarted = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
|
||||
snapshot = restarted.snapshot()
|
||||
assert snapshot.status == "unresolved"
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.schema_version == NETWORK_MUTATION_LEDGER_SCHEMA
|
||||
assert snapshot.record.operation_id == OPERATION_ID
|
||||
assert snapshot.record.previous_connection is not None
|
||||
assert snapshot.record.previous_connection.transport_ref == TRANSPORT_REF
|
||||
migrated = json.loads(restarted.path.read_text(encoding="utf-8"))
|
||||
assert migrated["schema_version"] == NETWORK_MUTATION_LEDGER_SCHEMA
|
||||
assert migrated["revision"] == before["revision"]
|
||||
|
||||
|
||||
def test_ambiguous_legacy_v1_previous_connection_fails_closed_and_is_preserved(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
document = json.loads(ledger.path.read_text(encoding="utf-8"))
|
||||
document["schema_version"] = NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA
|
||||
assert isinstance(document["previous_connection"], dict)
|
||||
document["previous_connection"].pop("transport_ref")
|
||||
payload = (json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
ledger.path.write_bytes(payload)
|
||||
ledger.path.chmod(0o600)
|
||||
|
||||
restarted = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
|
||||
assert restarted.snapshot().status == "corrupt"
|
||||
with pytest.raises(NetworkMutationLedgerCorrupt):
|
||||
restarted.require_mutation_allowed()
|
||||
assert restarted.path.read_bytes() == payload
|
||||
|
||||
|
||||
def test_resolution_contract_does_not_invent_no_side_effect_after_dispatch(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
|
||||
with pytest.raises(NetworkMutationTransitionError, match="target-observed"):
|
||||
ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=1,
|
||||
resolution="target-observed",
|
||||
)
|
||||
|
||||
ledger.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
with pytest.raises(NetworkMutationTransitionError, match="not-dispatched"):
|
||||
ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=2,
|
||||
resolution="not-dispatched",
|
||||
)
|
||||
with pytest.raises(NetworkMutationTransitionError, match="status evidence"):
|
||||
ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=2,
|
||||
resolution="target-observed",
|
||||
)
|
||||
|
||||
assert ledger.snapshot().status == "unresolved"
|
||||
|
||||
|
||||
def test_prepared_record_can_be_explicitly_resolved_as_not_dispatched(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
|
||||
resolved = ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=1,
|
||||
resolution="not-dispatched",
|
||||
)
|
||||
assert resolved.stage == "resolved"
|
||||
assert resolved.resolution == "not-dispatched"
|
||||
assert resolved.write_confirmed is None
|
||||
assert resolved.last_observation is None
|
||||
ledger.require_mutation_allowed()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"corrupt_payload",
|
||||
[
|
||||
b"{corrupt\n",
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": NETWORK_MUTATION_LEDGER_SCHEMA,
|
||||
"password": "must-never-enter-ledger",
|
||||
}
|
||||
).encode("utf-8"),
|
||||
],
|
||||
)
|
||||
def test_corrupt_or_noncanonical_record_fails_closed_without_being_overwritten(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
corrupt_payload: bytes,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
path = tmp_path / "private-data" / "xgrids-k1" / "network-mutation.json"
|
||||
path.parent.mkdir(mode=0o700, parents=True)
|
||||
path.parent.chmod(0o700)
|
||||
path.write_bytes(corrupt_payload)
|
||||
path.chmod(0o600)
|
||||
before = path.read_bytes()
|
||||
|
||||
ledger = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
snapshot = ledger.snapshot()
|
||||
assert snapshot.status == "corrupt"
|
||||
assert snapshot.reason_code == "network-mutation-ledger-corrupt"
|
||||
assert snapshot.mutation_allowed is False
|
||||
with pytest.raises(NetworkMutationLedgerCorrupt):
|
||||
ledger.require_mutation_allowed()
|
||||
with pytest.raises(NetworkMutationLedgerCorrupt):
|
||||
_prepare(ledger)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_atomic_publication_fsyncs_file_and_parent_and_cleans_temporary_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
real_fsync = ledger_module.os.fsync
|
||||
fsync_kinds: list[str] = []
|
||||
|
||||
def observe_fsync(descriptor: int) -> None:
|
||||
mode = ledger_module.os.fstat(descriptor).st_mode
|
||||
fsync_kinds.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(ledger_module.os, "fsync", observe_fsync)
|
||||
_prepare(ledger)
|
||||
|
||||
assert "file" in fsync_kinds
|
||||
assert "directory" in fsync_kinds
|
||||
assert fsync_kinds.index("file") < len(fsync_kinds) - 1
|
||||
assert fsync_kinds[-1] == "directory"
|
||||
assert not list(ledger.path.parent.glob(".*.tmp"))
|
||||
|
||||
|
||||
def test_failed_atomic_replace_preserves_previous_resolved_record(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ledger = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(ledger)
|
||||
ledger.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=1,
|
||||
resolution="not-dispatched",
|
||||
)
|
||||
before = ledger.path.read_bytes()
|
||||
|
||||
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||
raise OSError("injected replace failure")
|
||||
|
||||
monkeypatch.setattr(ledger_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="injected replace failure"):
|
||||
ledger.prepare(
|
||||
operation_id=SECOND_OPERATION_ID,
|
||||
transport_ref=TRANSPORT_REF,
|
||||
intended_mode="quick-connect",
|
||||
write_mode="with_response",
|
||||
baseline_status=_target(),
|
||||
)
|
||||
|
||||
assert ledger.path.read_bytes() == before
|
||||
assert not list(ledger.path.parent.glob(".*.tmp"))
|
||||
|
||||
|
||||
def test_stale_same_operation_revision_cannot_mark_dispatching(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(current)
|
||||
stale = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
|
||||
dispatching = current.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
|
||||
with pytest.raises(NetworkMutationTransitionError, match="stale record revision"):
|
||||
stale.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
|
||||
persisted = stale.snapshot().record
|
||||
assert persisted == dispatching
|
||||
assert persisted is not None
|
||||
assert persisted.stage == "dispatching"
|
||||
assert persisted.revision == 2
|
||||
|
||||
|
||||
def test_stale_same_operation_revision_cannot_mark_observing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(current)
|
||||
dispatching = current.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
stale = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
|
||||
observing = current.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=True,
|
||||
observation=_target(),
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkMutationTransitionError, match="stale record revision"):
|
||||
stale.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=False,
|
||||
observation=_later_target(),
|
||||
)
|
||||
|
||||
persisted = stale.snapshot().record
|
||||
assert persisted == observing
|
||||
assert persisted is not None
|
||||
assert persisted.stage == "observing"
|
||||
assert persisted.revision == 3
|
||||
assert persisted.write_confirmed is True
|
||||
assert persisted.last_observation == _target()
|
||||
|
||||
|
||||
def test_stale_same_operation_revision_cannot_resolve(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(current)
|
||||
dispatching = current.mark_dispatching(OPERATION_ID, expected_revision=1)
|
||||
first_observation = current.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=dispatching.revision,
|
||||
write_confirmed=False,
|
||||
observation=_target(),
|
||||
)
|
||||
stale = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
latest = current.mark_observing(
|
||||
OPERATION_ID,
|
||||
expected_revision=first_observation.revision,
|
||||
write_confirmed=True,
|
||||
observation=_later_target(),
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkMutationTransitionError, match="stale record revision"):
|
||||
stale.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=first_observation.revision,
|
||||
resolution="target-observed",
|
||||
)
|
||||
|
||||
persisted = stale.snapshot().record
|
||||
assert persisted == latest
|
||||
assert persisted is not None
|
||||
assert persisted.stage == "observing"
|
||||
assert persisted.revision == 4
|
||||
assert persisted.resolution is None
|
||||
assert persisted.last_observation == _later_target()
|
||||
|
||||
|
||||
def test_two_instances_cannot_prepare_from_the_same_revision(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = _ledger(tmp_path, monkeypatch)
|
||||
second = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
real_write = ledger_module._write_private_json_atomic
|
||||
first_write_entered = threading.Event()
|
||||
release_first_write = threading.Event()
|
||||
second_started = threading.Event()
|
||||
second_finished = threading.Event()
|
||||
write_calls: list[str] = []
|
||||
call_lock = threading.Lock()
|
||||
outcomes: dict[str, BaseException | None] = {}
|
||||
|
||||
def blocked_first_write(
|
||||
path: Path,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> None:
|
||||
with call_lock:
|
||||
write_calls.append(str(payload["operation_id"]))
|
||||
is_first_write = len(write_calls) == 1
|
||||
if is_first_write:
|
||||
first_write_entered.set()
|
||||
assert release_first_write.wait(timeout=5)
|
||||
real_write(path, payload, data_dir=data_dir)
|
||||
|
||||
def prepare_in_thread(
|
||||
name: str,
|
||||
ledger: NetworkMutationLedger,
|
||||
operation_id: str,
|
||||
*,
|
||||
started: threading.Event | None = None,
|
||||
finished: threading.Event | None = None,
|
||||
) -> None:
|
||||
if started is not None:
|
||||
started.set()
|
||||
try:
|
||||
_prepare(ledger, operation_id=operation_id)
|
||||
except BaseException as exc: # captured for assertion in the test thread
|
||||
outcomes[name] = exc
|
||||
else:
|
||||
outcomes[name] = None
|
||||
finally:
|
||||
if finished is not None:
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(ledger_module, "_write_private_json_atomic", blocked_first_write)
|
||||
first_thread = threading.Thread(
|
||||
target=prepare_in_thread,
|
||||
args=("first", first, OPERATION_ID),
|
||||
daemon=True,
|
||||
)
|
||||
second_thread = threading.Thread(
|
||||
target=prepare_in_thread,
|
||||
args=("second", second, SECOND_OPERATION_ID),
|
||||
kwargs={"started": second_started, "finished": second_finished},
|
||||
daemon=True,
|
||||
)
|
||||
first_thread.start()
|
||||
assert first_write_entered.wait(timeout=5)
|
||||
second_thread.start()
|
||||
assert second_started.wait(timeout=5)
|
||||
try:
|
||||
assert second_finished.wait(timeout=0.2) is False
|
||||
assert write_calls == [OPERATION_ID]
|
||||
finally:
|
||||
release_first_write.set()
|
||||
first_thread.join(timeout=5)
|
||||
second_thread.join(timeout=5)
|
||||
|
||||
assert first_thread.is_alive() is False
|
||||
assert second_thread.is_alive() is False
|
||||
assert outcomes["first"] is None
|
||||
assert isinstance(outcomes["second"], NetworkMutationBlocked)
|
||||
assert write_calls == [OPERATION_ID]
|
||||
snapshot = second.snapshot()
|
||||
assert snapshot.status == "unresolved"
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.operation_id == OPERATION_ID
|
||||
assert snapshot.record.revision == 1
|
||||
|
||||
|
||||
def test_second_instance_snapshot_waits_for_in_flight_prepare_publication(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = _ledger(tmp_path, monkeypatch)
|
||||
_prepare(first)
|
||||
first.resolve(
|
||||
OPERATION_ID,
|
||||
expected_revision=1,
|
||||
resolution="not-dispatched",
|
||||
)
|
||||
second = NetworkMutationLedger(tmp_path / "repository", clock=_clock)
|
||||
real_write = ledger_module._write_private_json_atomic
|
||||
prepare_write_entered = threading.Event()
|
||||
release_prepare_write = threading.Event()
|
||||
snapshot_started = threading.Event()
|
||||
snapshot_finished = threading.Event()
|
||||
outcomes: dict[str, object] = {}
|
||||
|
||||
def blocked_prepare_write(
|
||||
path: Path,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> None:
|
||||
prepare_write_entered.set()
|
||||
assert release_prepare_write.wait(timeout=5)
|
||||
real_write(path, payload, data_dir=data_dir)
|
||||
|
||||
def prepare_next() -> None:
|
||||
try:
|
||||
_prepare(first, operation_id=SECOND_OPERATION_ID)
|
||||
except BaseException as exc: # captured for assertion in the test thread
|
||||
outcomes["prepare"] = exc
|
||||
else:
|
||||
outcomes["prepare"] = None
|
||||
|
||||
def read_snapshot() -> None:
|
||||
snapshot_started.set()
|
||||
try:
|
||||
outcomes["snapshot"] = second.snapshot()
|
||||
except BaseException as exc: # captured for assertion in the test thread
|
||||
outcomes["snapshot"] = exc
|
||||
finally:
|
||||
snapshot_finished.set()
|
||||
|
||||
monkeypatch.setattr(ledger_module, "_write_private_json_atomic", blocked_prepare_write)
|
||||
prepare_thread = threading.Thread(target=prepare_next, daemon=True)
|
||||
snapshot_thread = threading.Thread(target=read_snapshot, daemon=True)
|
||||
prepare_thread.start()
|
||||
assert prepare_write_entered.wait(timeout=5)
|
||||
snapshot_thread.start()
|
||||
assert snapshot_started.wait(timeout=5)
|
||||
try:
|
||||
assert snapshot_finished.wait(timeout=0.2) is False
|
||||
finally:
|
||||
release_prepare_write.set()
|
||||
prepare_thread.join(timeout=5)
|
||||
snapshot_thread.join(timeout=5)
|
||||
|
||||
assert prepare_thread.is_alive() is False
|
||||
assert snapshot_thread.is_alive() is False
|
||||
assert outcomes["prepare"] is None
|
||||
snapshot = outcomes["snapshot"]
|
||||
assert isinstance(snapshot, ledger_module.NetworkMutationLedgerSnapshot)
|
||||
assert snapshot.status == "unresolved"
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.operation_id == SECOND_OPERATION_ID
|
||||
assert snapshot.record.revision == 3
|
||||
with pytest.raises(NetworkMutationBlocked):
|
||||
second.require_mutation_allowed()
|
||||
@@ -0,0 +1,815 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import stat
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import (
|
||||
network_provisioning_idempotency_journal as journal_module,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.network_provisioning_idempotency_journal import (
|
||||
NetworkProvisioningIdempotencyBlocked,
|
||||
NetworkProvisioningIdempotencyConflict,
|
||||
NetworkProvisioningIdempotencyCorrupt,
|
||||
NetworkProvisioningIdempotencyJournal,
|
||||
NetworkProvisioningIdempotencyTransitionError,
|
||||
NetworkProvisioningLegacyAdoptionProvenance,
|
||||
NetworkProvisioningTerminalMetadata,
|
||||
derive_request_binding_sha256,
|
||||
)
|
||||
|
||||
ACTION = "network.provision"
|
||||
KEY = "network-provision:31d482b6-77ff-4cd4-a4b5-2bc565c21cb0"
|
||||
OPERATION_ID = "op-network-001"
|
||||
|
||||
|
||||
def _clock() -> datetime:
|
||||
return datetime(2026, 8, 7, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _binding(
|
||||
key: str = KEY,
|
||||
*,
|
||||
device_id: str = "device-k1-owned",
|
||||
password: str = "never-persist-this-password",
|
||||
) -> str:
|
||||
canonical = json.dumps(
|
||||
{
|
||||
"connection_mode": "bridge",
|
||||
"device_id": device_id,
|
||||
"password": password,
|
||||
"ssid": "never-persist-this-ssid",
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
return derive_request_binding_sha256(
|
||||
key,
|
||||
action=ACTION,
|
||||
canonical_request=canonical,
|
||||
)
|
||||
|
||||
|
||||
def _success(
|
||||
*,
|
||||
side_effect_status: str = "applied",
|
||||
) -> NetworkProvisioningTerminalMetadata:
|
||||
return NetworkProvisioningTerminalMetadata(
|
||||
outcome="succeeded",
|
||||
outcome_code="network.provision.completed",
|
||||
error_code=None,
|
||||
side_effect_status=side_effect_status, # type: ignore[arg-type]
|
||||
retryable=False,
|
||||
safe_to_retry=False,
|
||||
)
|
||||
|
||||
|
||||
def _failure_no_side_effect() -> NetworkProvisioningTerminalMetadata:
|
||||
return NetworkProvisioningTerminalMetadata(
|
||||
outcome="failed",
|
||||
outcome_code="network.provision.failed",
|
||||
error_code="host.precondition.failed",
|
||||
side_effect_status="none",
|
||||
retryable=True,
|
||||
safe_to_retry=True,
|
||||
)
|
||||
|
||||
|
||||
def _legacy_provenance(
|
||||
*,
|
||||
operation_id: str = "legacy-network-operation",
|
||||
transport_ref: str = "legacy-k1-transport",
|
||||
) -> NetworkProvisioningLegacyAdoptionProvenance:
|
||||
return NetworkProvisioningLegacyAdoptionProvenance(
|
||||
source_schema_version="missioncore.xgrids-k1-network-mutation/v2",
|
||||
operation_id=operation_id,
|
||||
transport_ref=transport_ref,
|
||||
intended_mode="bridge",
|
||||
write_mode="with_response",
|
||||
baseline_status_sha256=hashlib.sha256(b"legacy-baseline-evidence").hexdigest(),
|
||||
previous_connection_sha256=None,
|
||||
created_at_utc="2026-08-06T17:00:00.000Z",
|
||||
)
|
||||
|
||||
|
||||
def _journal(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
max_terminal_records: int = 64,
|
||||
clock: Any = _clock,
|
||||
) -> NetworkProvisioningIdempotencyJournal:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
return NetworkProvisioningIdempotencyJournal(
|
||||
tmp_path / "repository",
|
||||
max_terminal_records=max_terminal_records,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def _complete_success(
|
||||
journal: NetworkProvisioningIdempotencyJournal,
|
||||
*,
|
||||
key: str,
|
||||
operation_id: str,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
admission = journal.begin(
|
||||
idempotency_key=key,
|
||||
action=ACTION,
|
||||
operation_id=operation_id,
|
||||
request_binding_sha256=_binding(key, device_id=device_id),
|
||||
)
|
||||
unresolved = journal.mark_unresolved(
|
||||
operation_id,
|
||||
expected_revision=admission.record.revision,
|
||||
)
|
||||
journal.complete(
|
||||
operation_id,
|
||||
expected_revision=unresolved.revision,
|
||||
terminal=_success(),
|
||||
)
|
||||
|
||||
|
||||
def _cross_process_admit(
|
||||
repository_root: str,
|
||||
data_dir: str,
|
||||
key: str,
|
||||
operation_id: str,
|
||||
start: Any,
|
||||
results: Any,
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
journal = NetworkProvisioningIdempotencyJournal(Path(repository_root), clock=_clock)
|
||||
start.wait(timeout=10)
|
||||
try:
|
||||
admission = journal.begin(
|
||||
idempotency_key=key,
|
||||
action=ACTION,
|
||||
operation_id=operation_id,
|
||||
request_binding_sha256=_binding(key, device_id=operation_id),
|
||||
)
|
||||
except NetworkProvisioningIdempotencyBlocked:
|
||||
results.put(("blocked", operation_id))
|
||||
else:
|
||||
results.put((admission.disposition, operation_id))
|
||||
|
||||
|
||||
def _cross_process_replay(
|
||||
repository_root: str,
|
||||
data_dir: str,
|
||||
results: Any,
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
journal = NetworkProvisioningIdempotencyJournal(Path(repository_root), clock=_clock)
|
||||
admission = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id="op-new-process-generated-id",
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
terminal = admission.record.terminal
|
||||
results.put(
|
||||
(
|
||||
admission.disposition,
|
||||
admission.record.operation_id,
|
||||
terminal.outcome if terminal is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cross_process_adopt_legacy(
|
||||
repository_root: str,
|
||||
data_dir: str,
|
||||
operation_id: str,
|
||||
start: Any,
|
||||
results: Any,
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
journal = NetworkProvisioningIdempotencyJournal(Path(repository_root), clock=_clock)
|
||||
start.wait(timeout=10)
|
||||
try:
|
||||
adopted = journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=_legacy_provenance(operation_id=operation_id),
|
||||
)
|
||||
except NetworkProvisioningIdempotencyBlocked:
|
||||
results.put(("blocked", operation_id))
|
||||
else:
|
||||
results.put((adopted.stage, operation_id))
|
||||
|
||||
|
||||
def test_journal_persists_only_hashes_and_private_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
|
||||
admission = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
document = journal.path.read_text(encoding="utf-8")
|
||||
assert admission.disposition == "admitted"
|
||||
assert KEY not in document
|
||||
assert "never-persist-this-ssid" not in document
|
||||
assert "never-persist-this-password" not in document
|
||||
assert stat.S_IMODE(journal.path.stat().st_mode) == 0o600
|
||||
assert journal.path.stat().st_nlink == 1
|
||||
assert stat.S_IMODE(journal.path.parent.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(journal._process_lock_path.stat().st_mode) == 0o600 # noqa: SLF001
|
||||
assert journal._process_lock_path.stat().st_nlink == 1 # noqa: SLF001
|
||||
|
||||
|
||||
def test_legacy_adoption_atomically_publishes_direct_unresolved_identity(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
provenance = _legacy_provenance()
|
||||
|
||||
adopted = journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=provenance,
|
||||
)
|
||||
persisted = journal.path.read_text(encoding="utf-8")
|
||||
|
||||
assert adopted.operation_id == provenance.operation_id
|
||||
assert adopted.stage == "unresolved"
|
||||
assert adopted.terminal is None
|
||||
assert adopted.created_revision == 1
|
||||
assert adopted.revision == 1
|
||||
assert "prepared" not in persisted
|
||||
assert provenance.transport_ref not in persisted
|
||||
assert provenance.created_at_utc not in persisted
|
||||
assert adopted.idempotency_key_sha256 in persisted
|
||||
assert adopted.request_binding_sha256 in persisted
|
||||
|
||||
# A crash after atomic replace but before returning is retried exactly,
|
||||
# without creating a competing record or advancing durable revision.
|
||||
restarted = NetworkProvisioningIdempotencyJournal(
|
||||
tmp_path / "repository",
|
||||
clock=_clock,
|
||||
)
|
||||
replayed = restarted.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=provenance,
|
||||
)
|
||||
assert replayed == adopted
|
||||
assert restarted.snapshot().revision == 1
|
||||
assert len(restarted.snapshot().records) == 1
|
||||
|
||||
|
||||
def test_legacy_adoption_refuses_competing_active_and_operation_rebinding(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
active = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkProvisioningIdempotencyBlocked):
|
||||
journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=_legacy_provenance(),
|
||||
)
|
||||
assert journal.snapshot().active_record == active.record
|
||||
|
||||
journal.complete(
|
||||
OPERATION_ID,
|
||||
expected_revision=active.record.revision,
|
||||
terminal=_failure_no_side_effect(),
|
||||
)
|
||||
first = journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=_legacy_provenance(),
|
||||
)
|
||||
assert first.stage == "unresolved"
|
||||
with pytest.raises(NetworkProvisioningIdempotencyConflict):
|
||||
journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=_legacy_provenance(transport_ref="different-legacy-transport"),
|
||||
)
|
||||
snapshot = journal.snapshot()
|
||||
assert snapshot.active_record == first
|
||||
assert len(snapshot.records) == 2
|
||||
|
||||
|
||||
def test_failed_atomic_legacy_adoption_leaves_no_prepared_or_partial_record(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
|
||||
def fail_replace(_source: object, _target: object) -> None:
|
||||
raise OSError("simulated legacy adoption publication failure")
|
||||
|
||||
with monkeypatch.context() as publication_failure:
|
||||
publication_failure.setattr(journal_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="legacy adoption publication failure"):
|
||||
journal.adopt_legacy_unresolved(
|
||||
action=ACTION,
|
||||
provenance=_legacy_provenance(),
|
||||
)
|
||||
|
||||
assert not journal.path.exists()
|
||||
assert not list(journal.path.parent.glob(".*.tmp"))
|
||||
restarted = NetworkProvisioningIdempotencyJournal(
|
||||
tmp_path / "repository",
|
||||
clock=_clock,
|
||||
)
|
||||
assert restarted.snapshot().status == "empty"
|
||||
assert restarted.snapshot().active_record is None
|
||||
|
||||
|
||||
def test_same_terminal_request_replays_across_restart_without_rebinding_operation(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
_complete_success(
|
||||
journal,
|
||||
key=KEY,
|
||||
operation_id=OPERATION_ID,
|
||||
device_id="device-k1-owned",
|
||||
)
|
||||
|
||||
restarted = NetworkProvisioningIdempotencyJournal(
|
||||
tmp_path / "repository",
|
||||
clock=_clock,
|
||||
)
|
||||
replay = restarted.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id="op-generated-after-restart",
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
assert replay.disposition == "terminal-replay"
|
||||
assert replay.created is False
|
||||
assert replay.record.operation_id == OPERATION_ID
|
||||
assert replay.record.terminal == _success()
|
||||
|
||||
|
||||
def test_same_key_cannot_be_rebound_to_another_request_or_action(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
_complete_success(
|
||||
journal,
|
||||
key=KEY,
|
||||
operation_id=OPERATION_ID,
|
||||
device_id="device-k1-owned",
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkProvisioningIdempotencyConflict, match="another request"):
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id="op-different-binding",
|
||||
request_binding_sha256=_binding(device_id="different-device"),
|
||||
)
|
||||
with pytest.raises(NetworkProvisioningIdempotencyConflict, match="another request"):
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action="network.reconcile",
|
||||
operation_id="op-different-action",
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cross_ambiguity_boundary", [False, True])
|
||||
def test_nonterminal_request_blocks_its_replay_and_every_new_side_effect(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
cross_ambiguity_boundary: bool,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
admission = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
if cross_ambiguity_boundary:
|
||||
journal.mark_unresolved(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision,
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkProvisioningIdempotencyBlocked) as replay_error:
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id="op-replay",
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
with pytest.raises(NetworkProvisioningIdempotencyBlocked) as new_error:
|
||||
journal.begin(
|
||||
idempotency_key="different-key",
|
||||
action=ACTION,
|
||||
operation_id="op-new",
|
||||
request_binding_sha256=_binding("different-key", device_id="different-device"),
|
||||
)
|
||||
|
||||
assert replay_error.value.record is not None
|
||||
assert new_error.value.record is not None
|
||||
assert replay_error.value.record.operation_id == OPERATION_ID
|
||||
assert new_error.value.record.operation_id == OPERATION_ID
|
||||
|
||||
|
||||
def test_revision_cas_and_stage_contract_guard_the_side_effect_boundary(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
admission = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
with pytest.raises(NetworkProvisioningIdempotencyTransitionError, match="stale"):
|
||||
journal.mark_unresolved(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision + 1,
|
||||
)
|
||||
with pytest.raises(NetworkProvisioningIdempotencyTransitionError, match="no-side-effect"):
|
||||
journal.complete(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision,
|
||||
terminal=_success(),
|
||||
)
|
||||
|
||||
failed = journal.complete(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision,
|
||||
terminal=_failure_no_side_effect(),
|
||||
)
|
||||
assert failed.stage == "terminal"
|
||||
assert (
|
||||
journal.complete(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision,
|
||||
terminal=_failure_no_side_effect(),
|
||||
)
|
||||
== failed
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_history_is_bounded_without_evicting_active_record(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch, max_terminal_records=2)
|
||||
for index in range(3):
|
||||
_complete_success(
|
||||
journal,
|
||||
key=f"key-{index}",
|
||||
operation_id=f"op-{index}",
|
||||
device_id=f"device-{index}",
|
||||
)
|
||||
|
||||
snapshot = journal.snapshot()
|
||||
assert snapshot.status == "ready"
|
||||
assert [record.operation_id for record in snapshot.records] == ["op-1", "op-2"]
|
||||
|
||||
active = journal.begin(
|
||||
idempotency_key="key-active",
|
||||
action=ACTION,
|
||||
operation_id="op-active",
|
||||
request_binding_sha256=_binding("key-active", device_id="device-active"),
|
||||
)
|
||||
assert active.created is True
|
||||
blocked = journal.snapshot()
|
||||
assert blocked.status == "blocked"
|
||||
assert blocked.active_record is not None
|
||||
assert blocked.active_record.operation_id == "op-active"
|
||||
assert len([record for record in blocked.records if record.stage == "terminal"]) == 2
|
||||
|
||||
|
||||
def test_corruption_fails_closed_and_is_not_overwritten(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
path = tmp_path / "private-data" / "xgrids-k1" / "network-provisioning-idempotency.json"
|
||||
path.parent.mkdir(mode=0o700, parents=True)
|
||||
(tmp_path / "private-data").chmod(0o700)
|
||||
path.parent.chmod(0o700)
|
||||
path.write_bytes(b'{"schema_version":"wrong","password":"must-stay-unread"}\n')
|
||||
path.chmod(0o600)
|
||||
before = path.read_bytes()
|
||||
|
||||
journal = NetworkProvisioningIdempotencyJournal(tmp_path / "repository", clock=_clock)
|
||||
|
||||
assert journal.snapshot().status == "corrupt"
|
||||
with pytest.raises(NetworkProvisioningIdempotencyCorrupt):
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_journal_rejects_symlink_and_hardlink_storage(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
target = journal.path.parent / "journal-target.json"
|
||||
journal.path.replace(target)
|
||||
journal.path.symlink_to(target)
|
||||
|
||||
assert journal.snapshot().status == "corrupt"
|
||||
journal.path.unlink()
|
||||
target.replace(journal.path)
|
||||
hardlink = journal.path.parent / "journal-hardlink.json"
|
||||
os.link(journal.path, hardlink)
|
||||
|
||||
assert journal.snapshot().status == "corrupt"
|
||||
assert journal.path.stat().st_nlink == 2
|
||||
|
||||
|
||||
def test_cross_process_lock_rejects_an_extra_hardlink(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
hardlink = journal.path.parent / "lock-hardlink"
|
||||
os.link(journal._process_lock_path, hardlink) # noqa: SLF001
|
||||
|
||||
with pytest.raises(NetworkProvisioningIdempotencyCorrupt, match="lock is unsafe"):
|
||||
journal.snapshot()
|
||||
|
||||
|
||||
def test_atomic_publication_fsyncs_file_then_parent_and_cleans_tempfiles(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
real_fsync = journal_module.os.fsync
|
||||
fsync_kinds: list[str] = []
|
||||
|
||||
def observe_fsync(descriptor: int) -> None:
|
||||
mode = journal_module.os.fstat(descriptor).st_mode
|
||||
fsync_kinds.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(journal_module.os, "fsync", observe_fsync)
|
||||
journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
|
||||
assert "file" in fsync_kinds
|
||||
assert fsync_kinds[-1] == "directory"
|
||||
assert fsync_kinds.index("file") < len(fsync_kinds) - 1
|
||||
assert not list(journal.path.parent.glob(".*.tmp"))
|
||||
|
||||
|
||||
def test_failed_atomic_replace_preserves_previous_terminal_history(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
_complete_success(
|
||||
journal,
|
||||
key=KEY,
|
||||
operation_id=OPERATION_ID,
|
||||
device_id="device-k1-owned",
|
||||
)
|
||||
before = journal.path.read_bytes()
|
||||
|
||||
def fail_replace(_source: object, _target: object) -> None:
|
||||
raise OSError("simulated atomic publication failure")
|
||||
|
||||
with monkeypatch.context() as publication_failure:
|
||||
publication_failure.setattr(journal_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="atomic publication failure"):
|
||||
journal.begin(
|
||||
idempotency_key="key-after-terminal",
|
||||
action=ACTION,
|
||||
operation_id="op-after-terminal",
|
||||
request_binding_sha256=_binding(
|
||||
"key-after-terminal",
|
||||
device_id="device-after-terminal",
|
||||
),
|
||||
)
|
||||
|
||||
assert journal.path.read_bytes() == before
|
||||
assert not list(journal.path.parent.glob(".*.tmp"))
|
||||
restarted = NetworkProvisioningIdempotencyJournal(tmp_path / "repository", clock=_clock)
|
||||
assert restarted.snapshot().status == "ready"
|
||||
|
||||
|
||||
def test_revision_not_wall_clock_orders_transitions_and_restart(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
moments = iter(
|
||||
[
|
||||
datetime(2026, 8, 7, 12, 0, tzinfo=UTC),
|
||||
datetime(2026, 8, 7, 11, 0, tzinfo=UTC),
|
||||
datetime(2026, 8, 7, 10, 0, tzinfo=UTC),
|
||||
datetime(2026, 8, 7, 9, 0, tzinfo=UTC),
|
||||
]
|
||||
)
|
||||
journal = _journal(tmp_path, monkeypatch, clock=lambda: next(moments))
|
||||
admission = journal.begin(
|
||||
idempotency_key=KEY,
|
||||
action=ACTION,
|
||||
operation_id=OPERATION_ID,
|
||||
request_binding_sha256=_binding(),
|
||||
)
|
||||
unresolved = journal.mark_unresolved(
|
||||
OPERATION_ID,
|
||||
expected_revision=admission.record.revision,
|
||||
)
|
||||
terminal = journal.complete(
|
||||
OPERATION_ID,
|
||||
expected_revision=unresolved.revision,
|
||||
terminal=_success(),
|
||||
)
|
||||
later_revision = journal.begin(
|
||||
idempotency_key="key-after-clock-rollback",
|
||||
action=ACTION,
|
||||
operation_id="op-after-clock-rollback",
|
||||
request_binding_sha256=_binding(
|
||||
"key-after-clock-rollback",
|
||||
device_id="device-after-clock-rollback",
|
||||
),
|
||||
).record
|
||||
|
||||
assert admission.record.revision < unresolved.revision < terminal.revision
|
||||
assert terminal.updated_at_utc == admission.record.created_at_utc
|
||||
assert later_revision.revision > terminal.revision
|
||||
assert later_revision.created_at_utc < terminal.updated_at_utc
|
||||
restarted = NetworkProvisioningIdempotencyJournal(tmp_path / "repository", clock=_clock)
|
||||
assert restarted.snapshot().status == "blocked"
|
||||
|
||||
|
||||
def test_discontinuous_record_revision_lineage_fails_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal = _journal(tmp_path, monkeypatch)
|
||||
_complete_success(
|
||||
journal,
|
||||
key=KEY,
|
||||
operation_id=OPERATION_ID,
|
||||
device_id="device-k1-owned",
|
||||
)
|
||||
document = json.loads(journal.path.read_text(encoding="utf-8"))
|
||||
record = document["records"][0]
|
||||
assert record["revision"] == 3
|
||||
assert record["previous_revision"] == 2
|
||||
record["previous_revision"] = 1
|
||||
journal.path.write_text(
|
||||
json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
restarted = NetworkProvisioningIdempotencyJournal(tmp_path / "repository", clock=_clock)
|
||||
|
||||
assert restarted.snapshot().status == "corrupt"
|
||||
with pytest.raises(NetworkProvisioningIdempotencyCorrupt):
|
||||
restarted.begin(
|
||||
idempotency_key="blocked-after-lineage-corruption",
|
||||
action=ACTION,
|
||||
operation_id="blocked-after-lineage-corruption",
|
||||
request_binding_sha256=_binding(
|
||||
"blocked-after-lineage-corruption",
|
||||
device_id="blocked-after-lineage-corruption",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_flock_allows_only_one_cross_process_admission(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_dir = tmp_path / "private-data"
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(data_dir))
|
||||
repository_root = tmp_path / "repository"
|
||||
context = multiprocessing.get_context("fork")
|
||||
start = context.Event()
|
||||
results = context.Queue()
|
||||
processes = [
|
||||
context.Process(
|
||||
target=_cross_process_admit,
|
||||
args=(
|
||||
str(repository_root),
|
||||
str(data_dir),
|
||||
f"cross-process-key-{index}",
|
||||
f"cross-process-op-{index}",
|
||||
start,
|
||||
results,
|
||||
),
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
for process in processes:
|
||||
process.start()
|
||||
start.set()
|
||||
for process in processes:
|
||||
process.join(timeout=15)
|
||||
assert process.exitcode == 0
|
||||
|
||||
outcomes = sorted(results.get(timeout=2) for _ in processes)
|
||||
assert [outcome[0] for outcome in outcomes] == ["admitted", "blocked"]
|
||||
snapshot = NetworkProvisioningIdempotencyJournal(repository_root, clock=_clock).snapshot()
|
||||
assert snapshot.status == "blocked"
|
||||
assert len(snapshot.records) == 1
|
||||
|
||||
|
||||
def test_flock_allows_only_one_competing_cross_process_legacy_adoption(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_dir = tmp_path / "private-data"
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(data_dir))
|
||||
repository_root = tmp_path / "repository"
|
||||
context = multiprocessing.get_context("fork")
|
||||
start = context.Event()
|
||||
results = context.Queue()
|
||||
processes = [
|
||||
context.Process(
|
||||
target=_cross_process_adopt_legacy,
|
||||
args=(
|
||||
str(repository_root),
|
||||
str(data_dir),
|
||||
f"legacy-cross-process-op-{index}",
|
||||
start,
|
||||
results,
|
||||
),
|
||||
)
|
||||
for index in range(2)
|
||||
]
|
||||
for process in processes:
|
||||
process.start()
|
||||
start.set()
|
||||
for process in processes:
|
||||
process.join(timeout=15)
|
||||
assert process.exitcode == 0
|
||||
|
||||
outcomes = sorted(results.get(timeout=2) for _ in processes)
|
||||
assert [outcome[0] for outcome in outcomes] == ["blocked", "unresolved"]
|
||||
snapshot = NetworkProvisioningIdempotencyJournal(repository_root, clock=_clock).snapshot()
|
||||
assert snapshot.status == "blocked"
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.active_record is not None
|
||||
assert snapshot.active_record.stage == "unresolved"
|
||||
|
||||
|
||||
def test_terminal_replay_is_identical_in_another_process(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_dir = tmp_path / "private-data"
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(data_dir))
|
||||
repository_root = tmp_path / "repository"
|
||||
journal = NetworkProvisioningIdempotencyJournal(repository_root, clock=_clock)
|
||||
_complete_success(
|
||||
journal,
|
||||
key=KEY,
|
||||
operation_id=OPERATION_ID,
|
||||
device_id="device-k1-owned",
|
||||
)
|
||||
context = multiprocessing.get_context("fork")
|
||||
results = context.Queue()
|
||||
process = context.Process(
|
||||
target=_cross_process_replay,
|
||||
args=(str(repository_root), str(data_dir), results),
|
||||
)
|
||||
process.start()
|
||||
process.join(timeout=15)
|
||||
|
||||
assert process.exitcode == 0
|
||||
assert results.get(timeout=2) == ("terminal-replay", OPERATION_ID, "succeeded")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import semantic_topology_store as store_module
|
||||
from k1link.device_plugins.xgrids_k1.semantic_topology_store import (
|
||||
SEMANTIC_TOPOLOGY_MAX_BYTES,
|
||||
SEMANTIC_TOPOLOGY_SCHEMA,
|
||||
SemanticTopologyRecord,
|
||||
SemanticTopologyStore,
|
||||
SemanticTopologyStoreCorrupt,
|
||||
StaleSemanticTopologyObservation,
|
||||
)
|
||||
|
||||
TRANSPORT_REF = "A161D9D5-C352-1069-D430-5FB0BC13F7F9"
|
||||
PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||
|
||||
|
||||
def _commit_while_holding_process_lock(
|
||||
data_dir: str,
|
||||
repository_root: str,
|
||||
release_path: str,
|
||||
events: multiprocessing.Queue[tuple[str, int]],
|
||||
) -> None:
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = data_dir
|
||||
store = SemanticTopologyStore(Path(repository_root))
|
||||
real_write = store_module._write_private_json_atomic
|
||||
|
||||
def hold_lock(
|
||||
path: Path,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> None:
|
||||
events.put(("entered", 0))
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(release_path).exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("parent did not release cross-process topology commit")
|
||||
time.sleep(0.01)
|
||||
real_write(path, payload, data_dir=data_dir)
|
||||
|
||||
store_module._write_private_json_atomic = hold_lock
|
||||
record = store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="bridge",
|
||||
ipv4="192.168.68.50",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-read-only-status",
|
||||
observed_at_utc="2026-08-06T12:30:00.000Z",
|
||||
)
|
||||
events.put(("committed", record.revision))
|
||||
|
||||
|
||||
def _store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SemanticTopologyStore:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
return SemanticTopologyStore(tmp_path / "repository")
|
||||
|
||||
|
||||
def _commit(
|
||||
store: SemanticTopologyStore,
|
||||
*,
|
||||
ipv4: str = "192.168.68.50",
|
||||
mode: str = "bridge",
|
||||
source: str = "ble-read-only-status",
|
||||
observed_at: str = "2026-08-06T12:30:00.000Z",
|
||||
) -> None:
|
||||
store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode=mode, # type: ignore[arg-type]
|
||||
ipv4=ipv4,
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source=source, # type: ignore[arg-type]
|
||||
observed_at_utc=observed_at,
|
||||
)
|
||||
|
||||
|
||||
def test_store_is_private_secret_free_and_restart_evidence_is_offline_only(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
record = store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="bridge",
|
||||
ipv4="192.168.68.50",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-read-only-status",
|
||||
observed_at_utc="2026-08-06T12:30:00Z",
|
||||
)
|
||||
|
||||
assert record.revision == 1
|
||||
assert record.observed_at_utc == "2026-08-06T12:30:00.000Z"
|
||||
assert record.live_connection_authority is False
|
||||
assert stat.S_IMODE((tmp_path / "private-data").stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(store.path.parent.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(store.path.stat().st_mode) == 0o600
|
||||
assert store.path.stat().st_nlink == 1
|
||||
|
||||
document = json.loads(store.path.read_text(encoding="utf-8"))
|
||||
assert document == record.as_dict()
|
||||
assert set(document) == {
|
||||
"schema_version",
|
||||
"revision",
|
||||
"transport_ref",
|
||||
"connection_mode",
|
||||
"ipv4",
|
||||
"compatibility_profile_id",
|
||||
"firmware_version",
|
||||
"source",
|
||||
"observed_at_utc",
|
||||
}
|
||||
serialized = store.path.read_text(encoding="utf-8").casefold()
|
||||
assert "ssid" not in serialized
|
||||
assert "password" not in serialized
|
||||
assert "credential" not in serialized
|
||||
assert "secret" not in serialized
|
||||
|
||||
restarted = SemanticTopologyStore(tmp_path / "repository")
|
||||
snapshot = restarted.snapshot()
|
||||
assert snapshot.status == "available"
|
||||
assert snapshot.record == record
|
||||
assert snapshot.configured_offline_evidence is True
|
||||
assert snapshot.live_connection_authority is False
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.live_connection_authority is False
|
||||
assert snapshot.as_dict() == {
|
||||
"schema_version": SEMANTIC_TOPOLOGY_SCHEMA,
|
||||
"status": "available",
|
||||
"configured_offline_evidence": True,
|
||||
"live_connection_authority": False,
|
||||
"reason_code": None,
|
||||
"record": record.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def test_serialized_commit_accepts_wall_rollback_and_lineage_rejects_stale_writer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_commit(store, observed_at="2026-08-06T12:30:01.000Z")
|
||||
second = store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="quick-connect",
|
||||
ipv4="192.168.56.1",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-post-write-status",
|
||||
observed_at_utc="2026-08-06T11:30:02.000Z",
|
||||
predecessor_revision=1,
|
||||
)
|
||||
|
||||
assert second.revision == 2
|
||||
assert second.connection_mode == "quick-connect"
|
||||
assert second.observed_at_utc == "2026-08-06T11:30:02.000Z"
|
||||
before = store.path.read_bytes()
|
||||
with pytest.raises(StaleSemanticTopologyObservation):
|
||||
store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="bridge",
|
||||
ipv4="192.168.68.50",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-read-only-status",
|
||||
observed_at_utc="2026-08-06T13:30:01.500Z",
|
||||
predecessor_revision=1,
|
||||
)
|
||||
assert store.path.read_bytes() == before
|
||||
|
||||
third = store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="bridge",
|
||||
ipv4="192.168.68.50",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-read-only-status",
|
||||
observed_at_utc="2026-08-06T10:30:01.000Z",
|
||||
predecessor_revision=2,
|
||||
)
|
||||
assert third.revision == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("connection_mode", "automatic"),
|
||||
("ipv4", "192.168.068.050"),
|
||||
("ipv4", "2001:db8::1"),
|
||||
("transport_ref", "../../scanner"),
|
||||
("compatibility_profile_id", "profile\nleak"),
|
||||
("firmware_version", "3.0.2\npassword=x"),
|
||||
("source", "tcp-probe"),
|
||||
("observed_at_utc", "2026-08-06T12:30:00+03:00"),
|
||||
],
|
||||
)
|
||||
def test_commit_rejects_values_outside_the_bounded_schema(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
field: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
arguments: dict[str, object] = {
|
||||
"transport_ref": TRANSPORT_REF,
|
||||
"connection_mode": "bridge",
|
||||
"ipv4": "192.168.68.50",
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
"firmware_version": "3.0.2",
|
||||
"source": "ble-read-only-status",
|
||||
"observed_at_utc": "2026-08-06T12:30:00.000Z",
|
||||
}
|
||||
arguments[field] = value
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
store.commit(**arguments) # type: ignore[arg-type]
|
||||
assert store.snapshot().status == "empty"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b'{"schema_version":"missioncore.xgrids-k1-semantic-topology/v1",'
|
||||
b'"schema_version":"missioncore.xgrids-k1-semantic-topology/v1"}\n',
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": SEMANTIC_TOPOLOGY_SCHEMA,
|
||||
"revision": 1,
|
||||
"transport_ref": TRANSPORT_REF,
|
||||
"connection_mode": "bridge",
|
||||
"ipv4": "192.168.68.50",
|
||||
"compatibility_profile_id": PROFILE_ID,
|
||||
"firmware_version": "3.0.2",
|
||||
"source": "ble-read-only-status",
|
||||
"observed_at_utc": "2026-08-06T12:30:00.000Z",
|
||||
"ssid": "must-not-be-stored",
|
||||
}
|
||||
).encode(),
|
||||
b"{" + b"x" * SEMANTIC_TOPOLOGY_MAX_BYTES + b"}",
|
||||
],
|
||||
)
|
||||
def test_duplicate_unknown_or_oversize_json_fails_closed_without_overwrite(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
path = tmp_path / "private-data" / "xgrids-k1" / "semantic-topology.json"
|
||||
path.parent.mkdir(mode=0o700, parents=True)
|
||||
(tmp_path / "private-data").chmod(0o700)
|
||||
path.parent.chmod(0o700)
|
||||
path.write_bytes(payload)
|
||||
path.chmod(0o600)
|
||||
|
||||
store = SemanticTopologyStore(tmp_path / "repository")
|
||||
assert store.snapshot().status == "corrupt"
|
||||
before = path.read_bytes()
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt):
|
||||
_commit(store)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_symlink_hardlink_and_nonprivate_file_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
parent = tmp_path / "private-data" / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700, parents=True)
|
||||
(tmp_path / "private-data").chmod(0o700)
|
||||
parent.chmod(0o700)
|
||||
path = parent / "semantic-topology.json"
|
||||
target = tmp_path / "outside.json"
|
||||
target.write_text("{}", encoding="utf-8")
|
||||
target.chmod(0o600)
|
||||
path.symlink_to(target)
|
||||
|
||||
symlink_store = SemanticTopologyStore(tmp_path / "repository")
|
||||
assert symlink_store.snapshot().status == "corrupt"
|
||||
path.unlink()
|
||||
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
hardlink = tmp_path / "second-link.json"
|
||||
os.link(path, hardlink)
|
||||
hardlink_store = SemanticTopologyStore(tmp_path / "repository")
|
||||
assert hardlink_store.snapshot().status == "corrupt"
|
||||
hardlink.unlink()
|
||||
path.unlink()
|
||||
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
path.chmod(0o644)
|
||||
permission_store = SemanticTopologyStore(tmp_path / "repository")
|
||||
assert permission_store.snapshot().status == "corrupt"
|
||||
|
||||
|
||||
def test_nonprivate_directory_and_unsafe_lock_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
data_dir = tmp_path / "private-data"
|
||||
data_dir.mkdir(mode=0o755)
|
||||
data_dir.chmod(0o755)
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt, match="permissions"):
|
||||
SemanticTopologyStore(tmp_path / "repository")
|
||||
|
||||
data_dir.chmod(0o700)
|
||||
parent = data_dir / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700)
|
||||
lock = parent / ".semantic-topology.lock"
|
||||
lock.write_bytes(b"not-empty")
|
||||
lock.chmod(0o600)
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt, match="stable private"):
|
||||
SemanticTopologyStore(tmp_path / "repository")
|
||||
|
||||
|
||||
def test_symlink_hardlink_and_nonprivate_lock_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_DATA_DIR", str(tmp_path / "private-data"))
|
||||
data_dir = tmp_path / "private-data"
|
||||
parent = data_dir / "xgrids-k1"
|
||||
parent.mkdir(mode=0o700, parents=True)
|
||||
data_dir.chmod(0o700)
|
||||
parent.chmod(0o700)
|
||||
lock = parent / ".semantic-topology.lock"
|
||||
target = tmp_path / "outside.lock"
|
||||
target.touch(mode=0o600)
|
||||
lock.symlink_to(target)
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt, match="opened safely"):
|
||||
SemanticTopologyStore(tmp_path / "repository")
|
||||
lock.unlink()
|
||||
|
||||
lock.touch(mode=0o600)
|
||||
lock.chmod(0o600)
|
||||
hardlink = tmp_path / "second.lock"
|
||||
os.link(lock, hardlink)
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt, match="stable private"):
|
||||
SemanticTopologyStore(tmp_path / "repository")
|
||||
hardlink.unlink()
|
||||
lock.unlink()
|
||||
|
||||
lock.touch(mode=0o600)
|
||||
lock.chmod(0o644)
|
||||
with pytest.raises(SemanticTopologyStoreCorrupt, match="stable private"):
|
||||
SemanticTopologyStore(tmp_path / "repository")
|
||||
|
||||
|
||||
def test_atomic_publication_fsyncs_file_and_parent_and_cleans_temp_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
real_fsync = store_module.os.fsync
|
||||
fsync_kinds: list[str] = []
|
||||
|
||||
def observe_fsync(descriptor: int) -> None:
|
||||
mode = store_module.os.fstat(descriptor).st_mode
|
||||
fsync_kinds.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(store_module.os, "fsync", observe_fsync)
|
||||
_commit(store)
|
||||
|
||||
assert "file" in fsync_kinds
|
||||
assert fsync_kinds[-1] == "directory"
|
||||
assert not list(store.path.parent.glob(".semantic-topology.json.*.tmp"))
|
||||
|
||||
|
||||
def test_failed_atomic_replace_preserves_previous_record(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_commit(store)
|
||||
before = store.path.read_bytes()
|
||||
|
||||
def fail_replace(_source: Path, _destination: Path) -> None:
|
||||
raise OSError("injected replace failure")
|
||||
|
||||
monkeypatch.setattr(store_module.os, "replace", fail_replace)
|
||||
with pytest.raises(OSError, match="injected replace failure"):
|
||||
_commit(store, observed_at="2026-08-06T12:31:00.000Z")
|
||||
assert store.path.read_bytes() == before
|
||||
assert not list(store.path.parent.glob(".semantic-topology.json.*.tmp"))
|
||||
|
||||
|
||||
def test_two_instances_serialize_reload_and_revision_publication(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = _store(tmp_path, monkeypatch)
|
||||
second = SemanticTopologyStore(tmp_path / "repository")
|
||||
real_write = store_module._write_private_json_atomic
|
||||
first_write_entered = threading.Event()
|
||||
release_first_write = threading.Event()
|
||||
second_started = threading.Event()
|
||||
second_finished = threading.Event()
|
||||
write_count = 0
|
||||
count_lock = threading.Lock()
|
||||
results: dict[str, object] = {}
|
||||
|
||||
def blocked_first_write(
|
||||
path: Path,
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
data_dir: Path,
|
||||
) -> None:
|
||||
nonlocal write_count
|
||||
with count_lock:
|
||||
write_count += 1
|
||||
should_block = write_count == 1
|
||||
if should_block:
|
||||
first_write_entered.set()
|
||||
assert release_first_write.wait(timeout=5)
|
||||
real_write(path, payload, data_dir=data_dir)
|
||||
|
||||
def run_first() -> None:
|
||||
results["first"] = first.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="bridge",
|
||||
ipv4="192.168.68.50",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-read-only-status",
|
||||
observed_at_utc="2026-08-06T12:30:00.000Z",
|
||||
)
|
||||
|
||||
def run_second() -> None:
|
||||
second_started.set()
|
||||
try:
|
||||
results["second"] = second.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="quick-connect",
|
||||
ipv4="192.168.56.1",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-post-write-status",
|
||||
observed_at_utc="2026-08-06T12:30:01.000Z",
|
||||
)
|
||||
finally:
|
||||
second_finished.set()
|
||||
|
||||
monkeypatch.setattr(store_module, "_write_private_json_atomic", blocked_first_write)
|
||||
first_thread = threading.Thread(target=run_first, daemon=True)
|
||||
second_thread = threading.Thread(target=run_second, daemon=True)
|
||||
first_thread.start()
|
||||
assert first_write_entered.wait(timeout=5)
|
||||
second_thread.start()
|
||||
assert second_started.wait(timeout=5)
|
||||
try:
|
||||
assert second_finished.wait(timeout=0.2) is False
|
||||
finally:
|
||||
release_first_write.set()
|
||||
first_thread.join(timeout=5)
|
||||
second_thread.join(timeout=5)
|
||||
|
||||
assert first_thread.is_alive() is False
|
||||
assert second_thread.is_alive() is False
|
||||
assert isinstance(results["first"], SemanticTopologyRecord)
|
||||
assert isinstance(results["second"], SemanticTopologyRecord)
|
||||
assert results["first"].revision == 1
|
||||
assert results["second"].revision == 2
|
||||
snapshot = SemanticTopologyStore(tmp_path / "repository").snapshot()
|
||||
assert snapshot.record is not None
|
||||
assert snapshot.record.revision == 2
|
||||
assert snapshot.record.connection_mode == "quick-connect"
|
||||
|
||||
|
||||
def test_separate_process_commit_holds_stable_flock_for_whole_transaction(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
context = multiprocessing.get_context("spawn")
|
||||
events = context.Queue()
|
||||
release_path = tmp_path / "release-child"
|
||||
child = context.Process(
|
||||
target=_commit_while_holding_process_lock,
|
||||
args=(
|
||||
str(tmp_path / "private-data"),
|
||||
str(tmp_path / "repository"),
|
||||
str(release_path),
|
||||
events,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
parent_finished = threading.Event()
|
||||
outcome: dict[str, object] = {}
|
||||
|
||||
def commit_from_parent() -> None:
|
||||
try:
|
||||
outcome["record"] = store.commit(
|
||||
transport_ref=TRANSPORT_REF,
|
||||
connection_mode="quick-connect",
|
||||
ipv4="192.168.56.1",
|
||||
compatibility_profile_id=PROFILE_ID,
|
||||
firmware_version="3.0.2",
|
||||
source="ble-post-write-status",
|
||||
observed_at_utc="2026-08-06T12:30:01.000Z",
|
||||
)
|
||||
finally:
|
||||
parent_finished.set()
|
||||
|
||||
child.start()
|
||||
parent_thread: threading.Thread | None = None
|
||||
try:
|
||||
assert events.get(timeout=5) == ("entered", 0)
|
||||
parent_thread = threading.Thread(target=commit_from_parent, daemon=True)
|
||||
parent_thread.start()
|
||||
assert parent_finished.wait(timeout=0.2) is False
|
||||
release_path.touch()
|
||||
assert events.get(timeout=5) == ("committed", 1)
|
||||
child.join(timeout=5)
|
||||
parent_thread.join(timeout=5)
|
||||
finally:
|
||||
if child.is_alive():
|
||||
child.terminate()
|
||||
child.join(timeout=5)
|
||||
|
||||
assert child.exitcode == 0
|
||||
assert parent_thread is not None
|
||||
assert parent_thread.is_alive() is False
|
||||
assert isinstance(outcome["record"], SemanticTopologyRecord)
|
||||
assert outcome["record"].revision == 2
|
||||
Reference in New Issue
Block a user