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:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 deletions
+50
View File
@@ -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)
+66
View File
@@ -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
+22
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+414
View File
@@ -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
+13 -1
View File
@@ -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(
+60
View File
@@ -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", "")
+23 -8
View File
@@ -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",
],
}
+39
View File
@@ -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,
+101 -10
View File
@@ -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
+467
View File
@@ -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(
+2 -2
View File
@@ -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
View File
@@ -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(),
)
)
+51
View File
@@ -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
View File
@@ -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(
+2
View File
@@ -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()
+94 -3
View File
@@ -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
+7 -1
View File
@@ -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": [
{
+117
View File
@@ -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"))
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
File diff suppressed because it is too large Load Diff
+175 -17
View File
@@ -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
+124 -1
View File
@@ -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
+823
View File
@@ -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
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]
+367
View File
@@ -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())
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