Spaces:
Running
Running
| 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() | |
| 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 | |
| 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 | |
| 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() | |