feat(k1): integrate durable evidence with acquisition lifecycle
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.sessions import (
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web import app as app_module
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
|
||||
def _make_completed_session(root: Path, session_id: str) -> Path:
|
||||
capture = root / session_id / "captures" / "mqtt_live"
|
||||
capture.mkdir(parents=True)
|
||||
topics = (
|
||||
"lixel/application/report/lio_pcl",
|
||||
"lixel/application/report/lio_pose",
|
||||
)
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
for sequence, topic in enumerate(topics, start=1):
|
||||
encoded_topic = topic.encode("utf-8")
|
||||
payload = b"fixture"
|
||||
frame_offset = len(raw)
|
||||
raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
|
||||
raw.extend(encoded_topic)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_epoch_ns": 1_000_000_000 + sequence,
|
||||
"received_monotonic_ns": 2_000_000_000 + sequence,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"raw_frame_offset": frame_offset,
|
||||
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
|
||||
"raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
|
||||
}
|
||||
)
|
||||
(capture / "mqtt.raw.k1mqtt").write_bytes(raw)
|
||||
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||
metadata_path.write_text(
|
||||
"".join(json.dumps(record) + "\n" for record in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(capture / "mqtt.summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"created_at_utc": "2026-07-16T20:56:32.699Z",
|
||||
"completed_at_utc": "2026-07-16T20:57:02.699Z",
|
||||
"capture_elapsed_seconds": 30.0,
|
||||
"stop_reason": "external_stop",
|
||||
"error": None,
|
||||
"message_count": 2,
|
||||
"raw_bytes": len(raw),
|
||||
"topic_counts": {topic: 1 for topic in topics},
|
||||
"artifact_hashes": {
|
||||
"raw_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return capture.parents[1]
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if isinstance(route, APIRoute) and route.path == path and method in route.methods
|
||||
)
|
||||
|
||||
|
||||
def test_list_refresh_discovers_new_completed_session_without_server_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
refresh_calls = 0
|
||||
|
||||
def refresh() -> tuple[str, ...]:
|
||||
nonlocal refresh_calls
|
||||
refresh_calls += 1
|
||||
return store.import_legacy_viewer_live(sessions)
|
||||
|
||||
router = build_session_router(store, catalog_refresher=refresh)
|
||||
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||
|
||||
assert list_route(limit=20, cursor=None) == {"items": []}
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
listing = list_route(limit=20, cursor=None)
|
||||
|
||||
assert refresh_calls == 2
|
||||
assert [item["id"] for item in listing["items"]] == [session.name]
|
||||
|
||||
|
||||
def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
refresh_calls = 0
|
||||
|
||||
def refresh() -> tuple[str, ...]:
|
||||
nonlocal refresh_calls
|
||||
refresh_calls += 1
|
||||
return store.import_legacy_viewer_live(sessions)
|
||||
|
||||
router = build_session_router(store, catalog_refresher=refresh)
|
||||
detail_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}",
|
||||
"GET",
|
||||
)
|
||||
replay_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/replay",
|
||||
"POST",
|
||||
)
|
||||
|
||||
detail = detail_route(session_id=session.name)
|
||||
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||
|
||||
assert detail["session_id"] == session.name
|
||||
assert replay["launch"]["session_id"] == session.name
|
||||
assert refresh_calls == 2
|
||||
|
||||
|
||||
def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
|
||||
def fail_refresh() -> None:
|
||||
raise OSError("private local path must not escape")
|
||||
|
||||
router = build_session_router(store, catalog_refresher=fail_refresh)
|
||||
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
list_route(limit=20, cursor=None)
|
||||
assert error.value.status_code == 503
|
||||
assert "private local path" not in str(error.value.detail)
|
||||
|
||||
|
||||
def test_startup_warmup_enqueues_every_finalized_replayable_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"startup-prepared-recording"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
enqueued = app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
|
||||
assert enqueued == (session.name,)
|
||||
assert snapshot is not None
|
||||
assert snapshot.state == "ready"
|
||||
assert snapshot.recording is not None
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
(stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"prepared-after-stale-row"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
enqueued = app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(good.name)
|
||||
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(good.name)
|
||||
|
||||
assert enqueued == (good.name,)
|
||||
assert manager.status(stale.name) is None
|
||||
assert snapshot is not None and snapshot.state == "ready"
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciler_requeues_only_a_restart_interrupted_job(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = 0
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
started.set()
|
||||
assert release.wait(timeout=2)
|
||||
payload = f"restart-{calls}".encode()
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
app_module.enqueue_replayable_recordings()
|
||||
assert started.wait(timeout=1)
|
||||
manager.close(timeout=0.001)
|
||||
manager.start()
|
||||
release.set()
|
||||
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while (
|
||||
snapshot is None or snapshot.state != "cancelled"
|
||||
) and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
assert snapshot is not None and snapshot.state == "cancelled"
|
||||
interrupted_id = snapshot.preparation_id
|
||||
|
||||
app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = manager.status(session.name)
|
||||
if snapshot is not None and snapshot.state == "ready":
|
||||
break
|
||||
time.sleep(0.005)
|
||||
assert snapshot is not None and snapshot.state == "ready"
|
||||
assert snapshot.preparation_id != interrupted_id
|
||||
assert calls == 2
|
||||
finally:
|
||||
release.set()
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
calls = 0
|
||||
|
||||
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise OSError("genuine export failure")
|
||||
|
||||
manager = SessionRecordingPreparationManager(
|
||||
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||
)
|
||||
monkeypatch.setattr(app_module, "session_store", store)
|
||||
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||
try:
|
||||
app_module.enqueue_replayable_recordings()
|
||||
deadline = time.monotonic() + 2
|
||||
snapshot = manager.status(session.name)
|
||||
while (
|
||||
snapshot is None or snapshot.state != "failed"
|
||||
) and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
snapshot = manager.status(session.name)
|
||||
assert snapshot is not None and snapshot.state == "failed"
|
||||
failed_id = snapshot.preparation_id
|
||||
|
||||
app_module.enqueue_replayable_recordings()
|
||||
time.sleep(0.05)
|
||||
unchanged = manager.status(session.name)
|
||||
assert unchanged is not None
|
||||
assert unchanged.preparation_id == failed_id
|
||||
assert unchanged.state == "failed"
|
||||
assert calls == 1
|
||||
finally:
|
||||
manager.close()
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.web.xgrids_k1_camera as camera_module
|
||||
from k1link.web.xgrids_k1_camera import (
|
||||
CAMERA_MEDIA_TYPE,
|
||||
XgridsK1CameraGateway,
|
||||
@@ -40,6 +43,63 @@ def _fake_ffmpeg(tmp_path: Path) -> Path:
|
||||
return executable
|
||||
|
||||
|
||||
def _burst_ffmpeg(tmp_path: Path) -> Path:
|
||||
executable = tmp_path / "burst-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
"time.sleep(0.2)\n"
|
||||
"for index in range(10):\n"
|
||||
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', bytes([index])))\n"
|
||||
" sys.stdout.buffer.flush()\n"
|
||||
" time.sleep(0.01)\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import pathlib, sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
f"for index in range({fragments}):\n"
|
||||
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', index.to_bytes(2, 'big')))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _incomplete_tail_ffmpeg(tmp_path: Path, sentinel: Path) -> Path:
|
||||
executable = tmp_path / "incomplete-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import pathlib, sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov') + box(b'moof'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||
"time.sleep(10)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _gateway(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -48,6 +108,16 @@ def _gateway(
|
||||
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
|
||||
|
||||
def _wait_until(predicate: object, *, timeout: float = 3.0) -> None:
|
||||
assert callable(predicate)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("condition was not reached before timeout")
|
||||
|
||||
|
||||
def test_camera_selection_is_exclusive_and_hides_device_transport(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -90,9 +160,7 @@ def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
|
||||
)
|
||||
|
||||
assert argv[0] == "/trusted/ffmpeg"
|
||||
assert argv[argv.index("-i") + 1] == (
|
||||
"rtsp://10.0.0.24:8554/live/chn_left_main"
|
||||
)
|
||||
assert argv[argv.index("-i") + 1] == ("rtsp://10.0.0.24:8554/live/chn_left_main")
|
||||
assert argv[argv.index("-c:v") + 1] == "copy"
|
||||
assert argv[argv.index("-allowed_media_types") + 1] == "video"
|
||||
assert argv[argv.index("-flush_packets") + 1] == "1"
|
||||
@@ -145,6 +213,279 @@ def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "sessions" / "camera-acquisition"
|
||||
try:
|
||||
left = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
gateway.start_recording(session)
|
||||
assert session.exists() is False
|
||||
session.mkdir(parents=True)
|
||||
gateway.start_recording(session)
|
||||
left_epoch = session / "media" / "sensor.camera.left" / f"epoch-{left['generation']}"
|
||||
|
||||
# No open_delivery/WebSocket exists: acquisition ownership alone starts
|
||||
# FFmpeg and commits the init segment before any preview consumer.
|
||||
_wait_until(
|
||||
lambda: (
|
||||
(left_epoch / "init.mp4").is_file()
|
||||
and len(list((left_epoch / "segments").glob("*.m4s"))) == 1
|
||||
)
|
||||
)
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
|
||||
right = gateway.select("sensor.camera.right", "192.168.1.20")
|
||||
right_epoch = session / "media" / "sensor.camera.right" / f"epoch-{right['generation']}"
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
_wait_until(lambda: len(list((right_epoch / "segments").glob("*.m4s"))) == 1)
|
||||
|
||||
lease = gateway.open_delivery(right["generation"])
|
||||
init_segment = lease.segments.get(timeout=1)
|
||||
assert init_segment is not None and init_segment[0] == "init"
|
||||
gateway.release_delivery(lease, client_closed=True)
|
||||
|
||||
# Browser disposal is not producer disposal while recording is active.
|
||||
assert lease.process.poll() is None
|
||||
assert gateway.snapshot()["recording"]["active"] is True
|
||||
assert gateway.snapshot()["phase"] == "streaming"
|
||||
|
||||
stopped = gateway.stop_recording(status="complete")
|
||||
assert stopped["recording"]["active"] is False
|
||||
assert stopped["recording"]["completed_epochs"] == 2
|
||||
|
||||
summaries = []
|
||||
for epoch in (left_epoch, right_epoch):
|
||||
init = (epoch / "init.mp4").read_bytes()
|
||||
entries = [
|
||||
json.loads(line)
|
||||
for line in (epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
summaries.append(summary)
|
||||
assert [entry["kind"] for entry in entries] == ["media"]
|
||||
assert all(
|
||||
entry["length"] == len((epoch / entry["path"]).read_bytes())
|
||||
and hashlib.sha256((epoch / entry["path"]).read_bytes()).hexdigest()
|
||||
== entry["sha256"]
|
||||
for entry in entries
|
||||
)
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == 1
|
||||
assert summary["entry_count"] == 1
|
||||
assert summary["valid_bytes"] == len(init) + sum(
|
||||
entry["length"] for entry in entries
|
||||
)
|
||||
|
||||
assert summaries[0]["failure_code"] == "source-switch"
|
||||
assert summaries[1]["failure_code"] is None
|
||||
serialized = json.dumps(stopped)
|
||||
assert "192.168.1.20" not in serialized
|
||||
assert "rtsp://" not in serialized
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
popen_called = False
|
||||
|
||||
class FailingArchive:
|
||||
def __init__(self, *_: object, **__: object) -> None:
|
||||
raise camera_module.CameraArchiveError("synthetic storage failure")
|
||||
|
||||
def forbidden_popen(*_: object, **__: object) -> object:
|
||||
nonlocal popen_called
|
||||
popen_called = True
|
||||
raise AssertionError("FFmpeg must not start without durable storage")
|
||||
|
||||
monkeypatch.setattr(camera_module, "CameraArchiveWriter", FailingArchive)
|
||||
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden_popen)
|
||||
try:
|
||||
gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
with pytest.raises(RuntimeError, match="хранилище"):
|
||||
gateway.start_recording(session)
|
||||
|
||||
state = gateway.snapshot()
|
||||
assert popen_called is False
|
||||
assert state["phase"] == "error"
|
||||
assert state["error"]["code"] == "camera-storage-failed"
|
||||
assert "192.168.1.20" not in json.dumps(state)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_append_failure_fails_producer_and_epoch_loudly(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
original_append = camera_module.CameraArchiveWriter.append
|
||||
|
||||
def failing_append(
|
||||
writer: object,
|
||||
kind: object,
|
||||
payload: bytes,
|
||||
**timestamps: object,
|
||||
) -> dict[str, object]:
|
||||
if kind == "media":
|
||||
raise camera_module.CameraArchiveError("synthetic media commit failure")
|
||||
return original_append(writer, kind, payload, **timestamps) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(camera_module.CameraArchiveWriter, "append", failing_append)
|
||||
gateway = _gateway(tmp_path, monkeypatch)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||
state = gateway.snapshot()
|
||||
assert state["error"]["code"] == "camera-storage-failed"
|
||||
summary_path = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
/ "summary.json"
|
||||
)
|
||||
_wait_until(
|
||||
lambda: (
|
||||
summary_path.is_file()
|
||||
and json.loads(summary_path.read_text(encoding="utf-8"))["status"]
|
||||
== "failed"
|
||||
)
|
||||
)
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "failed"
|
||||
assert summary["failure_code"] == "camera-storage-failed"
|
||||
assert "192.168.1.20" not in json.dumps(state)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_slow_browser_is_dropped_without_stopping_archive_producer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_burst_ffmpeg(tmp_path)))
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
lease = gateway.open_delivery(selected["generation"])
|
||||
|
||||
# Deliberately never drain the bounded queue.
|
||||
_wait_until(lambda: lease.failure_code == "consumer-too-slow")
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||
assert lease.process.poll() is None
|
||||
assert gateway.snapshot()["recording"]["active"] is True
|
||||
|
||||
gateway.release_delivery(lease, client_closed=False)
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
_wait_until(
|
||||
lambda: len(list((epoch / "segments").glob("*.m4s"))) == 10,
|
||||
)
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
summary_path = epoch / "summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == 10
|
||||
assert summary["media_segment_count"] == 10
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_clean_stop_drains_ffmpeg_stdout_before_sealing_archive(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fragment_count = 100
|
||||
sentinel = tmp_path / "ffmpeg-wrote-buffered-tail"
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_buffered_tail_ffmpeg(tmp_path, sentinel, fragments=fragment_count)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
_wait_until(sentinel.is_file)
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["segment_count"] == fragment_count
|
||||
assert len(list((epoch / "segments").glob("*.m4s"))) == fragment_count
|
||||
assert len((epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()) == (
|
||||
fragment_count
|
||||
)
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_clean_stop_marks_incomplete_fragment_tail_interrupted(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sentinel = tmp_path / "ffmpeg-wrote-incomplete-tail"
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_incomplete_tail_ffmpeg(tmp_path, sentinel)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
_wait_until(sentinel.is_file)
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
|
||||
summary_path = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
/ "summary.json"
|
||||
)
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "interrupted"
|
||||
assert summary["failure_code"] == "incomplete-fmp4-fragment"
|
||||
assert summary["segment_count"] == 0
|
||||
state = gateway.snapshot()
|
||||
assert state["phase"] == "error"
|
||||
assert state["error"]["code"] == "incomplete-fmp4-fragment"
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user