reachy_mini_i_spy / tests /test_runtime.py
Timbo89's picture
release: publish standalone I Spy v0.2.0
9c7fd58 verified
Raw
History Blame Contribute Delete
77.8 kB
from __future__ import annotations
import io
import threading
import time
import wave
from enum import StrEnum
from typing import Any
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from reachy_mini_i_spy import runtime as runtime_module
from reachy_mini_i_spy.game import GameState, validate_target
from reachy_mini_i_spy.runtime import GameRuntime, MotionOwner, PrivacySafeRuntimeError
class FakeMedia:
def __init__(self) -> None:
self.frames = 0
self.pushed: list[np.ndarray] = []
def get_frame_jpeg(self) -> bytes:
self.frames += 1
return b"jpeg"
def get_output_audio_samplerate(self) -> int:
return 16_000
def start_playing(self) -> None:
pass
def stop_playing(self) -> None:
pass
def push_audio_sample(self, audio: np.ndarray) -> None:
self.pushed.append(audio)
class FakeRobot:
def __init__(self) -> None:
self.media = FakeMedia()
self.moves: list[tuple[int, dict[str, Any]]] = []
self.fold_threads: list[int] = []
self.operations: list[str] = []
self.motor_mode = "disabled"
self.pose = np.eye(4, dtype=np.float64)
self.pose[2, 3] = -0.20
self.wake_error: Exception | None = None
self.fold_error: Exception | None = None
self.disable_error: Exception | None = None
self.wake_entered = threading.Event()
self.wake_release = threading.Event()
self.wake_release.set()
class Client:
def __init__(inner_self, owner: FakeRobot) -> None:
inner_self.owner = owner
def get_status(inner_self, **_: object) -> object:
backend = type("BackendStatus", (), {"motor_control_mode": inner_self.owner.motor_mode})()
return type("DaemonStatus", (), {"backend_status": backend})()
self.client = Client(self)
def goto_target(self, **kwargs: object) -> None:
self.operations.append("goto_target")
self.moves.append((threading.get_ident(), kwargs))
def goto_sleep(self) -> None:
self.operations.append("goto_sleep")
self.fold_threads.append(threading.get_ident())
if self.fold_error is not None:
raise self.fold_error
self.pose = MotionOwner.SLEEP_HEAD_POSE.copy()
def wake_up(self) -> None:
self.operations.append("wake_up")
self.wake_entered.set()
assert self.wake_release.wait(2)
if self.wake_error is not None:
raise self.wake_error
self.pose = np.eye(4, dtype=np.float64)
def enable_motors(self) -> None:
self.operations.append("enable_motors")
self.motor_mode = "enabled"
def disable_motors(self) -> None:
self.operations.append("disable_motors")
if self.disable_error is not None:
raise self.disable_error
self.motor_mode = "disabled"
def get_current_head_pose(self) -> np.ndarray:
return self.pose.copy()
class PositivePipelineState(StrEnum):
NULL = "null"
PLAYING = "playing"
class PositiveAudio:
"""Test audio transport whose transition to silence is directly observable."""
class Pipeline:
def __init__(self) -> None:
self.state = PositivePipelineState.PLAYING
def set_state(self, state: PositivePipelineState) -> None:
self.state = state
def get_state(self, timeout: int) -> object:
assert timeout >= 0
return type("StateChange", (), {"state": self.state})()
def __init__(self) -> None:
self._pipeline = self.Pipeline()
class FakeProvider:
def select_target(self, frames: list[bytes], **_: object):
assert len(frames) == 3
return validate_target({
"object_name": "chair", "colour": "blue", "category": "furniture",
"location": "near the table", "frame_index": 0, "bbox": [0.2, 0.2, 0.3, 0.4],
"confidence": 0.9, "stable": True, "visible_frame_count": 2,
"hints_en": ["You sit on it"],
"hints_nl": ["Je zit erop"],
}, frame_count=3)
def wait_for(predicate, timeout: float = 2.0) -> None: # type: ignore[no-untyped-def]
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
raise AssertionError("condition not reached")
def test_search_motion_is_bounded_and_captures_only_three_frames(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
frames = runtime.motion.search(generation)
assert frames == [b"jpeg"] * 3
assert runtime.motion.SEARCH_SETTLE_SECONDS == 1.5
assert runtime.motion.STOP_CLEANUP_TIMEOUT_SECONDS == 20.0
assert len(robot.moves) == 5
for _, move in robot.moves:
assert abs(float(move["body_yaw"])) <= np.deg2rad(30)
assert move["duration"] == 0.75
assert move["method"] == "minjerk"
def test_search_retries_a_transient_missing_camera_frame(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
responses = iter([None, b"jpeg", b"jpeg", b"jpeg"])
monkeypatch.setattr(robot.media, "get_frame_jpeg", lambda: next(responses))
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
assert runtime.motion.search(generation) == [b"jpeg"] * 3
def test_search_waits_boundedly_for_camera_readiness(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
ready_at = time.monotonic() + 0.1
def warming_frame() -> bytes | None:
return b"jpeg" if time.monotonic() >= ready_at else None
monkeypatch.setattr(robot.media, "get_frame_jpeg", warming_frame)
monkeypatch.setattr(runtime.motion, "FRAME_CAPTURE_TIMEOUT_SECONDS", 0.5)
monkeypatch.setattr(runtime.motion, "FRAME_CAPTURE_RETRY_SECONDS", 0.02)
assert runtime.motion._capture_frame(generation, "search") == b"jpeg"
def test_search_bounds_persistent_missing_frame_attempts(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
attempts = 0
def missing_frame() -> None:
nonlocal attempts
attempts += 1
monkeypatch.setattr(robot.media, "get_frame_jpeg", missing_frame)
monkeypatch.setattr(runtime.motion, "FRAME_CAPTURE_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(runtime.motion, "FRAME_CAPTURE_RETRY_SECONDS", 0.002)
started = time.monotonic()
with pytest.raises(PrivacySafeRuntimeError, match="search_frame_unavailable"):
runtime.motion._capture_frame(generation, "search")
assert time.monotonic() - started < 0.1
assert 1 < attempts < 20
def test_search_camera_readiness_wait_is_cancellable(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
attempts = 0
def cancel_during_warmup() -> None:
nonlocal attempts
attempts += 1
runtime.machine.stop()
monkeypatch.setattr(robot.media, "get_frame_jpeg", cancel_during_warmup)
assert runtime.motion._capture_frame(generation, "search") is None
assert attempts == 1
def test_search_failure_logs_only_privacy_safe_diagnostic_code(monkeypatch, caplog) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
monkeypatch.setattr(runtime.motion, "FRAME_CAPTURE_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
monkeypatch.setattr(
robot.media,
"get_frame_jpeg",
lambda: (_ for _ in ()).throw(RuntimeError("private scene and camera detail")),
)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
wait_for(lambda: runtime.machine.state == GameState.ERROR)
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
finally:
stop_event.set()
worker.join(timeout=2)
assert "search_frame_capture_failed" in caplog.text
assert "private scene and camera detail" not in caplog.text
assert runtime.snapshot()["camera_active"] is False
assert robot.motor_mode == "disabled"
def test_search_checks_cancellation_between_moves(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
def cancel(*_: object, **__: object) -> None:
robot.moves.append((threading.get_ident(), {}))
runtime.machine.stop()
monkeypatch.setattr(robot, "goto_target", cancel)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: False)
assert runtime.motion.search(generation) == []
assert len(robot.moves) == 1
assert robot.media.frames == 0
def test_runtime_owns_all_motion_and_folds_on_shutdown(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
worker = threading.Thread(target=runtime.run)
worker.start()
runtime.start("en", "7-9", True)
wait_for(lambda: runtime.machine.state == GameState.GUESSING)
target = runtime.machine.target
runtime.stop()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
assert runtime.machine.target is None
assert target is not None
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
assert robot.fold_threads == [worker.ident]
# MotionOwner remains the sole command path, while individual blocking SDK
# calls run on revocable daemon wrappers so Stop never waits on them.
assert len(robot.moves) == 6
assert robot.operations.index("enable_motors") < robot.operations.index("wake_up")
assert robot.operations.index("goto_sleep") < robot.operations.index("disable_motors")
assert robot.motor_mode == "disabled"
def test_sdk_shutdown_revokes_blocked_provider_and_completes_safe_fold(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
provider_entered = threading.Event()
provider_release = threading.Event()
def blocked_provider() -> object:
provider_entered.set()
provider_release.wait()
return FakeProvider()
monkeypatch.setattr(
runtime,
"_run_round",
lambda generation: runtime._call_if_current(generation, blocked_provider),
)
worker = threading.Thread(target=runtime.run)
worker.start()
runtime.start("en", "7-9", True)
assert provider_entered.wait(1)
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
assert runtime._shutdown_complete.is_set()
assert not runtime._shutdown_failed.is_set()
assert runtime.snapshot()["runtime_ready"] is False
assert runtime.snapshot()["camera_active"] is False
assert robot.motor_mode == "disabled"
provider_release.set()
def test_shutdown_fold_failure_is_reported_and_never_marked_complete() -> None:
robot = FakeRobot()
robot.motor_mode = "enabled"
robot.fold_error = RuntimeError("private servo diagnostics")
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
failures: list[BaseException] = []
def run() -> None:
try:
runtime.run()
except BaseException as exc:
failures.append(exc)
worker = threading.Thread(target=run)
worker.start()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
assert len(failures) == 1
assert isinstance(failures[0], PrivacySafeRuntimeError)
assert failures[0].diagnostic_code == "shutdown_fold_unconfirmed"
assert runtime._shutdown_failed.is_set()
assert not runtime._shutdown_complete.is_set()
assert runtime.snapshot()["runtime_ready"] is False
assert robot.motor_mode == "enabled"
assert "disable_motors" not in robot.operations
def test_motion_pose_table_stays_within_app_bounds() -> None:
assert len(MotionOwner.SEARCH_POSES) == 5
assert all(abs(body) <= 30 and abs(yaw) <= 38 and abs(pitch) <= 10 for body, yaw, pitch in MotionOwner.SEARCH_POSES)
assert all(abs(yaw - body) <= 20 for body, yaw, _ in MotionOwner.SEARCH_POSES)
def test_tts_wav_is_resampled_in_memory_for_reachy_output() -> None:
robot = FakeRobot()
robot.media.audio = PositiveAudio()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(24_000)
wav_file.writeframes(np.zeros(240, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
assert len(robot.media.pushed) == 1
assert robot.media.pushed[0].dtype == np.float32
assert len(robot.media.pushed[0]) == 160
def test_start_rejects_until_runtime_worker_is_ready() -> None:
runtime = GameRuntime(FakeRobot(), threading.Event())
with pytest.raises(RuntimeError, match="not ready"):
runtime.start("en", "7-9", True)
assert runtime.snapshot()["runtime_ready"] is False
assert runtime.snapshot()["camera_active"] is False
def test_stop_cannot_be_lost_between_start_state_and_event_publication(monkeypatch) -> None: # type: ignore[no-untyped-def]
runtime = GameRuntime(FakeRobot(), threading.Event())
runtime._ready.set()
start_event_entered = threading.Event()
start_event_release = threading.Event()
stop_done = threading.Event()
original_replace_pending = runtime._replace_pending
def pause_start_publication(event) -> None: # type: ignore[no-untyped-def]
if event.kind == "start":
start_event_entered.set()
assert start_event_release.wait(2)
original_replace_pending(event)
monkeypatch.setattr(runtime, "_replace_pending", pause_start_publication)
start_thread = threading.Thread(target=runtime.start, args=("en", "7-9", True))
start_thread.start()
assert start_event_entered.wait(1)
stop_thread = threading.Thread(target=lambda: (runtime.stop(), stop_done.set()))
stop_thread.start()
assert not stop_done.wait(0.05)
start_event_release.set()
start_thread.join(timeout=2)
stop_thread.join(timeout=2)
assert not start_thread.is_alive()
assert not stop_thread.is_alive()
event = runtime._events.get_nowait()
assert event.kind == "stop"
assert runtime._events.empty()
assert runtime.machine.state == GameState.STOPPED
assert runtime.snapshot()["camera_active"] is False
def test_disabled_motor_round_wakes_and_verifies_before_search(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
wait_for(lambda: runtime.machine.state == GameState.GUESSING)
assert robot.operations[:3] == ["enable_motors", "wake_up", "goto_target"]
assert runtime.snapshot()["motor_state"] == "awake"
finally:
runtime.stop()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_wake_accepts_sdk_near_init_pose_in_magic_distance() -> None:
robot = FakeRobot()
def wake_with_calibrated_measured_pose() -> None:
robot.operations.append("wake_up")
robot.pose = np.eye(4, dtype=np.float64)
robot.pose[:3, :3] = Rotation.from_euler("y", 5, degrees=True).as_matrix()
robot.pose[0, 3] = 0.020
robot.wake_up = wake_with_calibrated_measured_pose # type: ignore[method-assign]
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
assert runtime.motion.status()["motor_state"] == "awake"
def test_wake_allows_bounded_sensor_convergence_after_sdk_command(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
pose_reads = 0
monotonic_seconds = 0.0
def wake_before_measured_pose_converges() -> None:
robot.operations.append("wake_up")
robot.pose = MotionOwner.SLEEP_HEAD_POSE.copy()
def delayed_measured_pose() -> np.ndarray:
nonlocal pose_reads
pose_reads += 1
if monotonic_seconds >= 1.1:
return MotionOwner.INIT_HEAD_POSE.copy()
return MotionOwner.SLEEP_HEAD_POSE.copy()
def advance_clock(seconds: float) -> None:
nonlocal monotonic_seconds
monotonic_seconds += seconds
robot.wake_up = wake_before_measured_pose_converges # type: ignore[method-assign]
robot.get_current_head_pose = delayed_measured_pose # type: ignore[method-assign]
monkeypatch.setattr(runtime_module.time, "monotonic", lambda: monotonic_seconds)
monkeypatch.setattr(runtime_module.time, "sleep", advance_clock)
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime.motion.wake(generation)
assert 1.0 < monotonic_seconds < MotionOwner.WAKE_POSE_TIMEOUT_SECONDS
assert pose_reads > 1
assert runtime.motion.status()["motor_state"] == "awake"
def test_wake_rejects_folded_pose() -> None:
robot = FakeRobot()
def wake_without_leaving_fold() -> None:
robot.operations.append("wake_up")
robot.pose = MotionOwner.SLEEP_HEAD_POSE.copy()
robot.wake_up = wake_without_leaving_fold # type: ignore[method-assign]
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
with pytest.raises(PrivacySafeRuntimeError, match="wake_pose_unconfirmed"):
runtime.motion.wake(generation)
assert runtime.motion.status()["motor_state"] == "error"
def test_wake_rejects_malformed_measured_pose() -> None:
robot = FakeRobot()
def wake_with_invalid_rotation() -> None:
robot.operations.append("wake_up")
robot.pose = np.eye(4, dtype=np.float64)
robot.pose[:3, :3] *= 2.0
robot.wake_up = wake_with_invalid_rotation # type: ignore[method-assign]
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
with pytest.raises(PrivacySafeRuntimeError, match="wake_pose_unconfirmed"):
runtime.motion.wake(generation)
def test_wake_failure_fails_closed_without_search_or_camera(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
robot.wake_error = RuntimeError("raw hardware detail must not leak")
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
wait_for(lambda: runtime.machine.state == GameState.ERROR)
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
snapshot = runtime.snapshot()
assert snapshot["camera_active"] is False
assert "raw hardware detail" not in str(snapshot)
assert "goto_target" not in robot.operations
assert robot.media.frames == 0
finally:
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_wake_pose_timeout_fails_closed_before_every_downstream_boundary(monkeypatch, caplog) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime.motion, "WAKE_POSE_TIMEOUT_SECONDS", 0.01)
provider_calls: list[int] = []
tts_calls: list[str] = []
broker_journal: list[str] = []
def wake_without_leaving_fold() -> None:
robot.operations.append("wake_up")
robot.pose = MotionOwner.SLEEP_HEAD_POSE.copy()
def provider(generation: int) -> FakeProvider:
provider_calls.append(generation)
broker_journal.append("provider boundary reached")
return FakeProvider()
robot.wake_up = wake_without_leaving_fold # type: ignore[method-assign]
monkeypatch.setattr(runtime, "_provider", provider)
monkeypatch.setattr(
runtime,
"_speak",
lambda provider, text, generation: tts_calls.append(text),
)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
started = runtime.start("en", "7-9", True)
assert started["generation"] == 1
wait_for(lambda: runtime.machine.state == GameState.ERROR)
failed = runtime.snapshot()
assert failed["generation"] == 2
assert failed["camera_active"] is False
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
finally:
stop_event.set()
worker.join(timeout=2)
assert "diagnostic=wake_pose_unconfirmed" in caplog.text
assert "unclassified_runtime_failure" not in caplog.text
assert provider_calls == []
assert broker_journal == []
assert tts_calls == []
assert robot.media.frames == 0
assert robot.media.pushed == []
assert robot.moves == []
assert robot.operations == ["enable_motors", "wake_up", "goto_sleep", "disable_motors"]
assert robot.motor_mode == "disabled"
assert runtime.motion.status()["motor_state"] == "asleep"
assert not worker.is_alive()
def test_stop_during_wake_cancels_search_then_folds(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
robot.wake_release.clear()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
assert robot.wake_entered.wait(1)
stopped = runtime.stop()
assert stopped["camera_active"] is False
robot.wake_release.set()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
assert "goto_target" not in robot.operations
assert robot.media.frames == 0
assert robot.motor_mode == "disabled"
finally:
robot.wake_release.set()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_stop_during_wake_side_effects_waits_for_quiescence_and_folds_before_return(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
robot.wake_release.clear()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
worker = threading.Thread(target=runtime.run)
worker.start()
stop_done = threading.Event()
stopped: list[dict[str, object]] = []
stopper = threading.Thread(target=lambda: (stopped.append(runtime.stop()), stop_done.set()))
try:
runtime.start("en", "7-9", True)
assert robot.wake_entered.wait(1)
stopper.start()
assert not stop_done.wait(0.05)
robot.wake_release.set()
assert stop_done.wait(1)
stopper.join(timeout=1)
assert not stopper.is_alive()
assert stopped[0]["motor_state"] == "asleep"
assert runtime.motion.status()["motor_state"] == "asleep"
assert robot.motor_mode == "disabled"
assert robot.operations.index("wake_up") < robot.operations.index("goto_sleep")
assert robot.operations.index("goto_sleep") < robot.operations.index("disable_motors")
assert "goto_target" not in robot.operations
finally:
robot.wake_release.set()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_stop_wake_barrier_timeout_is_bounded_and_fail_closed(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
robot.wake_release.clear()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime.motion, "WAKE_STOP_BARRIER_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
assert robot.wake_entered.wait(1)
before = time.monotonic()
with pytest.raises(PrivacySafeRuntimeError, match="wake_stop_barrier_timeout"):
runtime.stop()
assert time.monotonic() - before < 0.25
assert runtime.machine.state == GameState.STOPPED
assert runtime.snapshot()["camera_active"] is False
assert runtime.motion.status()["motor_state"] == "stopping"
finally:
robot.wake_release.set()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
assert robot.motor_mode == "disabled"
def test_start_cannot_replace_pending_stop_cleanup(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
provider_entered = threading.Event()
provider_release = threading.Event()
class BlockingProvider(FakeProvider):
def select_target(self, frames: list[bytes], **kwargs: object): # type: ignore[no-untyped-def]
provider_entered.set()
assert provider_release.wait(2)
return super().select_target(frames, **kwargs)
monkeypatch.setattr(runtime, "_provider", lambda generation: BlockingProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
monkeypatch.setattr(runtime.motion, "_wait", lambda generation, seconds: True)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
assert provider_entered.wait(1)
runtime.stop()
try:
restarted = runtime.start("en", "7-9", True)
except RuntimeError as exc:
assert "cleanup is pending" in str(exc)
restarted = None
else:
# Revocable provider wrappers may let the worker finish cleanup
# before Stop returns. A restart is valid only after verified fold.
assert runtime.motion.status()["motor_state"] == "asleep"
provider_release.set()
if restarted is None:
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
wait_for(lambda: runtime._stop_cycle is not None and runtime._stop_cycle.restart_safe.is_set())
restarted = runtime.start("en", "7-9", True)
assert restarted["state"] == GameState.SEARCHING
wait_for(lambda: runtime.machine.state == GameState.GUESSING)
first_fold = robot.operations.index("goto_sleep")
assert first_fold < robot.operations.index("wake_up", first_fold + 1)
finally:
provider_release.set()
runtime.stop()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_stop_during_successful_wake_pose_read_cannot_advance(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
pose_read_entered = threading.Event()
pose_read_release = threading.Event()
provider_calls: list[int] = []
original_pose_is = runtime.motion._pose_is
def blocked_successful_wake_pose_read(expected: np.ndarray, max_magic_distance: float) -> bool:
if np.array_equal(expected, MotionOwner.INIT_HEAD_POSE):
pose_read_entered.set()
assert pose_read_release.wait(2)
return True
return original_pose_is(expected, max_magic_distance)
def provider(generation: int) -> FakeProvider:
provider_calls.append(generation)
return FakeProvider()
monkeypatch.setattr(runtime.motion, "_pose_is", blocked_successful_wake_pose_read)
monkeypatch.setattr(runtime, "_provider", provider)
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
assert pose_read_entered.wait(1)
stopped = runtime.stop()
assert stopped["camera_active"] is False
pose_read_release.set()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
assert provider_calls == []
assert "goto_target" not in robot.operations
assert robot.media.frames == 0
assert robot.media.pushed == []
assert robot.motor_mode == "disabled"
assert runtime.snapshot()["camera_active"] is False
finally:
pose_read_release.set()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_stop_after_final_wake_check_cannot_publish_awake(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
final_check_entered = threading.Event()
final_check_release = threading.Event()
original_set_state = runtime.motion._set_state
def pause_awake_publication(state: str, message: str) -> None:
if state == "awake":
final_check_entered.set()
assert final_check_release.wait(2)
original_set_state(state, message)
monkeypatch.setattr(runtime.motion, "_set_state", pause_awake_publication)
monkeypatch.setattr(runtime.motion, "_wait_for_pose", lambda *args, **kwargs: True)
result: list[bool] = []
wake_thread = threading.Thread(target=lambda: result.append(runtime.motion.wake(generation)))
wake_thread.start()
assert final_check_entered.wait(1)
stop_done = threading.Event()
stop_thread = threading.Thread(target=lambda: (runtime.stop(), stop_done.set()))
stop_thread.start()
assert not stop_done.wait(0.05)
final_check_release.set()
stop_thread.join(timeout=2)
wake_thread.join(timeout=2)
assert not stop_thread.is_alive()
assert not wake_thread.is_alive()
assert result == [False]
assert runtime.machine.state == GameState.STOPPED
assert runtime.motion.status()["motor_state"] == "asleep"
def test_stop_after_round_check_cannot_create_or_retain_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
provider_boundary_entered = threading.Event()
provider_boundary_release = threading.Event()
provider_constructions: list[int] = []
original_provider = runtime._provider
def paused_provider(checked_generation: int): # type: ignore[no-untyped-def]
provider_boundary_entered.set()
assert provider_boundary_release.wait(2)
return original_provider(checked_generation)
class CancellableProvider(FakeProvider):
def cancel(self) -> None:
pass
def construct_provider(*_: object, **__: object) -> CancellableProvider:
provider_constructions.append(generation)
return CancellableProvider()
monkeypatch.setattr(runtime.motion, "wake", lambda checked_generation: True)
monkeypatch.setattr(runtime, "_provider", paused_provider)
monkeypatch.setattr(runtime_module, "load_config", lambda: object())
monkeypatch.setattr(runtime_module, "ProviderClient", construct_provider)
round_thread = threading.Thread(target=runtime._run_round, args=(generation,))
round_thread.start()
assert provider_boundary_entered.wait(1)
runtime.stop()
provider_boundary_release.set()
round_thread.join(timeout=2)
assert not round_thread.is_alive()
assert provider_constructions == []
assert runtime._providers == {}
def test_stop_revokes_in_process_provider() -> None:
runtime = GameRuntime(FakeRobot(), threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
local_cancelled = threading.Event()
class TrackedProvider:
def cancel_local(self) -> bool:
local_cancelled.set()
return True
runtime._providers[generation] = TrackedProvider() # type: ignore[assignment]
runtime.stop()
assert local_cancelled.is_set()
assert runtime._providers == {}
def test_stop_after_tts_check_cannot_invoke_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
tts_check_entered = threading.Event()
tts_check_release = threading.Event()
synth_calls: list[str] = []
original_is_current = runtime._is_current
first_check = True
def pause_after_successful_check(checked_generation: int) -> bool:
nonlocal first_check
current = original_is_current(checked_generation)
if first_check:
first_check = False
assert current
tts_check_entered.set()
assert tts_check_release.wait(2)
return current
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
synth_calls.append(text)
return b""
monkeypatch.setattr(runtime, "_is_current", pause_after_successful_check)
speak_thread = threading.Thread(
target=runtime._speak,
args=(SpeechProvider(), "private words", generation), # type: ignore[arg-type]
)
speak_thread.start()
assert tts_check_entered.wait(1)
runtime.stop()
tts_check_release.set()
speak_thread.join(timeout=2)
assert not speak_thread.is_alive()
assert synth_calls == []
assert robot.media.pushed == []
def test_consent_revoke_after_frame_check_cannot_capture_or_return_frame(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
frame_check_entered = threading.Event()
frame_check_release = threading.Event()
original_is_current = runtime.motion._is_current
first_check = True
def pause_after_successful_check(checked_generation: int) -> bool:
nonlocal first_check
current = original_is_current(checked_generation)
if first_check:
first_check = False
assert current
frame_check_entered.set()
assert frame_check_release.wait(2)
return current
monkeypatch.setattr(runtime.motion, "_is_current", pause_after_successful_check)
captured: list[bytes | None] = []
capture_thread = threading.Thread(
target=lambda: captured.append(runtime.motion._capture_frame(generation, "search"))
)
capture_thread.start()
assert frame_check_entered.wait(1)
runtime.stop("camera_disabled")
frame_check_release.set()
capture_thread.join(timeout=2)
assert not capture_thread.is_alive()
assert captured == [None]
assert robot.media.frames == 0
def test_stop_after_search_check_cannot_advance_camera_viewpoint(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
runtime.motion._set_state("awake", "test-only verified wake")
search_check_entered = threading.Event()
search_check_release = threading.Event()
original_is_current = runtime.motion._is_current
first_check = True
def pause_after_successful_check(checked_generation: int) -> bool:
nonlocal first_check
current = original_is_current(checked_generation)
if first_check:
first_check = False
assert current
search_check_entered.set()
assert search_check_release.wait(2)
return current
monkeypatch.setattr(runtime.motion, "_is_current", pause_after_successful_check)
frames: list[list[bytes]] = []
search_thread = threading.Thread(target=lambda: frames.append(runtime.motion.search(generation)))
search_thread.start()
assert search_check_entered.wait(1)
runtime.stop()
search_check_release.set()
search_thread.join(timeout=2)
assert not search_thread.is_alive()
assert frames == [[]]
assert robot.moves == []
assert robot.media.frames == 0
def test_stop_during_search_allows_no_late_motion_or_camera(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
move_entered = threading.Event()
move_release = threading.Event()
original_goto = robot.goto_target
def blocked_goto(**kwargs: object) -> None:
original_goto(**kwargs)
move_entered.set()
assert move_release.wait(2)
monkeypatch.setattr(robot, "goto_target", blocked_goto)
worker = threading.Thread(target=runtime.run)
worker.start()
try:
runtime.start("en", "7-9", True)
assert move_entered.wait(1)
before = time.monotonic()
runtime.stop()
assert time.monotonic() - before < 0.25
move_release.set()
wait_for(lambda: runtime.motion.status()["motor_state"] == "asleep")
assert len(robot.moves) == 1
assert robot.media.frames == 0
assert runtime.snapshot()["camera_active"] is False
finally:
move_release.set()
stop_event.set()
worker.join(timeout=2)
assert not worker.is_alive()
def test_app_shutdown_during_search_cancels_before_frame_and_folds(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
stop_event = threading.Event()
runtime = GameRuntime(robot, stop_event)
monkeypatch.setattr(runtime, "_provider", lambda generation: FakeProvider())
monkeypatch.setattr(runtime, "_speak", lambda provider, text, generation: None)
move_entered = threading.Event()
move_release = threading.Event()
original_goto = robot.goto_target
def blocked_goto(**kwargs: object) -> None:
original_goto(**kwargs)
move_entered.set()
assert move_release.wait(2)
monkeypatch.setattr(robot, "goto_target", blocked_goto)
worker = threading.Thread(target=runtime.run)
worker.start()
runtime.start("en", "7-9", True)
assert move_entered.wait(1)
stop_event.set()
move_release.set()
worker.join(timeout=2)
assert not worker.is_alive()
assert len(robot.moves) == 1
assert robot.media.frames == 0
assert runtime.snapshot()["camera_active"] is False
assert runtime.motion.status()["motor_state"] == "asleep"
assert robot.motor_mode == "disabled"
def test_fold_failure_preserves_torque_and_reports_sanitized_error() -> None:
robot = FakeRobot()
robot.motor_mode = "enabled"
robot.pose = MotionOwner.INIT_HEAD_POSE.copy()
robot.fold_error = RuntimeError("private servo diagnostics")
runtime = GameRuntime(robot, threading.Event())
with pytest.raises(RuntimeError, match="private servo diagnostics"):
runtime.motion.fold_and_disable()
assert robot.motor_mode == "enabled"
assert "disable_motors" not in robot.operations
status = runtime.motion.status()
assert status["motor_state"] == "error"
assert "private servo diagnostics" not in str(status)
def test_disable_failure_is_truthful_and_only_attempted_after_verified_fold() -> None:
robot = FakeRobot()
robot.motor_mode = "enabled"
robot.disable_error = RuntimeError("daemon detail")
runtime = GameRuntime(robot, threading.Event())
with pytest.raises(RuntimeError, match="daemon detail"):
runtime.motion.fold_and_disable()
assert np.allclose(robot.pose, MotionOwner.SLEEP_HEAD_POSE)
assert robot.operations == ["goto_sleep", "disable_motors"]
assert runtime.motion.status() == {
"motor_state": "error",
"motor_message": "Reachy is folded, but motor shutdown was not confirmed.",
}
def test_stop_is_prompt_while_provider_call_is_blocked_and_discards_late_result() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
entered = threading.Event()
release = threading.Event()
accepted: list[object | None] = []
def blocked_provider_result() -> object:
entered.set()
assert release.wait(2)
return object()
caller = threading.Thread(
target=lambda: accepted.append(runtime._call_if_current(generation, blocked_provider_result))
)
caller.start()
assert entered.wait(1)
before = time.monotonic()
stopped = runtime.stop()
latency = time.monotonic() - before
assert latency < 0.25
assert stopped["camera_active"] is False
caller.join(timeout=0.1)
assert not caller.is_alive(), "the local operation wrapper did not complete as cancelled"
assert accepted == [None]
assert runtime._active_leases == {}
release.set()
caller.join(timeout=2)
assert not caller.is_alive()
assert accepted == [None]
def test_stop_synchronously_silences_active_tts_before_return() -> None:
robot = FakeRobot()
active = threading.Event()
robot.media.audio = PositiveAudio()
original_start = robot.media.start_playing
original_stop = robot.media.stop_playing
robot.media.start_playing = lambda: (original_start(), active.set()) # type: ignore[method-assign]
robot.media.stop_playing = lambda: (original_stop(), active.clear()) # type: ignore[method-assign]
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(16_000, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
speaker = threading.Thread(
target=runtime._speak,
args=(SpeechProvider(), "hello", generation), # type: ignore[arg-type]
)
speaker.start()
assert active.wait(1)
runtime.stop()
assert not active.is_set()
speaker.join(timeout=2)
assert not speaker.is_alive()
def test_consent_revoke_is_prompt_during_blocked_capture_and_discards_late_jpeg() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
entered = threading.Event()
release = threading.Event()
captured: list[bytes | None] = []
def blocked_capture() -> bytes:
entered.set()
assert release.wait(2)
return b"private-jpeg"
robot.media.get_frame_jpeg = blocked_capture # type: ignore[method-assign]
caller = threading.Thread(
target=lambda: captured.append(runtime.motion._capture_frame(generation, "search"))
)
caller.start()
assert entered.wait(1)
before = time.monotonic()
runtime.stop("camera_disabled")
assert time.monotonic() - before < 0.25
caller.join(timeout=0.1)
assert not caller.is_alive(), "the local capture wrapper did not complete as cancelled"
assert captured == [None]
assert runtime._active_leases == {}
release.set()
caller.join(timeout=2)
assert not caller.is_alive()
assert captured == [None]
def test_stop_between_wake_unlock_and_return_revokes_pending_success(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
completion_entered = threading.Event()
completion_release = threading.Event()
checks = 0
original = runtime.motion._lease_is_current
def pause_post_unlock_completion(lease) -> bool: # type: ignore[no-untyped-def]
nonlocal checks
checks += 1
current = original(lease)
if checks == 2:
completion_entered.set()
assert completion_release.wait(2)
return current
monkeypatch.setattr(runtime.motion, "_lease_is_current", pause_post_unlock_completion)
monkeypatch.setattr(runtime.motion, "_wait_for_pose", lambda *args, **kwargs: True)
returned: list[bool] = []
caller = threading.Thread(target=lambda: returned.append(runtime.motion.wake(generation)))
caller.start()
assert completion_entered.wait(1)
stopped: list[dict[str, object]] = []
stop_done = threading.Event()
stopper = threading.Thread(target=lambda: (stopped.append(runtime.stop()), stop_done.set()))
stopper.start()
assert not stop_done.wait(0.05)
completion_release.set()
stopper.join(timeout=2)
caller.join(timeout=2)
assert not stopper.is_alive()
assert not caller.is_alive()
assert stopped[0]["motor_state"] == "asleep"
assert returned == [False]
assert runtime.motion.status()["motor_state"] != "awake"
def test_revoke_between_frame_unlock_and_return_discards_pending_bytes(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
completion_entered = threading.Event()
completion_release = threading.Event()
checks = 0
original = runtime.motion._lease_is_current
def pause_post_unlock_completion(lease) -> bool: # type: ignore[no-untyped-def]
nonlocal checks
checks += 1
if checks == 2:
completion_entered.set()
assert completion_release.wait(2)
return original(lease)
monkeypatch.setattr(runtime.motion, "_lease_is_current", pause_post_unlock_completion)
returned: list[bytes | None] = []
caller = threading.Thread(
target=lambda: returned.append(runtime.motion._capture_frame(generation, "search"))
)
caller.start()
assert completion_entered.wait(1)
stop_done = threading.Event()
stopper = threading.Thread(target=lambda: (runtime.stop("camera_disabled"), stop_done.set()))
stopper.start()
assert not stop_done.wait(0.05)
completion_release.set()
caller.join(timeout=2)
stopper.join(timeout=2)
assert not caller.is_alive()
assert not stopper.is_alive()
assert returned == [None]
def test_stop_is_idempotent_while_provider_never_returns() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
entered = threading.Event()
never_returns = threading.Event()
returned: list[object | None] = []
def blocked_forever() -> object:
entered.set()
never_returns.wait()
return object()
caller = threading.Thread(
target=lambda: returned.append(runtime._call_if_current(generation, blocked_forever))
)
caller.start()
assert entered.wait(1)
for _ in range(2):
before = time.monotonic()
stopped = runtime.stop()
assert time.monotonic() - before < 0.25
assert stopped["camera_active"] is False
caller.join(timeout=0.1)
assert not caller.is_alive()
assert returned == [None]
assert runtime._active_leases == {}
def test_late_provider_exception_after_stop_is_discarded() -> None:
runtime = GameRuntime(FakeRobot(), threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
entered = threading.Event()
release = threading.Event()
outcome: list[object | None] = []
def late_failure() -> object:
entered.set()
assert release.wait(2)
raise RuntimeError("private provider failure")
caller = threading.Thread(
target=lambda: outcome.append(runtime._call_if_current(generation, late_failure))
)
caller.start()
assert entered.wait(1)
runtime.stop()
caller.join(timeout=0.1)
assert not caller.is_alive()
assert outcome == [None]
assert runtime._active_leases == {}
release.set()
def test_runtime_nonce_makes_provider_session_unique_across_process_restarts(monkeypatch) -> None: # type: ignore[no-untyped-def]
session_ids: list[str] = []
class SessionProvider:
def __init__(self, _config: object, *, session_id: str) -> None:
session_ids.append(session_id)
def cancel_local(self) -> bool:
return True
monkeypatch.setattr(runtime_module, "load_config", lambda: object())
monkeypatch.setattr(runtime_module, "ProviderClient", SessionProvider)
for _ in range(2):
runtime = GameRuntime(FakeRobot(), threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
assert runtime._provider(generation) is not None
assert len(session_ids) == 2
assert session_ids[0] != session_ids[1]
assert all(len(value) == 32 for value in session_ids)
def test_stop_waits_for_explicit_completion_after_lease_release(monkeypatch) -> None: # type: ignore[no-untyped-def]
runtime = GameRuntime(FakeRobot(), threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
completion_entered = threading.Event()
completion_release = threading.Event()
original_complete = runtime._complete_lease
def paused_completion(lease) -> None: # type: ignore[no-untyped-def]
completion_entered.set()
assert completion_release.wait(2)
original_complete(lease)
monkeypatch.setattr(runtime, "_complete_lease", paused_completion)
caller = threading.Thread(target=runtime._call_if_current, args=(generation, lambda: object()))
caller.start()
assert completion_entered.wait(1)
stop_done = threading.Event()
stopper = threading.Thread(target=lambda: (runtime.stop(), stop_done.set()))
stopper.start()
assert not stop_done.wait(0.05)
completion_release.set()
caller.join(timeout=2)
stopper.join(timeout=2)
assert not caller.is_alive()
assert not stopper.is_alive()
@pytest.mark.parametrize("boundary", ["jpeg", "provider_result", "provider_object", "wake", "motion"])
def test_stop_cannot_complete_before_true_public_return_boundary(monkeypatch, boundary: str) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
completion_acknowledged = threading.Event()
allow_public_return = threading.Event()
original_complete = runtime._complete_lease
def pause_after_real_completion_acknowledgement(lease) -> None: # type: ignore[no-untyped-def]
original_complete(lease)
completion_acknowledged.set()
assert allow_public_return.wait(2)
monkeypatch.setattr(runtime, "_complete_lease", pause_after_real_completion_acknowledgement)
runtime.motion._complete_runtime_lease = pause_after_real_completion_acknowledgement
sentinel = object()
class CachedProvider:
def cancel_local(self) -> bool:
return False
runtime._providers[generation] = CachedProvider() # type: ignore[assignment]
monkeypatch.setattr(runtime.motion, "_wait_for_pose", lambda *args, **kwargs: True)
def invoke() -> object:
if boundary == "jpeg":
return runtime.motion._capture_frame(generation, "search")
if boundary == "provider_result":
return runtime._call_if_current(generation, lambda: sentinel)
if boundary == "provider_object":
return runtime._provider(generation)
if boundary == "wake":
return runtime.motion.wake(generation)
return runtime.motion.neutral(generation)
returned: list[object] = []
caller = threading.Thread(target=lambda: returned.append(invoke()))
caller.start()
assert completion_acknowledged.wait(1)
stop_done = threading.Event()
stop_outcome: list[dict[str, object]] = []
stopper = threading.Thread(target=lambda: (stop_outcome.append(runtime.stop()), stop_done.set()))
stopper.start()
assert not stop_done.wait(0.05), "Stop acknowledged before the public operation returned"
allow_public_return.set()
caller.join(timeout=2)
stopper.join(timeout=2)
assert not caller.is_alive()
assert not stopper.is_alive()
assert len(returned) == 1
assert stop_outcome[0]["state"] == GameState.STOPPED
@pytest.mark.parametrize("failure", ["blocked", "throws"])
@pytest.mark.parametrize("pipeline_attribute", ["_pipeline", "_pipeline_record"])
def test_stop_uses_backend_owned_failsafe_and_returns_only_after_playback_is_quiescent(
failure: str,
pipeline_attribute: str,
monkeypatch,
) -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
runtime._ready.set()
media_entered = threading.Event()
media_release = threading.Event()
playback_active = threading.Event()
playback_active.set()
class PipelineState(StrEnum):
NULL = "null"
PLAYING = "playing"
class Pipeline:
def __init__(self) -> None:
self.state = PipelineState.PLAYING
def set_state(self, state: str) -> None:
self.state = state
if state == PipelineState.NULL:
playback_active.clear()
def get_state(self, timeout: int) -> object:
assert timeout >= 0
return type("StateChange", (), {"state": self.state})()
robot.media.audio = type("Audio", (), {pipeline_attribute: Pipeline()})()
if pipeline_attribute == "_pipeline_record":
monkeypatch.setattr(runtime, "_clear_webrtc_incoming_audio", lambda audio: True)
def blocked_stop_playing() -> None:
media_entered.set()
media_release.wait()
playback_active.clear()
if failure == "blocked":
robot.media.stop_playing = blocked_stop_playing # type: ignore[method-assign]
else:
robot.media.stop_playing = lambda: (_ for _ in ()).throw(RuntimeError("backend detail")) # type: ignore[method-assign]
before = time.monotonic()
try:
stopped = runtime.stop()
assert stopped["state"] == GameState.STOPPED
assert not playback_active.is_set()
if failure == "blocked":
with pytest.raises(RuntimeError, match="cleanup is pending"):
runtime.start("en", "7-9", True)
finally:
media_release.set()
wait_for(lambda: not runtime._media_callbacks_pending.is_set())
wait_for(lambda: runtime._stop_cycle is not None and runtime._stop_cycle.restart_safe.is_set())
if pipeline_attribute == "_pipeline_record":
with pytest.raises(RuntimeError, match="cleanup is pending"):
runtime.start("en", "7-9", True)
else:
assert runtime.start("en", "7-9", True)["state"] == GameState.SEARCHING
assert failure == "throws" or media_entered.is_set()
assert time.monotonic() - before < 0.25
@pytest.mark.parametrize("failure", ["blocked", "throws"])
def test_speech_is_refused_before_launch_when_only_unacknowledged_public_clear_exists(failure: str) -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
playback_active = threading.Event()
clear_calls: list[int] = []
class Audio:
def clear_player(self) -> None:
clear_calls.append(1)
# Reachy SDK 1.9 swallows a failed daemon flush and returns None.
# Accepted output would therefore remain active.
robot.media.audio = Audio()
original_push = robot.media.push_audio_sample
robot.media.push_audio_sample = lambda audio: (original_push(audio), playback_active.set()) # type: ignore[method-assign]
stop_calls: list[int] = []
def blocked_stop_playing() -> None:
stop_calls.append(1)
threading.Event().wait(2)
if failure == "blocked":
robot.media.stop_playing = blocked_stop_playing # type: ignore[method-assign]
else:
robot.media.stop_playing = lambda: (_ for _ in ()).throw(RuntimeError("backend detail")) # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
with pytest.raises(PrivacySafeRuntimeError, match="media_quiescence_unavailable"):
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
assert robot.media.pushed == []
assert not playback_active.is_set()
assert clear_calls == []
assert stop_calls == []
def test_speech_is_rejected_before_start_when_backend_has_no_positive_quiescence_path() -> None:
robot = FakeRobot()
robot.media.audio = object()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
started = threading.Event()
robot.media.start_playing = started.set # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
with pytest.raises(PrivacySafeRuntimeError, match="media_quiescence_unavailable"):
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
assert not started.is_set()
def test_stop_retains_admitted_pipeline_when_backend_reference_disappears() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
playback_active = threading.Event()
class PipelineState(StrEnum):
NULL = "null"
PLAYING = "playing"
class Pipeline:
def __init__(self) -> None:
self.state = PipelineState.PLAYING
def set_state(self, state: PipelineState) -> None:
self.state = state
if state == PipelineState.NULL:
playback_active.clear()
def get_state(self, timeout: int) -> object:
assert timeout >= 0
return type("StateChange", (), {"state": self.state})()
audio = type("Audio", (), {})()
audio._pipeline = Pipeline()
robot.media.audio = audio
original_push = robot.media.push_audio_sample
robot.media.push_audio_sample = lambda samples: (original_push(samples), playback_active.set()) # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
assert playback_active.is_set()
del audio._pipeline
stopped = runtime.stop()
assert stopped["state"] == GameState.STOPPED
assert not playback_active.is_set()
def test_revocation_after_start_before_publication_does_not_reenter_audio_lock() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
playback_active = threading.Event()
playback_started = threading.Event()
speaker_ident: list[int] = []
original_is_current = runtime._lease_is_current
class Pipeline:
def __init__(self) -> None:
self.state = PositivePipelineState.NULL
def set_state(self, state: PositivePipelineState) -> None:
self.state = state
if state == PositivePipelineState.NULL:
playback_active.clear()
def get_state(self, timeout: int) -> object:
assert timeout >= 0
return type("StateChange", (), {"state": self.state})()
pipeline = Pipeline()
robot.media.audio = type("Audio", (), {"_pipeline": pipeline})()
def start_playing() -> None:
pipeline.state = PositivePipelineState.PLAYING
playback_active.set()
playback_started.set()
robot.media.start_playing = start_playing # type: ignore[method-assign]
def revoke_at_locked_publication(lease) -> bool: # type: ignore[no-untyped-def]
current = original_is_current(lease)
if (
current
and speaker_ident == [threading.get_ident()]
and runtime._audio_lock.locked()
and playback_active.is_set()
):
runtime._revocation_requested.set()
if lease.revoked is not None:
lease.revoked.set()
return False
return current
runtime._lease_is_current = revoke_at_locked_publication # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
def speak() -> None:
speaker_ident.append(threading.get_ident())
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
speech_done.set()
speech_done = threading.Event()
speaker = threading.Thread(
target=speak,
daemon=True,
)
speaker.start()
wait_for(playback_started.is_set)
stop_done = threading.Event()
stopper = threading.Thread(target=lambda: (runtime.stop(), stop_done.set()), daemon=True)
stopper.start()
assert speech_done.wait(1), "revoked speech deadlocked while recursively acquiring _audio_lock"
assert stop_done.wait(1), "user Stop could not join the revoked playback cleanup"
assert not playback_active.is_set()
def test_start_playing_replacement_binds_silence_to_actual_transport() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
playback_active = threading.Event()
class Pipeline(PositiveAudio.Pipeline):
def set_state(self, state: PositivePipelineState) -> None:
super().set_state(state)
if state == PositivePipelineState.NULL:
playback_active.clear()
stale_pipeline = Pipeline()
replacement_pipeline = Pipeline()
replacement_pipeline.state = PositivePipelineState.NULL
audio = type("Audio", (), {"_pipeline": stale_pipeline})()
robot.media.audio = audio
def start_playing() -> None:
audio._pipeline = replacement_pipeline
def push_audio_sample(samples: np.ndarray) -> None:
robot.media.pushed.append(samples)
replacement_pipeline.state = PositivePipelineState.PLAYING
playback_active.set()
robot.media.start_playing = start_playing # type: ignore[method-assign]
robot.media.push_audio_sample = push_audio_sample # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
runtime._speak(SpeechProvider(), "Hello", generation) # type: ignore[arg-type]
assert playback_active.is_set()
stopped = runtime.stop()
assert stopped["state"] == GameState.STOPPED
assert replacement_pipeline.state == PositivePipelineState.NULL
assert not playback_active.is_set()
def test_blocked_replacing_start_makes_stop_return_bounded_fail_closed() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
start_entered = threading.Event()
start_release = threading.Event()
playback_active = threading.Event()
class Pipeline(PositiveAudio.Pipeline):
def set_state(self, state: PositivePipelineState) -> None:
super().set_state(state)
if state == PositivePipelineState.NULL:
playback_active.clear()
audio = type("Audio", (), {"_pipeline": Pipeline()})()
replacement = Pipeline()
replacement.state = PositivePipelineState.NULL
robot.media.audio = audio
def start_playing() -> None:
start_entered.set()
assert start_release.wait(2)
audio._pipeline = replacement
replacement.state = PositivePipelineState.PLAYING
playback_active.set()
robot.media.start_playing = start_playing # type: ignore[method-assign]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16_000)
wav_file.writeframes(np.zeros(160, dtype="<i2").tobytes())
class SpeechProvider:
def synthesize_wav(self, text: str, *, language: str) -> bytes:
return buffer.getvalue()
speaker = threading.Thread(
target=lambda: runtime._speak(SpeechProvider(), "Hello", generation), # type: ignore[arg-type]
daemon=True,
)
speaker.start()
assert start_entered.wait(1)
before = time.monotonic()
with pytest.raises(PrivacySafeRuntimeError, match="media_start_pending"):
runtime.stop()
assert time.monotonic() - before < 0.5
start_release.set()
speaker.join(timeout=2)
assert not speaker.is_alive()
assert replacement.state == PositivePipelineState.NULL
assert not playback_active.is_set()
def test_concurrent_stop_calls_join_one_media_cleanup_outcome() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
media_entered = threading.Event()
media_release = threading.Event()
calls = 0
def blocked_stop_playing() -> None:
nonlocal calls
calls += 1
media_entered.set()
assert media_release.wait(2)
robot.media.stop_playing = blocked_stop_playing # type: ignore[method-assign]
outcomes: list[dict[str, object]] = []
first = threading.Thread(target=lambda: outcomes.append(runtime.stop()))
second_done = threading.Event()
second = threading.Thread(target=lambda: (outcomes.append(runtime.stop()), second_done.set()))
first.start()
assert media_entered.wait(1)
second.start()
assert not second_done.wait(0.05)
media_release.set()
first.join(timeout=2)
second.join(timeout=2)
assert not first.is_alive()
assert not second.is_alive()
assert calls == 1
assert len(outcomes) == 2
assert all(outcome["state"] == GameState.STOPPED for outcome in outcomes)
def test_start_is_excluded_until_returned_stop_cycle_is_no_longer_joinable(monkeypatch) -> None: # type: ignore[no-untyped-def]
runtime = GameRuntime(FakeRobot(), threading.Event())
runtime._ready.set()
pending_cycles = []
def defer_cycle_acknowledgement(cycle) -> None: # type: ignore[no-untyped-def]
pending_cycles.append(cycle)
monkeypatch.setattr(runtime, "_ack_stop_after_public_return", defer_cycle_acknowledgement)
first_stop = runtime.stop()
assert first_stop["state"] == GameState.STOPPED
assert len(pending_cycles) == 1
assert not pending_cycles[0].done.is_set()
with pytest.raises(RuntimeError, match="cleanup is pending"):
runtime.start("en", "7-9", True)
pending_cycles[0].done.set()
pending_cycles[0].restart_safe.set()
restarted = runtime.start("en", "7-9", True)
assert restarted["state"] == GameState.SEARCHING
assert restarted["camera_active"] is True
second_stop = runtime.stop()
actual = runtime.snapshot()
assert second_stop["generation"] > restarted["generation"]
assert second_stop["state"] == GameState.STOPPED
assert actual["state"] == GameState.STOPPED
assert actual["camera_active"] is False
def test_start_is_excluded_until_joined_stop_caller_has_publicly_returned(monkeypatch) -> None: # type: ignore[no-untyped-def]
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
runtime._ready.set()
media_entered = threading.Event()
media_release = threading.Event()
joined_acknowledgements = []
def blocked_stop_playing() -> None:
media_entered.set()
assert media_release.wait(2)
def acknowledge_leader(cycle) -> None: # type: ignore[no-untyped-def]
cycle.done.set()
runtime._release_stop_public_caller(cycle)
def defer_joined_acknowledgement(cycle, boundary) -> None: # type: ignore[no-untyped-def]
joined_acknowledgements.append(cycle)
robot.media.stop_playing = blocked_stop_playing # type: ignore[method-assign]
monkeypatch.setattr(runtime, "_ack_stop_after_public_return", acknowledge_leader)
monkeypatch.setattr(runtime, "_ack_stop_caller_after_public_return", defer_joined_acknowledgement)
outcomes = []
leader = threading.Thread(target=lambda: outcomes.append(runtime.stop()))
joiner = threading.Thread(target=lambda: outcomes.append(runtime.stop()))
leader.start()
assert media_entered.wait(1)
joiner.start()
media_release.set()
leader.join(timeout=2)
joiner.join(timeout=2)
assert not leader.is_alive()
assert not joiner.is_alive()
assert len(outcomes) == 2
assert len(joined_acknowledgements) == 1
cycle = joined_acknowledgements[0]
assert cycle.public_callers == 1
assert not cycle.restart_safe.is_set()
with pytest.raises(RuntimeError, match="cleanup is pending"):
runtime.start("en", "7-9", True)
runtime._release_stop_public_caller(cycle)
assert runtime.start("en", "7-9", True)["state"] == GameState.SEARCHING
def test_concurrent_stop_calls_share_one_blocked_media_failure() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
media_entered = threading.Event()
media_release = threading.Event()
calls = 0
def blocked_stop_playing() -> None:
nonlocal calls
calls += 1
media_entered.set()
media_release.wait()
def capture_stop_error(diagnostics: list[str]) -> None:
try:
runtime.stop()
except PrivacySafeRuntimeError as exc:
diagnostics.append(exc.diagnostic_code)
robot.media.stop_playing = blocked_stop_playing # type: ignore[method-assign]
diagnostics: list[str] = []
first = threading.Thread(target=capture_stop_error, args=(diagnostics,))
second = threading.Thread(target=capture_stop_error, args=(diagnostics,))
first.start()
assert media_entered.wait(1)
second.start()
first.join(timeout=1)
second.join(timeout=1)
try:
assert not first.is_alive()
assert not second.is_alive()
assert calls == 1
assert diagnostics == ["media_stop_timeout", "media_stop_timeout"]
finally:
media_release.set()
def test_reentrant_stop_during_media_cleanup_shares_fail_closed_outcome() -> None:
robot = FakeRobot()
runtime = GameRuntime(robot, threading.Event())
nested_diagnostics: list[str] = []
def reentrant_stop_playing() -> None:
try:
runtime.stop()
except PrivacySafeRuntimeError as exc:
nested_diagnostics.append(exc.diagnostic_code)
robot.media.stop_playing = reentrant_stop_playing # type: ignore[method-assign]
with pytest.raises(PrivacySafeRuntimeError, match="reentrant_media_stop") as outer:
runtime.stop()
assert nested_diagnostics == [outer.value.diagnostic_code]
def test_revoked_return_boundary_timeout_is_bounded_and_fail_closed(monkeypatch) -> None: # type: ignore[no-untyped-def]
runtime = GameRuntime(FakeRobot(), threading.Event())
runtime._ready.set()
generation = runtime.machine.start(language="en", age_band="7-9", camera_consent=True)
completion_acknowledged = threading.Event()
allow_public_return = threading.Event()
original_complete = runtime._complete_lease
def blocked_after_completion_acknowledgement(lease) -> None: # type: ignore[no-untyped-def]
original_complete(lease)
completion_acknowledged.set()
allow_public_return.wait()
monkeypatch.setattr(runtime, "_complete_lease", blocked_after_completion_acknowledgement)
caller = threading.Thread(target=runtime._call_if_current, args=(generation, lambda: object()))
caller.start()
assert completion_acknowledged.wait(1)
before = time.monotonic()
try:
with pytest.raises(PrivacySafeRuntimeError, match="local_completion_timeout"):
runtime.stop()
assert caller.is_alive()
assert time.monotonic() - before < 0.25
assert runtime.machine.state == GameState.STOPPED
with pytest.raises(RuntimeError, match="cleanup is pending"):
runtime.start("en", "7-9", True)
finally:
allow_public_return.set()
caller.join(timeout=2)
assert not caller.is_alive()