497 lines
17 KiB
Python
497 lines
17 KiB
Python
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()
|