Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from collections.abc import Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
import game_data_capture.cli as cli
|
||||
import game_data_capture.runtime as runtime_module
|
||||
from game_data_capture.config import AppConfig, LifecycleConfig, ServerConfig
|
||||
from game_data_capture.ingest.clipboard import (
|
||||
ClipboardIntake,
|
||||
FocusedWindowMetadata,
|
||||
IntakeReceipt,
|
||||
WatchConfig,
|
||||
)
|
||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, receipt_name, spool_png
|
||||
from game_data_capture.paths import RuntimePaths
|
||||
from game_data_capture.runtime import CaptureRuntime, InstanceLock, RuntimeFailure, _CaptureControl
|
||||
from game_data_capture.storage import CaptureRepository, ContentAddressedBlobStore, StorageDatabase
|
||||
|
||||
_PNG = PNG_SIGNATURE + b"watch-png"
|
||||
|
||||
|
||||
class FakeIntake:
|
||||
def __init__(self, cancelled: Event, payloads: list[dict[str, object]]) -> None:
|
||||
self.cancelled = cancelled
|
||||
self.payloads = deque(payloads)
|
||||
self.receipts: deque[IntakeReceipt] = deque()
|
||||
self.armed = self.reconciled = self.shutdown_called = False
|
||||
self.opened = 0
|
||||
self.acknowledged: list[str] = []
|
||||
self.seen_tokens: set[str] = set()
|
||||
self._ticks = 0
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
self.reconciled = True
|
||||
return []
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed = True
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self._ticks += 1
|
||||
if self._ticks > 1:
|
||||
self.cancelled.set()
|
||||
|
||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
||||
return self.payloads.popleft() if self.payloads else None
|
||||
|
||||
def accept_helper_receipt(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
occurrence_id: str,
|
||||
processing_version: str,
|
||||
) -> bool:
|
||||
token = str(payload["receipt_token"])
|
||||
if token in self.seen_tokens:
|
||||
return False
|
||||
self.seen_tokens.add(token)
|
||||
self.receipts.append(
|
||||
IntakeReceipt(
|
||||
occurrence_id,
|
||||
token,
|
||||
"0" * 64,
|
||||
len(_PNG),
|
||||
"image/png",
|
||||
Path("/private/spool.png"),
|
||||
"capture-v1",
|
||||
processing_version,
|
||||
FocusedWindowMetadata(None, None, None),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def take(self) -> IntakeReceipt | None:
|
||||
return self.receipts.popleft() if self.receipts else None
|
||||
|
||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
|
||||
self.opened += 1
|
||||
return nullcontext([_PNG])
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
self.acknowledged.append(receipt.receipt_token)
|
||||
return True
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path, intake: FakeIntake) -> CaptureRuntime:
|
||||
root = tmp_path / "xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
return CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=lambda _config: intake,
|
||||
)
|
||||
|
||||
|
||||
def _payload(token: str) -> dict[str, object]:
|
||||
return {
|
||||
"receipt_version": 1,
|
||||
"receipt_token": token,
|
||||
"sha256": "0" * 64,
|
||||
"byte_size": len(_PNG),
|
||||
"media_type": "image/png",
|
||||
"spool_name": "ignored.png",
|
||||
"policy_version": "capture-v1",
|
||||
}
|
||||
|
||||
|
||||
class PassiveClipboardIntake(ClipboardIntake):
|
||||
"""Use real private spool sidecars without launching a Wayland watcher."""
|
||||
|
||||
def __init__(
|
||||
self, config: WatchConfig, cancelled: Event, *, acknowledge_result: bool = True
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self.cancelled = cancelled
|
||||
self.acknowledge_result = acknowledge_result
|
||||
self.acknowledged: list[str] = []
|
||||
self.reconcile_calls = 0
|
||||
self._ticks = 0
|
||||
|
||||
def reconcile(self) -> list[str]:
|
||||
self.reconcile_calls += 1
|
||||
return super().reconcile()
|
||||
|
||||
def arm(self) -> None:
|
||||
self.armed, self.paused = True, False
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self._ticks += 1
|
||||
if self._ticks > 1:
|
||||
self.cancelled.set()
|
||||
|
||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
||||
self.acknowledged.append(receipt.receipt_token)
|
||||
return self.acknowledge_result and super().acknowledge(receipt)
|
||||
|
||||
|
||||
class StopTimeoutReceiptIntake(PassiveClipboardIntake):
|
||||
def __init__(self, config: WatchConfig, cancelled: Event) -> None:
|
||||
super().__init__(config, cancelled)
|
||||
self.tick_entered = Event()
|
||||
self.release_tick = Event()
|
||||
|
||||
def tick(self, now: float | None = None) -> None:
|
||||
self.tick_entered.set()
|
||||
self.release_tick.wait()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.release_tick.set()
|
||||
super().shutdown()
|
||||
|
||||
|
||||
def _durable_runtime(
|
||||
tmp_path: Path, cancelled: Event, *, acknowledge_result: bool = True
|
||||
) -> tuple[CaptureRuntime, list[PassiveClipboardIntake]]:
|
||||
created: list[PassiveClipboardIntake] = []
|
||||
root = tmp_path / "durable-xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
|
||||
def factory(config: WatchConfig) -> PassiveClipboardIntake:
|
||||
intake = PassiveClipboardIntake(config, cancelled, acknowledge_result=acknowledge_result)
|
||||
created.append(intake)
|
||||
return intake
|
||||
|
||||
return (
|
||||
CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=factory,
|
||||
),
|
||||
created,
|
||||
)
|
||||
|
||||
|
||||
def _durable_receipt(runtime: CaptureRuntime, token: str) -> None:
|
||||
spool_png(
|
||||
io.BytesIO(_PNG),
|
||||
spool=runtime.paths.cache_dir / "spool",
|
||||
max_bytes=100,
|
||||
receipt_token=token,
|
||||
policy_version="capture-v1",
|
||||
)
|
||||
|
||||
|
||||
def test_watch_streams_verified_receipts_and_replays_exact_tokens_idempotently(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [_payload("watch-receipt-1"), _payload("watch-receipt-1")])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
|
||||
assert intake.armed and intake.reconciled and intake.shutdown_called and intake.opened == 1
|
||||
assert intake.acknowledged == ["watch-receipt-1"]
|
||||
|
||||
|
||||
def test_watch_ignores_an_absent_helper_receipt_without_error_noise(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
|
||||
assert intake.shutdown_called and intake.opened == 0
|
||||
|
||||
|
||||
def test_startup_reconciles_partial_blob_storage_before_arming(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
intake = FakeIntake(cancelled, [])
|
||||
runtime = _runtime(tmp_path, intake)
|
||||
blobs = ContentAddressedBlobStore(runtime.paths.data_dir)
|
||||
blobs.temporary.mkdir(parents=True)
|
||||
partial = blobs.temporary / ".leftover.partial"
|
||||
partial.write_bytes(b"partial")
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
assert not partial.exists()
|
||||
assert intake.reconciled and intake.shutdown_called
|
||||
|
||||
|
||||
def test_durable_receipt_is_acknowledged_once_after_repository_confirmation(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "confirmed")
|
||||
events: list[str] = []
|
||||
original_receive = CaptureRepository.receive
|
||||
original_acknowledge = PassiveClipboardIntake.acknowledge
|
||||
|
||||
def tracked_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
events.append("receive")
|
||||
return original_receive(
|
||||
self,
|
||||
capture_id,
|
||||
receipt_token,
|
||||
chunks,
|
||||
media_type=media_type,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", tracked_receive)
|
||||
|
||||
def tracked_acknowledge(self: PassiveClipboardIntake, receipt: IntakeReceipt) -> bool:
|
||||
events.append("acknowledge")
|
||||
return original_acknowledge(self, receipt)
|
||||
|
||||
monkeypatch.setattr(PassiveClipboardIntake, "acknowledge", tracked_acknowledge)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert events == ["receive", "acknowledge"]
|
||||
assert created[0].acknowledged == ["confirmed"]
|
||||
assert created[0].reconcile_calls == 1
|
||||
assert not (runtime.paths.cache_dir / "spool" / receipt_name("confirmed")).exists()
|
||||
|
||||
|
||||
def test_storage_failure_leaves_durable_receipt_unacknowledged(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "storage-failure")
|
||||
|
||||
def failed_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
raise OSError("storage unavailable")
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", failed_receive)
|
||||
with pytest.raises(RuntimeFailure, match="capture watch failed"):
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("storage-failure")).exists()
|
||||
|
||||
|
||||
def test_cancellation_after_storage_confirmation_does_not_acknowledge(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
cancelled = Event()
|
||||
runtime, created = _durable_runtime(tmp_path, cancelled)
|
||||
_durable_receipt(runtime, "cancelled-after-storage")
|
||||
original_receive = CaptureRepository.receive
|
||||
|
||||
def cancel_after_receive(
|
||||
self: CaptureRepository,
|
||||
capture_id: str,
|
||||
receipt_token: str,
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
media_type: str,
|
||||
max_bytes: int,
|
||||
) -> str:
|
||||
result = original_receive(
|
||||
self,
|
||||
capture_id,
|
||||
receipt_token,
|
||||
chunks,
|
||||
media_type=media_type,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
cancelled.set()
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(CaptureRepository, "receive", cancel_after_receive)
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("cancelled-after-storage")).exists()
|
||||
|
||||
|
||||
def test_acknowledgement_failure_replays_idempotently_and_releases_lock(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
runtime, failed = _durable_runtime(tmp_path, cancelled, acknowledge_result=False)
|
||||
_durable_receipt(runtime, "replay-token")
|
||||
with pytest.raises(RuntimeFailure, match="acknowledgement failed"):
|
||||
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
|
||||
assert failed[0].acknowledged == ["replay-token"]
|
||||
sidecar = runtime.paths.cache_dir / "spool" / receipt_name("replay-token")
|
||||
assert sidecar.exists()
|
||||
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
|
||||
reusable.release()
|
||||
|
||||
replay_cancelled = Event()
|
||||
replay_runtime, replayed = _durable_runtime(tmp_path, replay_cancelled)
|
||||
replay_runtime.watch(cancelled=replay_cancelled, poll_seconds=0.001)
|
||||
database = StorageDatabase(replay_runtime.paths.data_dir, replay_runtime.migrations_dir)
|
||||
with database.connect() as connection:
|
||||
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
|
||||
assert replayed[0].acknowledged == ["replay-token"]
|
||||
assert not sidecar.exists()
|
||||
|
||||
|
||||
def test_service_stop_timeout_preserves_unacknowledged_durable_sidecar(tmp_path: Path) -> None:
|
||||
cancelled = Event()
|
||||
created: list[StopTimeoutReceiptIntake] = []
|
||||
root = tmp_path / "stop-timeout-xdg"
|
||||
paths = RuntimePaths(
|
||||
config_dir=(root / "config").resolve(),
|
||||
data_dir=(root / "data").resolve(),
|
||||
cache_dir=(root / "cache").resolve(),
|
||||
runtime_dir=(root / "runtime").resolve(),
|
||||
)
|
||||
|
||||
def factory(config: WatchConfig) -> StopTimeoutReceiptIntake:
|
||||
intake = StopTimeoutReceiptIntake(config, cancelled)
|
||||
created.append(intake)
|
||||
return intake
|
||||
|
||||
runtime = CaptureRuntime(
|
||||
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
|
||||
paths,
|
||||
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
|
||||
intake_factory=factory,
|
||||
)
|
||||
_durable_receipt(runtime, "timeout-receipt")
|
||||
resources = runtime._start()
|
||||
control = _CaptureControl(runtime, resources.repository)
|
||||
try:
|
||||
assert control.start_session() == "started"
|
||||
assert created[0].tick_entered.wait(0.2)
|
||||
assert control.stop_session() == "stopped"
|
||||
assert created[0].acknowledged == []
|
||||
assert (runtime.paths.cache_dir / "spool" / receipt_name("timeout-receipt")).exists()
|
||||
finally:
|
||||
control.shutdown()
|
||||
resources.close()
|
||||
|
||||
|
||||
def test_cli_bare_watch_is_a_safe_refusal_and_modes_never_start_live_processes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = tmp_path / "fresh-xdg"
|
||||
for key, name in (
|
||||
("HOME", "home"),
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
("XDG_RUNTIME_DIR", "runtime"),
|
||||
):
|
||||
monkeypatch.setenv(key, str(root / name))
|
||||
|
||||
def forbidden(*_arguments: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("test started a process")
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", forbidden)
|
||||
assert cli.main(["watch"]) == cli.EXIT_UNIMPLEMENTED
|
||||
assert "requires gdc-runtime-integration" in capsys.readouterr().err
|
||||
assert cli.main(["watch", "--dry-run"]) == 0
|
||||
assert capsys.readouterr().out == "watch dry-run: capture runtime plan is valid\n"
|
||||
assert not root.exists()
|
||||
|
||||
|
||||
def test_cli_arm_reaches_only_a_mocked_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, _config: object, _paths: object) -> None:
|
||||
calls.append("created")
|
||||
|
||||
def watch(self) -> None:
|
||||
calls.append("watch")
|
||||
|
||||
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
|
||||
assert cli.main(["watch", "--arm"]) == 0
|
||||
assert calls == ["created", "watch"]
|
||||
|
||||
|
||||
def test_cli_serve_validates_before_constructing_resources_and_passes_exact_authority(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[str, object]] = []
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, config: AppConfig, _paths: object) -> None:
|
||||
calls.append(("created", config.server))
|
||||
|
||||
def serve(self) -> None:
|
||||
calls.append(("serve", None))
|
||||
|
||||
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
|
||||
assert cli.main(["serve", "--host", "127.0.0.1", "--port", "9877"]) == 0
|
||||
assert calls == [("created", ServerConfig("127.0.0.1", 9877)), ("serve", None)]
|
||||
|
||||
calls.clear()
|
||||
assert cli.main(["serve", "--host", "0.0.0.0"]) == cli.EXIT_CONFIG_ERROR
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_cli_serve_converts_uvicorn_startup_exit_without_leaking_its_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
root = tmp_path / "serve-xdg"
|
||||
for key, name in (
|
||||
("HOME", "home"),
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
("XDG_RUNTIME_DIR", "runtime"),
|
||||
):
|
||||
monkeypatch.setenv(key, str(root / name))
|
||||
token = "Z" * 43
|
||||
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
|
||||
|
||||
def failed_server(*_: object, **__: object) -> None:
|
||||
raise SystemExit(2)
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", failed_server)
|
||||
assert cli.main(["serve"]) == cli.EXIT_RUNTIME_ERROR
|
||||
output = capsys.readouterr()
|
||||
assert output.err == "capture runtime failed\n"
|
||||
assert token not in output.err
|
||||
assert "SystemExit" not in output.err
|
||||
reusable = InstanceLock.acquire(root / "runtime" / "shd-capture" / "instance.lock", 0)
|
||||
reusable.release()
|
||||
Reference in New Issue
Block a user