1765 lines
64 KiB
Python
1765 lines
64 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import stat
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import APIRouter, HTTPException, Response
|
|
from fastapi.routing import APIRoute
|
|
|
|
import k1link.sessions.media as recorded_media_module
|
|
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
|
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
|
from k1link.sessions import (
|
|
MaterializedRecording,
|
|
RecordedMediaInspector,
|
|
ReplayCommand,
|
|
SessionIntegrityError,
|
|
SessionRecordingMaterializer,
|
|
SessionRecordingPreparationManager,
|
|
SessionStore,
|
|
)
|
|
from k1link.sessions.media import _manifest_generation_sha256
|
|
from k1link.viewer.recorded import recorded_blueprint_rrd
|
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
|
from k1link.web.camera_archive import CameraArchiveWriter
|
|
from k1link.web.session_api import (
|
|
LayoutPutRequest,
|
|
RecordedBlueprintRequest,
|
|
ReplayRequest,
|
|
build_session_router,
|
|
)
|
|
|
|
|
|
def make_legacy_session(sessions_root: Path, session_id: str) -> Path:
|
|
session = sessions_root / session_id
|
|
capture = session / "captures" / "mqtt_live"
|
|
capture.mkdir(parents=True)
|
|
topic_counts = {
|
|
"lixel/application/report/lio_pcl": 1,
|
|
"lixel/application/report/lio_pose": 1,
|
|
}
|
|
raw = bytearray(RAW_MAGIC)
|
|
metadata: list[dict[str, object]] = []
|
|
for sequence, topic in enumerate(topic_counts, start=1):
|
|
topic_bytes = topic.encode()
|
|
payload = b"fixture"
|
|
frame_offset = len(raw)
|
|
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
|
raw.extend(topic_bytes)
|
|
raw.extend(payload)
|
|
metadata.append(
|
|
{
|
|
"record_type": "message",
|
|
"sequence": sequence,
|
|
"received_at_epoch_ns": 1_000_000_000 + sequence - 1,
|
|
"received_monotonic_ns": 2_000_000_000 + sequence - 1,
|
|
"topic": topic,
|
|
"payload_bytes": len(payload),
|
|
"raw_frame_offset": frame_offset,
|
|
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(topic_bytes),
|
|
"raw_frame_bytes": FRAME_HEADER.size + len(topic_bytes) + len(payload),
|
|
}
|
|
)
|
|
raw_path = capture / "mqtt.raw.k1mqtt"
|
|
raw_path.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-16T21:20:43.018Z",
|
|
"capture_elapsed_seconds": 1440.1,
|
|
"stop_reason": "external_stop",
|
|
"error": None,
|
|
"message_count": 2,
|
|
"raw_bytes": len(raw),
|
|
"topic_counts": topic_counts,
|
|
"artifact_hashes": {
|
|
"raw_sha256": hashlib.sha256(raw).hexdigest(),
|
|
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(session / "manifest.redacted.json").write_text("{}", encoding="utf-8")
|
|
return session
|
|
|
|
|
|
def make_recorded_h264_fixture(
|
|
*,
|
|
timescale: int = 1_000,
|
|
sample_duration: int = 500,
|
|
base_decode_time: int = 0,
|
|
) -> tuple[bytes, bytes]:
|
|
def box(box_type: bytes, payload: bytes = b"") -> bytes:
|
|
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
|
|
|
|
def full_box(
|
|
box_type: bytes,
|
|
payload: bytes = b"",
|
|
*,
|
|
flags: int = 0,
|
|
) -> bytes:
|
|
return box(box_type, bytes([0]) + flags.to_bytes(3, "big") + payload)
|
|
|
|
track_id = 1
|
|
tkhd = full_box(
|
|
b"tkhd",
|
|
b"\x00" * 8 + track_id.to_bytes(4, "big") + b"\x00" * 4,
|
|
)
|
|
mdhd = full_box(
|
|
b"mdhd",
|
|
b"\x00" * 8 + timescale.to_bytes(4, "big") + b"\x00" * 4,
|
|
)
|
|
hdlr = full_box(b"hdlr", b"\x00" * 4 + b"vide")
|
|
trak = box(b"trak", tkhd + box(b"mdia", mdhd + hdlr))
|
|
trex = full_box(
|
|
b"trex",
|
|
track_id.to_bytes(4, "big")
|
|
+ (1).to_bytes(4, "big")
|
|
+ sample_duration.to_bytes(4, "big")
|
|
+ b"\x00" * 8,
|
|
)
|
|
avcc = box(b"avcC", b"\x01\x64\x00\x28")
|
|
init = box(b"ftyp", b"isom") + box(b"moov", trak + box(b"mvex", trex) + avcc)
|
|
|
|
tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000)
|
|
tfdt = full_box(b"tfdt", base_decode_time.to_bytes(4, "big"))
|
|
trun = full_box(b"trun", (1).to_bytes(4, "big"))
|
|
fragment = box(b"moof", box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", b"frame")
|
|
return init, fragment
|
|
|
|
|
|
def endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
|
routes = router.routes
|
|
return next(
|
|
route.endpoint
|
|
for route in routes
|
|
if isinstance(route, APIRoute) and route.path == path and method in route.methods
|
|
)
|
|
|
|
|
|
def assert_no_local_paths(value: object, root: Path) -> None:
|
|
serialized = json.dumps(value, ensure_ascii=False, default=str)
|
|
assert str(root) not in serialized
|
|
assert "mqtt.raw.k1mqtt" not in serialized
|
|
|
|
|
|
async def render_file_response(
|
|
response: Response,
|
|
*,
|
|
range_header: bytes | None,
|
|
) -> tuple[dict[str, Any], bytes]:
|
|
sent: list[dict[str, Any]] = []
|
|
|
|
async def receive() -> dict[str, Any]:
|
|
return {"type": "http.request", "body": b"", "more_body": False}
|
|
|
|
async def send(message: dict[str, Any]) -> None:
|
|
sent.append(message)
|
|
|
|
scope: dict[str, Any] = {
|
|
"type": "http",
|
|
"asgi": {"version": "3.0"},
|
|
"http_version": "1.1",
|
|
"method": "GET",
|
|
"scheme": "http",
|
|
"path": "/recording.rrd",
|
|
"raw_path": b"/recording.rrd",
|
|
"query_string": b"",
|
|
"headers": [] if range_header is None else [(b"range", range_header)],
|
|
"client": ("127.0.0.1", 1),
|
|
"server": ("127.0.0.1", 80),
|
|
}
|
|
await response(scope, receive, send)
|
|
start = next(message for message in sent if message["type"] == "http.response.start")
|
|
body = b"".join(
|
|
message.get("body", b"") for message in sent if message["type"] == "http.response.body"
|
|
)
|
|
return start, body
|
|
|
|
|
|
def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Path) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
observed: list[ReplayCommand] = []
|
|
|
|
async def launch(command: ReplayCommand) -> dict[str, Any]:
|
|
observed.append(command)
|
|
return {"source_mode": "replay", "phase": "replay"}
|
|
|
|
router = build_session_router(store, replay_launcher=launch)
|
|
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
|
|
detail_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "GET")
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
|
|
listing = list_route(limit=20, cursor=None)
|
|
detail = detail_route(session_id=session.name)
|
|
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
|
|
|
assert set(listing) == {"items"}
|
|
assert listing["items"][0] == {
|
|
"id": session.name,
|
|
"label": session.name,
|
|
"started_at_utc": "2026-07-16T20:56:32.699Z",
|
|
"completed_at_utc": "2026-07-16T21:20:43.018Z",
|
|
"status": "ready",
|
|
"modalities": ["point-cloud", "trajectory"],
|
|
"duration_seconds": 1440.1,
|
|
"replayable": True,
|
|
}
|
|
assert detail["timeline"]["seekable"] is True
|
|
assert detail["modalities"] == ["point-cloud", "trajectory"]
|
|
assert replay["launch"] == {
|
|
"kind": "plugin-action",
|
|
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
|
|
"action_id": "stream.start-replay",
|
|
"session_id": session.name,
|
|
"speed": 1.0,
|
|
"loop": False,
|
|
"dispatched": True,
|
|
}
|
|
assert replay["runtime_state"]["source_mode"] == "replay"
|
|
assert observed[0].primary_artifact.path.name == "mqtt.raw.k1mqtt"
|
|
for value in (listing, detail, replay):
|
|
assert_no_local_paths(value, repository)
|
|
|
|
|
|
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
payload = b"RRD-seekable-recording"
|
|
export_calls = 0
|
|
|
|
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
|
nonlocal export_calls
|
|
export_calls += 1
|
|
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": 2_500_000_000,
|
|
}
|
|
|
|
materializer = SessionRecordingMaterializer(
|
|
store.data_dir,
|
|
exporter=export_recording,
|
|
)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
allow_synchronous_recording_fallback=True,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
recording_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
|
"GET",
|
|
)
|
|
|
|
replay = asyncio.run(
|
|
replay_route(
|
|
session_id=session.name,
|
|
request=ReplayRequest(speed=2.0, loop=True),
|
|
)
|
|
)
|
|
|
|
assert replay == {
|
|
"schema_version": "missioncore.observation-session-replay/v2",
|
|
"launch": {
|
|
"kind": "rerun-recording",
|
|
"session_id": session.name,
|
|
"source_url": (f"/api/v1/observation-sessions/{session.name}/recording.rrd"),
|
|
"viewer_source_url": (
|
|
f"/api/v1/observation-sessions/{session.name}/recording.rrd"
|
|
f"?generation={hashlib.sha256(payload).hexdigest()}"
|
|
),
|
|
"media_type": "application/vnd.rerun.rrd",
|
|
"timeline": "session_time",
|
|
"timeline_start_seconds": 0.0,
|
|
"timeline_end_seconds": 2.5,
|
|
"seekable": True,
|
|
"byte_length": len(payload),
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"playback": {"speed": 2.0, "loop": True},
|
|
"media_sources": [],
|
|
},
|
|
}
|
|
assert_no_local_paths(replay, repository)
|
|
|
|
file_response = asyncio.run(recording_route(session_id=session.name))
|
|
response_start, response_body = asyncio.run(
|
|
render_file_response(file_response, range_header=b"bytes=4-11")
|
|
)
|
|
|
|
headers = {
|
|
key.decode("latin-1"): value.decode("latin-1") for key, value in response_start["headers"]
|
|
}
|
|
assert response_start["status"] == 206
|
|
assert response_body == payload[4:12]
|
|
assert headers["accept-ranges"] == "bytes"
|
|
assert headers["content-range"] == f"bytes 4-11/{len(payload)}"
|
|
assert headers["content-type"] == "application/vnd.rerun.rrd"
|
|
assert headers["cache-control"] == "private, no-cache, no-transform"
|
|
assert str(repository) not in json.dumps(headers)
|
|
|
|
not_modified = asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
if_none_match=f'W/{headers["etag"]}, "unrelated"',
|
|
)
|
|
)
|
|
assert not_modified.status_code == 304
|
|
assert not_modified.body == b""
|
|
assert not_modified.headers["etag"] == headers["etag"]
|
|
assert not_modified.headers["cache-control"] == "private, no-cache, no-transform"
|
|
assert export_calls == 1
|
|
|
|
|
|
def test_cold_replay_returns_quick_202_then_status_returns_ready_launch(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
calls = 0
|
|
payload = b"background-recording"
|
|
|
|
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
|
nonlocal calls
|
|
calls += 1
|
|
started.set()
|
|
assert release.wait(timeout=2)
|
|
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": 2_500_000_000,
|
|
}
|
|
|
|
materializer = SessionRecordingMaterializer(
|
|
store.data_dir,
|
|
exporter=export_recording,
|
|
)
|
|
manager = SessionRecordingPreparationManager(materializer)
|
|
|
|
def forbidden_cold_cache_resolution(*_args: object, **_kwargs: object) -> None:
|
|
raise AssertionError("HTTP must not perform cold cache validation")
|
|
|
|
monkeypatch.setattr(manager, "resolve_cached", forbidden_cold_cache_resolution)
|
|
monkeypatch.setattr(manager, "resolve_cached_pinned", forbidden_cold_cache_resolution)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
status_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
|
"GET",
|
|
)
|
|
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
|
|
recording_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
|
"GET",
|
|
)
|
|
try:
|
|
with pytest.raises(HTTPException) as missing_generation:
|
|
asyncio.run(recording_route(session_id=session.name, if_none_match=None))
|
|
assert missing_generation.value.status_code == 428
|
|
|
|
cold_file_before = time.monotonic()
|
|
cold_file = asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
if_match='"sha256:previous-generation"',
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
assert time.monotonic() - cold_file_before < 0.1
|
|
assert cold_file.status_code == 202
|
|
cold_preparation_id = json.loads(cold_file.body)["preparation"]["preparation_id"]
|
|
|
|
before = time.monotonic()
|
|
pending = asyncio.run(
|
|
replay_route(
|
|
session_id=session.name,
|
|
request=ReplayRequest(speed=2.0, loop=True),
|
|
)
|
|
)
|
|
|
|
assert time.monotonic() - before < 0.1
|
|
assert pending.status_code == 202
|
|
document = json.loads(pending.body)
|
|
preparation = document["preparation"]
|
|
assert document["schema_version"] == "missioncore.observation-session-preparation/v1"
|
|
assert preparation["preparation_id"] == cold_preparation_id
|
|
assert preparation["state"] in {"queued", "validating", "exporting"}
|
|
assert preparation["status_url"].endswith("/recording-preparation")
|
|
assert pending.headers["location"] == preparation["status_url"]
|
|
assert pending.headers["retry-after"] == "1"
|
|
preparation_etag = pending.headers["etag"]
|
|
assert preparation_etag == f'"{preparation["preparation_id"]}"'
|
|
assert started.wait(timeout=1)
|
|
catalog_preparation = list_route(limit=20, cursor=None)["items"][0]["preparation"]
|
|
assert catalog_preparation["preparation_id"] == preparation["preparation_id"]
|
|
assert catalog_preparation["state"] in {"validating", "exporting"}
|
|
|
|
duplicate = asyncio.run(
|
|
replay_route(
|
|
session_id=session.name,
|
|
request=ReplayRequest(speed=2.0, loop=True),
|
|
)
|
|
)
|
|
assert (
|
|
json.loads(duplicate.body)["preparation"]["preparation_id"]
|
|
== preparation["preparation_id"]
|
|
)
|
|
release.set()
|
|
deadline = time.monotonic() + 2
|
|
ready: dict[str, Any] | None = None
|
|
while time.monotonic() < deadline:
|
|
candidate = asyncio.run(
|
|
status_route(
|
|
session_id=session.name,
|
|
if_match=preparation_etag,
|
|
)
|
|
)
|
|
if candidate.status_code == 200:
|
|
assert candidate.headers["etag"] == preparation_etag
|
|
ready = json.loads(candidate.body)
|
|
break
|
|
time.sleep(0.005)
|
|
|
|
assert ready is not None
|
|
assert ready["launch"]["source_url"].endswith("/recording.rrd")
|
|
assert ready["launch"]["viewer_source_url"] == (
|
|
f"{ready['launch']['source_url']}?generation={ready['launch']['sha256']}"
|
|
)
|
|
assert ready["launch"]["playback"] == {"speed": 1.0, "loop": False}
|
|
recording_etag = f'"sha256:{ready["launch"]["sha256"]}"'
|
|
with pytest.raises(HTTPException) as stale_generation:
|
|
asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
if_match='"sha256:stale"',
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
assert stale_generation.value.status_code == 412
|
|
with pytest.raises(HTTPException) as stale_query_generation:
|
|
asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
generation="0" * 64,
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
assert stale_query_generation.value.status_code == 412
|
|
with pytest.raises(HTTPException) as malformed_query_generation:
|
|
asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
generation="NOT-A-CANONICAL-DIGEST",
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
assert malformed_query_generation.value.status_code == 412
|
|
|
|
recording_response = asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
if_match=recording_etag,
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
recording_start, recording_body = asyncio.run(
|
|
render_file_response(recording_response, range_header=None)
|
|
)
|
|
recording_headers = {
|
|
key.decode("latin-1"): value.decode("latin-1")
|
|
for key, value in recording_start["headers"]
|
|
}
|
|
assert recording_start["status"] == 200
|
|
assert recording_body == payload
|
|
assert recording_headers["etag"] == recording_etag
|
|
assert recording_headers["content-length"] == str(len(payload))
|
|
assert recording_headers["content-type"] == "application/vnd.rerun.rrd"
|
|
assert recording_headers["cache-control"] == "private, no-cache, no-transform"
|
|
|
|
viewer_response = asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
generation=ready["launch"]["sha256"],
|
|
if_none_match=None,
|
|
)
|
|
)
|
|
viewer_start, viewer_body = asyncio.run(
|
|
render_file_response(viewer_response, range_header=None)
|
|
)
|
|
viewer_headers = {
|
|
key.decode("latin-1"): value.decode("latin-1") for key, value in viewer_start["headers"]
|
|
}
|
|
assert viewer_start["status"] == 200
|
|
assert viewer_body == payload
|
|
assert viewer_headers["etag"] == recording_etag
|
|
assert viewer_headers["content-length"] == str(len(payload))
|
|
assert viewer_headers["content-type"] == "application/vnd.rerun.rrd"
|
|
assert viewer_headers["cache-control"] == (
|
|
"private, max-age=31536000, immutable, no-transform"
|
|
)
|
|
|
|
viewer_not_modified = asyncio.run(
|
|
recording_route(
|
|
session_id=session.name,
|
|
generation=ready["launch"]["sha256"],
|
|
if_none_match=recording_etag,
|
|
)
|
|
)
|
|
assert viewer_not_modified.status_code == 304
|
|
assert viewer_not_modified.headers["etag"] == recording_etag
|
|
assert viewer_not_modified.headers["cache-control"] == (
|
|
"private, max-age=31536000, immutable, no-transform"
|
|
)
|
|
assert calls == 1
|
|
finally:
|
|
release.set()
|
|
manager.close()
|
|
|
|
|
|
def test_catalog_read_never_prepares_a_cold_historical_session(tmp_path: Path) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
calls = 0
|
|
|
|
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
|
nonlocal calls
|
|
calls += 1
|
|
raise AssertionError("catalog reads must not invoke the exporter")
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
manager = SessionRecordingPreparationManager(materializer)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
)
|
|
list_route = endpoint(router, "/api/v1/observation-sessions", "GET")
|
|
try:
|
|
item = list_route(limit=20, cursor=None)["items"][0]
|
|
time.sleep(0.02)
|
|
|
|
assert item["id"] == session.name
|
|
assert item["preparation"] is None
|
|
assert manager.status(session.name) is None
|
|
assert calls == 0
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_recording_response_releases_pin_once_when_asgi_send_fails(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
recording_path = tmp_path / "pinned.rrd"
|
|
payload = b"RRD-pinned-response"
|
|
recording_path.write_bytes(payload)
|
|
recording = MaterializedRecording(
|
|
session_id=session.name,
|
|
path=recording_path,
|
|
media_type="application/vnd.rerun.rrd",
|
|
byte_length=len(payload),
|
|
sha256=hashlib.sha256(payload).hexdigest(),
|
|
source_sha256="source-digest",
|
|
timeline="session_time",
|
|
timeline_start_ns=0,
|
|
timeline_end_ns=1_000_000_000,
|
|
)
|
|
|
|
class PinnedMaterializer:
|
|
def __init__(self) -> None:
|
|
self.release_calls = 0
|
|
|
|
def __call__(self, _command: ReplayCommand) -> MaterializedRecording:
|
|
return recording
|
|
|
|
def materialize_pinned(
|
|
self,
|
|
_command: ReplayCommand,
|
|
) -> tuple[MaterializedRecording, Callable[[], None]]:
|
|
return recording, self.release
|
|
|
|
def release(self) -> None:
|
|
self.release_calls += 1
|
|
|
|
materializer = PinnedMaterializer()
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
allow_synchronous_recording_fallback=True,
|
|
)
|
|
recording_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
|
"GET",
|
|
)
|
|
response = asyncio.run(recording_route(session_id=session.name))
|
|
|
|
async def send_failure() -> None:
|
|
async def receive() -> dict[str, Any]:
|
|
return {"type": "http.request", "body": b"", "more_body": False}
|
|
|
|
async def send(message: dict[str, Any]) -> None:
|
|
if message["type"] == "http.response.body":
|
|
raise RuntimeError("synthetic ASGI send failure")
|
|
|
|
scope: dict[str, Any] = {
|
|
"type": "http",
|
|
"asgi": {"version": "3.0"},
|
|
"http_version": "1.1",
|
|
"method": "GET",
|
|
"scheme": "http",
|
|
"path": "/recording.rrd",
|
|
"raw_path": b"/recording.rrd",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"client": ("127.0.0.1", 1),
|
|
"server": ("127.0.0.1", 80),
|
|
}
|
|
await response(scope, receive, send)
|
|
|
|
with pytest.raises(RuntimeError, match="synthetic ASGI send failure"):
|
|
asyncio.run(send_failure())
|
|
assert materializer.release_calls == 1
|
|
|
|
# A response must never double-release the same cache lease, even if an
|
|
# ASGI host accidentally invokes the response object again after failure.
|
|
with pytest.raises(RuntimeError, match="synthetic ASGI send failure"):
|
|
asyncio.run(send_failure())
|
|
assert materializer.release_calls == 1
|
|
|
|
|
|
def test_preparation_status_and_cancel_are_bound_to_exact_job_etag(tmp_path: Path) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
|
started.set()
|
|
assert release.wait(timeout=2)
|
|
payload = b"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,
|
|
}
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
manager = SessionRecordingPreparationManager(materializer)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
status_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
|
"GET",
|
|
)
|
|
cancel_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
|
"DELETE",
|
|
)
|
|
try:
|
|
pending = asyncio.run(replay_route(session_id=session.name, request=None))
|
|
assert pending.status_code == 202
|
|
assert started.wait(timeout=1)
|
|
etag = pending.headers["etag"]
|
|
|
|
with pytest.raises(HTTPException) as stale_status:
|
|
asyncio.run(status_route(session_id=session.name, if_match='"stale-job"'))
|
|
assert stale_status.value.status_code == 412
|
|
|
|
with pytest.raises(HTTPException) as stale_cancel:
|
|
cancel_route(session_id=session.name, if_match='"stale-job"')
|
|
assert stale_cancel.value.status_code == 412
|
|
current = manager.status(session.name)
|
|
assert current is not None
|
|
assert f'"{current.preparation_id}"' == etag
|
|
assert current.state in {"validating", "exporting"}
|
|
|
|
assert cancel_route(session_id=session.name, if_match=etag).status_code == 204
|
|
release.set()
|
|
deadline = time.monotonic() + 2
|
|
while time.monotonic() < deadline:
|
|
current = manager.status(session.name)
|
|
if current is not None and current.state == "cancelled":
|
|
break
|
|
time.sleep(0.005)
|
|
assert current is not None and current.state == "cancelled"
|
|
finally:
|
|
release.set()
|
|
manager.close()
|
|
|
|
|
|
def test_stale_status_poll_does_not_create_a_replacement_job(tmp_path: Path) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
calls = 0
|
|
|
|
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
|
nonlocal calls
|
|
calls += 1
|
|
raise AssertionError("a stale status poll must not enqueue work")
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
manager = SessionRecordingPreparationManager(materializer)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
)
|
|
status_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
|
"GET",
|
|
)
|
|
try:
|
|
with pytest.raises(HTTPException) as stale:
|
|
asyncio.run(
|
|
status_route(
|
|
session_id=session.name,
|
|
if_match='"no-longer-current"',
|
|
)
|
|
)
|
|
assert stale.value.status_code == 412
|
|
assert manager.status(session.name) is None
|
|
assert calls == 0
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_ready_post_uses_each_callers_playback_without_mutating_shared_job(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
calls = 0
|
|
|
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
|
nonlocal calls
|
|
calls += 1
|
|
payload = b"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,
|
|
}
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
manager = SessionRecordingPreparationManager(materializer)
|
|
command = store.prepare_replay(session.name)
|
|
manager.enqueue(command)
|
|
deadline = time.monotonic() + 2
|
|
while time.monotonic() < deadline:
|
|
ready = manager.status(session.name)
|
|
if ready is not None and ready.state == "ready":
|
|
break
|
|
time.sleep(0.005)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
try:
|
|
first = asyncio.run(
|
|
replay_route(
|
|
session_id=session.name,
|
|
request=ReplayRequest(speed=2.0, loop=True),
|
|
)
|
|
)
|
|
second = asyncio.run(
|
|
replay_route(
|
|
session_id=session.name,
|
|
request=ReplayRequest(speed=7.0, loop=False),
|
|
)
|
|
)
|
|
|
|
assert first["launch"]["playback"] == {"speed": 2.0, "loop": True}
|
|
assert second["launch"]["playback"] == {"speed": 7.0, "loop": False}
|
|
shared = manager.status(session.name)
|
|
assert shared is not None
|
|
assert shared.command.speed == 1.0
|
|
assert shared.command.loop is False
|
|
assert calls == 1
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_production_router_never_materializes_recording_inline(tmp_path: Path) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
calls = 0
|
|
|
|
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
|
nonlocal calls
|
|
calls += 1
|
|
raise AssertionError("public route must not export inline")
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
router = build_session_router(store, recording_materializer=materializer)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
recording_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
|
"GET",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as replay_error:
|
|
asyncio.run(replay_route(session_id=session.name, request=None))
|
|
with pytest.raises(HTTPException) as recording_error:
|
|
asyncio.run(recording_route(session_id=session.name, if_none_match=None))
|
|
|
|
assert replay_error.value.status_code == 503
|
|
assert recording_error.value.status_code == 503
|
|
assert calls == 0
|
|
|
|
|
|
def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
observed_settings: list[RerunSceneSettings] = []
|
|
|
|
def capture_settings(settings: RerunSceneSettings, **kwargs: Any) -> bytes:
|
|
observed_settings.append(settings)
|
|
return recorded_blueprint_rrd(settings, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
"k1link.web.session_api.recorded_blueprint_rrd",
|
|
capture_settings,
|
|
)
|
|
router = build_session_router(store)
|
|
blueprint_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/blueprint.rrd",
|
|
"POST",
|
|
)
|
|
|
|
response = asyncio.run(
|
|
blueprint_route(
|
|
session_id=session.name,
|
|
request=RecordedBlueprintRequest(
|
|
application_id="nodedc_mission_core_recorded",
|
|
recording_id="recording-001",
|
|
accumulation_seconds=18.5,
|
|
show_points=False,
|
|
show_trajectory=True,
|
|
show_grid=False,
|
|
point_size=6.25,
|
|
palette="custom",
|
|
custom_color="#112233",
|
|
),
|
|
)
|
|
)
|
|
|
|
assert response.media_type == "application/vnd.rerun.rrd"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
assert response.body.startswith(b"RRF2")
|
|
assert len(response.body) < 100_000
|
|
assert_no_local_paths(response.headers, repository)
|
|
assert len(observed_settings) == 1
|
|
assert observed_settings[0].show_points is False
|
|
assert observed_settings[0].show_trajectory is True
|
|
|
|
with pytest.raises(ValueError):
|
|
RecordedBlueprintRequest.model_validate(
|
|
{
|
|
"application_id": "nodedc_mission_core_recorded",
|
|
"recording_id": "recording-001",
|
|
"accumulation_seconds": 12,
|
|
"show_grid": True,
|
|
"source_url": "https://outside.invalid/recording.rrd",
|
|
}
|
|
)
|
|
with pytest.raises(HTTPException) as missing:
|
|
asyncio.run(
|
|
blueprint_route(
|
|
session_id="missing-session",
|
|
request=RecordedBlueprintRequest(
|
|
application_id="nodedc_mission_core_recorded",
|
|
recording_id="recording-001",
|
|
accumulation_seconds=12,
|
|
show_points=True,
|
|
show_trajectory=True,
|
|
show_grid=True,
|
|
),
|
|
)
|
|
)
|
|
assert missing.value.status_code == 404
|
|
|
|
|
|
def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 9)
|
|
init, segment = make_recorded_h264_fixture()
|
|
writer.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_250_000_000,
|
|
host_monotonic_ns=2_250_000_000,
|
|
)
|
|
writer.close()
|
|
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
payload = b"recording"
|
|
|
|
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
|
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": 5_000_000_000,
|
|
}
|
|
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=SessionRecordingMaterializer(
|
|
store.data_dir,
|
|
exporter=export_recording,
|
|
),
|
|
allow_synchronous_recording_fallback=True,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
manifest_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest",
|
|
"GET",
|
|
)
|
|
init_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
|
"epochs/{epoch_ordinal}/init.mp4",
|
|
"GET",
|
|
)
|
|
segment_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
|
"epochs/{epoch_ordinal}/segments/{segment_sequence}.m4s",
|
|
"GET",
|
|
)
|
|
|
|
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
|
source = replay["launch"]["media_sources"][0]
|
|
assert source == {
|
|
"id": source["id"],
|
|
"label": "Записанная камера 1",
|
|
"modality": "video",
|
|
"manifest_url": source["manifest_url"],
|
|
"manifest_generation_sha256": source["manifest_generation_sha256"],
|
|
"byte_length": len(init) + len(segment),
|
|
"media_type": "video/mp4",
|
|
"timeline_start_seconds": 0.0,
|
|
"timeline_end_seconds": 0.5,
|
|
"seekable": True,
|
|
"synchronization": "host-arrival-best-effort",
|
|
}
|
|
assert source["id"].startswith("recorded.camera.")
|
|
assert "sensor.camera.private-left" not in json.dumps(replay)
|
|
artifact_id = source["manifest_url"].split("/")[-2]
|
|
|
|
with pytest.raises(HTTPException) as missing_manifest_generation:
|
|
manifest_route(session_id=session.name, artifact_id=artifact_id)
|
|
assert missing_manifest_generation.value.status_code == 428
|
|
with pytest.raises(HTTPException) as stale_manifest_generation:
|
|
manifest_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
if_match='"sha256:stale"',
|
|
)
|
|
assert stale_manifest_generation.value.status_code == 412
|
|
|
|
manifest_response = manifest_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
if_match=f'"sha256:{source["manifest_generation_sha256"]}"',
|
|
)
|
|
manifest = json.loads(manifest_response.body)
|
|
assert manifest["schema_version"] == "missioncore.observation-recorded-media/v2"
|
|
assert manifest["source_id"] == source["id"]
|
|
assert manifest["generation_sha256"] == source["manifest_generation_sha256"]
|
|
assert manifest["byte_length"] == source["byte_length"]
|
|
assert manifest["timeline_start_seconds"] == source["timeline_start_seconds"]
|
|
assert manifest["timeline_end_seconds"] == source["timeline_end_seconds"]
|
|
assert manifest_response.headers["etag"] == (f'"sha256:{manifest["generation_sha256"]}"')
|
|
init_sha256 = hashlib.sha256(init).hexdigest()
|
|
segment_sha256 = hashlib.sha256(segment).hexdigest()
|
|
assert manifest["epochs"] == [
|
|
{
|
|
"ordinal": 1,
|
|
"timeline_start_seconds": 0.0,
|
|
"timeline_end_seconds": 0.5,
|
|
"media_type": 'video/mp4; codecs="avc1.640028"',
|
|
"init_url": manifest["epochs"][0]["init_url"],
|
|
"init_byte_length": len(init),
|
|
"init_sha256": init_sha256,
|
|
"segment_count": 1,
|
|
"segment_url_prefix": manifest["epochs"][0]["segment_url_prefix"],
|
|
"segments": [
|
|
{
|
|
"sequence": 1,
|
|
"url": manifest["epochs"][0]["segments"][0]["url"],
|
|
"byte_length": len(segment),
|
|
"sha256": segment_sha256,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
assert "sensor.camera.private-left" not in json.dumps(manifest)
|
|
assert_no_local_paths(manifest, repository)
|
|
|
|
with pytest.raises(HTTPException) as missing_init_generation:
|
|
init_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
epoch_ordinal=1,
|
|
range_header="bytes=4-7",
|
|
)
|
|
assert missing_init_generation.value.status_code == 428
|
|
|
|
init_response = init_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
epoch_ordinal=1,
|
|
if_match=f'"sha256:{init_sha256}"',
|
|
range_header="bytes=4-7",
|
|
)
|
|
init_start, init_body = asyncio.run(
|
|
render_file_response(init_response, range_header=b"bytes=4-7")
|
|
)
|
|
assert init_start["status"] == 206
|
|
assert init_body == init[4:8]
|
|
|
|
with pytest.raises(HTTPException) as stale_segment_generation:
|
|
segment_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
epoch_ordinal=1,
|
|
segment_sequence=1,
|
|
if_match='"sha256:stale"',
|
|
range_header=None,
|
|
)
|
|
assert stale_segment_generation.value.status_code == 412
|
|
|
|
segment_response = segment_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
epoch_ordinal=1,
|
|
segment_sequence=1,
|
|
if_match=f'"sha256:{segment_sha256}"',
|
|
range_header="bytes=0-5",
|
|
)
|
|
segment_start, segment_body = asyncio.run(
|
|
render_file_response(segment_response, range_header=b"bytes=0-5")
|
|
)
|
|
headers = {
|
|
key.decode("latin-1"): value.decode("latin-1") for key, value in segment_start["headers"]
|
|
}
|
|
assert segment_start["status"] == 206
|
|
assert segment_body == segment[:6]
|
|
assert headers["cache-control"] == ("private, max-age=31536000, immutable, no-transform")
|
|
assert headers["etag"] == f'"sha256:{segment_sha256}"'
|
|
assert headers["content-length"] == "6"
|
|
assert headers["x-content-type-options"] == "nosniff"
|
|
|
|
original_segment = writer.segments_dir / "1.m4s"
|
|
outside = tmp_path / "outside.m4s"
|
|
outside.write_bytes(segment)
|
|
original_segment.unlink()
|
|
original_segment.symlink_to(outside)
|
|
with pytest.raises(HTTPException) as confined:
|
|
segment_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
epoch_ordinal=1,
|
|
segment_sequence=1,
|
|
if_match=f'"sha256:{segment_sha256}"',
|
|
range_header=None,
|
|
)
|
|
assert confined.value.status_code == 409
|
|
|
|
|
|
def test_ready_launch_uses_background_prepared_camera_manifest_without_rescan(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
init, segment = make_recorded_h264_fixture()
|
|
writer.append(
|
|
"init",
|
|
init,
|
|
host_epoch_ns=1_100_000_000,
|
|
host_monotonic_ns=2_100_000_000,
|
|
)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_250_000_000,
|
|
host_monotonic_ns=2_250_000_000,
|
|
)
|
|
writer.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
|
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
|
payload = b"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": 5_000_000_000,
|
|
}
|
|
|
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
|
inspector = RecordedMediaInspector()
|
|
|
|
def prepare_media(
|
|
command: ReplayCommand,
|
|
_recording: MaterializedRecording,
|
|
) -> tuple[Any, ...]:
|
|
return tuple(
|
|
inspector.inspect(artifact, command)
|
|
for artifact in store.list_recorded_media(command.session_id)
|
|
)
|
|
|
|
manager = SessionRecordingPreparationManager(
|
|
materializer,
|
|
ready_preparer=prepare_media,
|
|
)
|
|
try:
|
|
manager.enqueue(store.prepare_replay(session.name))
|
|
deadline = time.monotonic() + 2
|
|
snapshot = manager.status(session.name)
|
|
while (snapshot is None or snapshot.state != "ready") and time.monotonic() < deadline:
|
|
time.sleep(0.005)
|
|
snapshot = manager.status(session.name)
|
|
assert snapshot is not None and snapshot.state == "ready"
|
|
assert snapshot.recorded_media is not None and len(snapshot.recorded_media) == 1
|
|
|
|
def forbidden_rescan(*_args: object, **_kwargs: object) -> None:
|
|
raise AssertionError("ready HTTP path must not rescan RRD or camera indexes")
|
|
|
|
monkeypatch.setattr(inspector, "inspect", forbidden_rescan)
|
|
monkeypatch.setattr(manager, "resolve_cached", forbidden_rescan)
|
|
monkeypatch.setattr(manager, "resolve_cached_pinned", forbidden_rescan)
|
|
router = build_session_router(
|
|
store,
|
|
recording_materializer=materializer,
|
|
recording_preparation_manager=manager,
|
|
media_inspector=inspector,
|
|
)
|
|
replay_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/replay",
|
|
"POST",
|
|
)
|
|
manifest_route = endpoint(
|
|
router,
|
|
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest",
|
|
"GET",
|
|
)
|
|
|
|
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
|
source = replay["launch"]["media_sources"][0]
|
|
artifact_id = source["manifest_url"].split("/")[-2]
|
|
manifest_response = manifest_route(
|
|
session_id=session.name,
|
|
artifact_id=artifact_id,
|
|
if_match=f'"sha256:{source["manifest_generation_sha256"]}"',
|
|
)
|
|
manifest = json.loads(manifest_response.body)
|
|
assert manifest["generation_sha256"] == source["manifest_generation_sha256"]
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_padded(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
init, segment = make_recorded_h264_fixture(sample_duration=250)
|
|
_, following_segment = make_recorded_h264_fixture(
|
|
sample_duration=250,
|
|
base_decode_time=250,
|
|
)
|
|
first = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
first.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000)
|
|
first.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_250_000_000,
|
|
host_monotonic_ns=2_250_000_000,
|
|
)
|
|
first.close()
|
|
second = CameraArchiveWriter(session, "sensor.camera.private-left", 2)
|
|
second.append("init", init, host_epoch_ns=1_800_000_000, host_monotonic_ns=2_800_000_000)
|
|
second.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_800_000_000,
|
|
host_monotonic_ns=2_800_000_000,
|
|
)
|
|
second.append(
|
|
"media",
|
|
following_segment,
|
|
host_epoch_ns=2_000_000_000,
|
|
host_monotonic_ns=3_000_000_000,
|
|
)
|
|
second.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
command = store.prepare_replay(session.name)
|
|
artifact = store.list_recorded_media(session.name)[0]
|
|
manifest = RecordedMediaInspector().inspect(artifact, command)
|
|
|
|
assert [epoch.timeline_start_seconds for epoch in manifest.epochs] == [0.0, 0.55]
|
|
assert [epoch.timeline_end_seconds for epoch in manifest.epochs] == [0.25, 1.05]
|
|
assert manifest.timeline_end_seconds == 1.05
|
|
assert manifest.byte_length == 2 * len(init) + 3 * len(segment)
|
|
|
|
original_generation = manifest.generation_sha256
|
|
changed_epochs = (
|
|
*manifest.epochs[:-1],
|
|
replace(
|
|
manifest.epochs[-1],
|
|
timeline_end_seconds=manifest.epochs[-1].timeline_end_seconds + 0.1,
|
|
),
|
|
)
|
|
changed_generation = _manifest_generation_sha256(
|
|
public_source_id=manifest.public_source_id,
|
|
artifact_id=manifest.artifact_id,
|
|
synchronization=manifest.synchronization,
|
|
epochs=changed_epochs,
|
|
)
|
|
assert changed_generation != original_generation
|
|
|
|
|
|
def test_recorded_media_rejects_non_monotonic_segments_and_overlapping_epochs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
init, segment = make_recorded_h264_fixture(sample_duration=500)
|
|
|
|
non_monotonic_root = tmp_path / "non-monotonic"
|
|
non_monotonic_session = make_legacy_session(
|
|
non_monotonic_root / "sessions",
|
|
"20260716T205632Z_viewer_live",
|
|
)
|
|
writer = CameraArchiveWriter(non_monotonic_session, "sensor.camera.private-left", 1)
|
|
writer.append("init", init)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_600_000_000,
|
|
host_monotonic_ns=2_600_000_000,
|
|
)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_500_000_000,
|
|
host_monotonic_ns=2_500_000_000,
|
|
)
|
|
writer.close()
|
|
store = SessionStore(non_monotonic_root, data_dir=tmp_path / "non-monotonic-data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(non_monotonic_root / "sessions"))
|
|
with pytest.raises(SessionIntegrityError, match="strictly monotonic"):
|
|
RecordedMediaInspector().inspect(
|
|
store.list_recorded_media(non_monotonic_session.name)[0],
|
|
store.prepare_replay(non_monotonic_session.name),
|
|
)
|
|
|
|
overlap_root = tmp_path / "overlap"
|
|
overlap_session = make_legacy_session(
|
|
overlap_root / "sessions",
|
|
"20260716T205633Z_viewer_live",
|
|
)
|
|
first = CameraArchiveWriter(overlap_session, "sensor.camera.private-left", 1)
|
|
first.append("init", init)
|
|
first.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_250_000_000,
|
|
host_monotonic_ns=2_250_000_000,
|
|
)
|
|
first.close()
|
|
second = CameraArchiveWriter(overlap_session, "sensor.camera.private-left", 2)
|
|
second.append("init", init)
|
|
second.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_500_000_000,
|
|
host_monotonic_ns=2_500_000_000,
|
|
)
|
|
second.close()
|
|
store = SessionStore(overlap_root, data_dir=tmp_path / "overlap-data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(overlap_root / "sessions"))
|
|
with pytest.raises(SessionIntegrityError, match="overlap"):
|
|
RecordedMediaInspector().inspect(
|
|
store.list_recorded_media(overlap_session.name)[0],
|
|
store.prepare_replay(overlap_session.name),
|
|
)
|
|
|
|
|
|
def test_recorded_media_rejects_discontinuous_tfdt_decode_timeline(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
init, first_segment = make_recorded_h264_fixture(sample_duration=500)
|
|
_, discontinuous_segment = make_recorded_h264_fixture(
|
|
sample_duration=500,
|
|
base_decode_time=1_000,
|
|
)
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
writer.append("init", init)
|
|
writer.append(
|
|
"media",
|
|
first_segment,
|
|
host_epoch_ns=1_250_000_000,
|
|
host_monotonic_ns=2_250_000_000,
|
|
)
|
|
writer.append(
|
|
"media",
|
|
discontinuous_segment,
|
|
host_epoch_ns=1_750_000_000,
|
|
host_monotonic_ns=2_750_000_000,
|
|
)
|
|
writer.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
|
|
with pytest.raises(SessionIntegrityError, match="decode timeline is discontinuous"):
|
|
RecordedMediaInspector().inspect(
|
|
store.list_recorded_media(session.name)[0],
|
|
store.prepare_replay(session.name),
|
|
)
|
|
|
|
|
|
def test_background_preparation_fails_closed_on_unparseable_recorded_media(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
writer.append("init", b"not-an-iso-bmff-init")
|
|
writer.append("media", b"not-an-iso-bmff-fragment")
|
|
writer.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
|
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
|
return {
|
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
|
"rrd_sha256": hashlib.sha256(b"recording").hexdigest(),
|
|
"rrd_bytes": destination.write_bytes(b"recording"),
|
|
"timeline": "session_time",
|
|
"timeline_start_ns": 0,
|
|
"timeline_end_ns": 5_000_000_000,
|
|
}
|
|
|
|
inspector = RecordedMediaInspector()
|
|
|
|
def prepare_media(
|
|
command: ReplayCommand,
|
|
_recording: MaterializedRecording,
|
|
) -> tuple[Any, ...]:
|
|
return tuple(
|
|
inspector.inspect(artifact, command)
|
|
for artifact in store.list_recorded_media(command.session_id)
|
|
)
|
|
|
|
manager = SessionRecordingPreparationManager(
|
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter),
|
|
ready_preparer=prepare_media,
|
|
)
|
|
try:
|
|
manager.enqueue(store.prepare_replay(session.name))
|
|
deadline = time.monotonic() + 2
|
|
snapshot = manager.status(session.name)
|
|
while snapshot is not None and snapshot.state not in {"failed", "ready"}:
|
|
assert time.monotonic() < deadline
|
|
time.sleep(0.005)
|
|
snapshot = manager.status(session.name)
|
|
assert snapshot is not None
|
|
assert snapshot.state == "failed"
|
|
assert snapshot.recording is None
|
|
assert snapshot.recorded_media is None
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_background_preparation_rejects_camera_outside_rrd_timeline(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
init, segment = make_recorded_h264_fixture(sample_duration=500)
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
writer.append("init", init)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_500_000_000,
|
|
host_monotonic_ns=2_500_000_000,
|
|
)
|
|
writer.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
|
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
|
payload = b"short-spatial-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": 400_000_000,
|
|
}
|
|
|
|
inspector = RecordedMediaInspector(tmp_path / "prepared-media")
|
|
|
|
def prepare_media(
|
|
command: ReplayCommand,
|
|
_recording: MaterializedRecording,
|
|
) -> tuple[Any, ...]:
|
|
return tuple(
|
|
inspector.inspect(artifact, command)
|
|
for artifact in store.list_recorded_media(command.session_id)
|
|
)
|
|
|
|
manager = SessionRecordingPreparationManager(
|
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter),
|
|
ready_preparer=prepare_media,
|
|
)
|
|
try:
|
|
manager.enqueue(store.prepare_replay(session.name))
|
|
deadline = time.monotonic() + 2
|
|
snapshot = manager.status(session.name)
|
|
while snapshot is not None and snapshot.state not in {"failed", "ready"}:
|
|
assert time.monotonic() < deadline
|
|
time.sleep(0.005)
|
|
snapshot = manager.status(session.name)
|
|
assert snapshot is not None
|
|
assert snapshot.state == "failed"
|
|
assert snapshot.recording is None
|
|
assert snapshot.recorded_media is None
|
|
finally:
|
|
manager.close()
|
|
|
|
|
|
def test_recorded_media_preparation_sidecar_reuses_and_rebuilds_generation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repository = tmp_path / "repo"
|
|
sessions = repository / "sessions"
|
|
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
|
init, segment = make_recorded_h264_fixture()
|
|
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
|
|
writer.append("init", init)
|
|
writer.append(
|
|
"media",
|
|
segment,
|
|
host_epoch_ns=1_500_000_000,
|
|
host_monotonic_ns=2_500_000_000,
|
|
)
|
|
writer.close()
|
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
|
command = store.prepare_replay(session.name)
|
|
artifact = store.list_recorded_media(session.name)[0]
|
|
cache_root = tmp_path / "prepared-media"
|
|
initial = RecordedMediaInspector(cache_root).inspect(artifact, command)
|
|
sidecars = list(cache_root.glob("*.json"))
|
|
assert len(sidecars) == 1
|
|
sidecar = sidecars[0]
|
|
assert stat.S_IMODE(sidecar.stat().st_mode) == 0o600
|
|
assert str(session) not in sidecar.read_text(encoding="utf-8")
|
|
|
|
def forbidden_reparse(*_args: object, **_kwargs: object) -> None:
|
|
raise AssertionError("restart must reuse the prepared media sidecar")
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(recorded_media_module, "_read_manifest", forbidden_reparse)
|
|
context.setattr(recorded_media_module, "_mp4_video_timing", forbidden_reparse)
|
|
restarted = RecordedMediaInspector(cache_root).inspect(artifact, command)
|
|
assert restarted.generation_sha256 == initial.generation_sha256
|
|
assert restarted.timeline_end_seconds == initial.timeline_end_seconds
|
|
|
|
original_read_manifest = recorded_media_module._read_manifest
|
|
reparses = 0
|
|
|
|
def counted_reparse(*args: Any, **kwargs: Any) -> Any:
|
|
nonlocal reparses
|
|
reparses += 1
|
|
return original_read_manifest(*args, **kwargs)
|
|
|
|
sidecar.write_bytes(b"{corrupt")
|
|
with monkeypatch.context() as context:
|
|
context.setattr(recorded_media_module, "_read_manifest", counted_reparse)
|
|
repaired = RecordedMediaInspector(cache_root).inspect(artifact, command)
|
|
assert repaired.generation_sha256 == initial.generation_sha256
|
|
assert reparses == 1
|
|
assert json.loads(sidecar.read_text(encoding="utf-8"))["checksum_sha256"]
|
|
|
|
segment_path = writer.segments_dir / "1.m4s"
|
|
replacement = writer.segments_dir / "replacement.m4s"
|
|
replacement.write_bytes(segment_path.read_bytes())
|
|
replacement.replace(segment_path)
|
|
reparses = 0
|
|
with monkeypatch.context() as context:
|
|
context.setattr(recorded_media_module, "_read_manifest", counted_reparse)
|
|
replaced = RecordedMediaInspector(cache_root).inspect(artifact, command)
|
|
assert replaced.generation_sha256 == initial.generation_sha256
|
|
assert reparses == 1
|
|
|
|
|
|
def test_recorded_media_preparation_scavenges_crash_temp_files(tmp_path: Path) -> None:
|
|
cache_root = tmp_path / "prepared-media"
|
|
cache_root.mkdir()
|
|
crash_temp = cache_root / f".tmp-{'a' * 32}"
|
|
crash_temp.write_bytes(b"partial")
|
|
|
|
RecordedMediaInspector(cache_root)
|
|
|
|
assert crash_temp.exists() is False
|
|
|
|
|
|
def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
|
|
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
|
router = build_session_router(store)
|
|
put_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "PUT")
|
|
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
|
|
request = LayoutPutRequest(
|
|
version=2,
|
|
revision=0,
|
|
workspace_id="observation.spatial",
|
|
scene_settings={
|
|
"projection": "3d",
|
|
"point_size": 3.0,
|
|
"color_mode": "intensity",
|
|
"palette": "turbo",
|
|
"custom_color": "#35d7c1",
|
|
"accumulation_seconds": 12,
|
|
"show_points": True,
|
|
"show_trajectory": True,
|
|
"show_grid": True,
|
|
"show_labels": False,
|
|
"show_camera_frustums": True,
|
|
},
|
|
visible_source_ids=["sensor.lidar.primary"],
|
|
active_floating_source_id=None,
|
|
window_rects={"sensor.camera.left": {"x": 0.65, "y": 0.65, "width": 0.3, "height": 0.3}},
|
|
viewport_size={"width": 1440, "height": 900},
|
|
)
|
|
put_response = Response()
|
|
|
|
saved = put_route(
|
|
workspace_id="observation.spatial",
|
|
request=request,
|
|
response=put_response,
|
|
if_match='"0"',
|
|
)
|
|
get_response = Response()
|
|
loaded = get_route(workspace_id="observation.spatial", response=get_response)
|
|
|
|
assert saved == loaded
|
|
assert set(loaded) == {
|
|
"version",
|
|
"revision",
|
|
"workspace_id",
|
|
"scene_settings",
|
|
"visible_source_ids",
|
|
"active_floating_source_id",
|
|
"window_rects",
|
|
"viewport_size",
|
|
}
|
|
assert loaded["version"] == 2
|
|
assert loaded["workspace_id"] == "observation.spatial"
|
|
assert loaded["revision"] == 1
|
|
assert put_response.headers["etag"] == '"1"'
|
|
assert get_response.headers["etag"] == '"1"'
|
|
|
|
stale = request.model_copy(update={"revision": 0})
|
|
with pytest.raises(HTTPException) as conflict:
|
|
put_route(
|
|
workspace_id="observation.spatial",
|
|
request=stale,
|
|
response=Response(),
|
|
if_match='"0"',
|
|
)
|
|
assert conflict.value.status_code == 412
|
|
|
|
|
|
def test_layout_get_migrates_legacy_tool_windows_out_of_the_contract(tmp_path: Path) -> None:
|
|
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
|
store.save_layout(
|
|
"observation.spatial",
|
|
schema_version=1,
|
|
expected_revision=0,
|
|
name="Пространственная сцена",
|
|
layout={
|
|
"scene_settings": {
|
|
"projection": "3d",
|
|
"point_size": 3.0,
|
|
"color_mode": "intensity",
|
|
"palette": "turbo",
|
|
"custom_color": "#35d7c1",
|
|
"accumulation_seconds": 12,
|
|
"show_points": True,
|
|
"show_trajectory": True,
|
|
"show_grid": True,
|
|
"show_labels": False,
|
|
"show_camera_frustums": True,
|
|
},
|
|
"tool_windows": {
|
|
"sources_open": True,
|
|
"display_open": True,
|
|
"layers_open": True,
|
|
"order": ["layers", "sources", "display"],
|
|
},
|
|
"visible_source_ids": [],
|
|
"active_floating_source_id": None,
|
|
"window_rects": {},
|
|
"viewport_size": {"width": 1440, "height": 900},
|
|
},
|
|
)
|
|
router = build_session_router(store)
|
|
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
|
|
|
|
loaded = get_route(workspace_id="observation.spatial", response=Response())
|
|
|
|
assert loaded["version"] == 2
|
|
assert "tool_windows" not in loaded
|