"""Deterministic shutdown races without microphone hardware or model loading.""" import importlib import queue import sys import threading import time from collections.abc import Generator, Iterator from contextlib import contextmanager, nullcontext from functools import partial from pathlib import Path from types import ModuleType from typing import Callable from unittest.mock import MagicMock, Mock import pytest from vocalinux.auto_pause_monitor import AutoPauseMonitor from vocalinux.common_types import RecognitionState from vocalinux.speech_recognition import recognition_manager as rm @pytest.fixture def manager(monkeypatch: pytest.MonkeyPatch) -> Generator[rm.SpeechRecognitionManager, None, None]: """Use real threads, mocked engines and short, independently adjustable budgets.""" monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_vosk", lambda self: None) monkeypatch.setattr(rm, "load_silero_vad", lambda: None) monkeypatch.setattr(rm.SpeechRecognitionManager, "_download_selected_model_if_missing", Mock()) monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy) for name in ("play_start_sound", "play_stop_sound", "play_error_sound"): monkeypatch.setattr(rm, name, Mock()) monkeypatch.setattr(rm, "_CAPTURE_JOIN_SECONDS", 0.02) monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 0.02) monkeypatch.setattr(rm, "_RECOGNITION_FINAL_JOIN_SECONDS", 0.02) monkeypatch.setattr(rm, "_FINAL_BUFFER_SECONDS", 0.02) # Restore this patch before the autouse notification patch exits. with monkeypatch.context() as patcher: patcher.setattr(rm, "_show_notification", Mock()) instance = rm.SpeechRecognitionManager(engine="vosk") instance.model = object() instance._model_initialized = True instance.stop_sound_guard_ms = 0 yield instance instance._session.cancel.set() instance.should_record = False instance._capture_finished.set() instance._signal_recognition_stop() for worker in (instance.audio_thread, instance.recognition_thread): if worker is not None: worker.join(2) assert not worker.is_alive() def test_blocked_read_leaves_lock_available_and_preserves_final_speech( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: entered = threading.Event() closed_on = [] transcribed = [] source = Mock(requires_pyaudio=False, sample_rate=16000, channels=1, audio=None) def read(should_continue: Callable[[], bool]) -> bytes: entered.set() assert manager._session.cancel.wait(2) assert not should_continue() return b"late samples" source.read_chunk.side_effect = read source.close.side_effect = lambda: closed_on.append(threading.current_thread()) monkeypatch.setattr(manager, "_new_capture_source", lambda: source) monkeypatch.setattr( manager, "_process_audio_buffer", lambda audio, language: transcribed.append(audio) ) monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 1) assert manager.start_recognition() assert entered.wait(1) try: assert manager._buffer_lock.acquire(timeout=0.1) try: manager.audio_buffer = [b"final word"] manager._recording_segment_has_speech = True finally: manager._buffer_lock.release() manager.stop_recognition() assert transcribed == [[b"final word"]] assert closed_on == [manager.audio_thread] rm.play_stop_sound.assert_called_once() assert manager.audio_buffer == [] assert manager.state == RecognitionState.IDLE finally: manager._session.cancel.set() def test_final_lock_timeout_drops_segment_and_allows_restart( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: manager.state = RecognitionState.LISTENING manager.should_record = True manager.audio_buffer = [b"unavailable tail"] manager._recording_segment_has_speech = True with manager._buffer_lock: started = time.monotonic() manager.stop_recognition() assert time.monotonic() - started < 0.5 assert "dropping final segment" in caplog.text assert manager._segment_queue.get_nowait() is None assert manager.state == RecognitionState.IDLE monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() assert manager.audio_buffer == [] def test_stuck_capture_suppresses_cue_and_blocks_reuse_until_owner_exits( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: entered = threading.Event() release = threading.Event() def capture() -> None: entered.set() assert release.wait(2) monkeypatch.setattr(manager, "_record_audio", capture) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() assert entered.wait(1) old_capture = manager.audio_thread try: manager.stop_recognition() assert manager.state == RecognitionState.IDLE rm.play_stop_sound.assert_not_called() assert not manager.start_recognition() finally: release.set() old_capture.join(1) monkeypatch.setattr(manager, "_record_audio", lambda: None) assert manager.start_recognition() def test_concurrent_and_reentrant_stops_handoff_only_once( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: manager.state = RecognitionState.LISTENING manager.should_record = True manager.audio_buffer = [b"one final segment"] manager._recording_segment_has_speech = True entered = threading.Event() release = threading.Event() waiting = threading.Event() stop = manager._stop_session wait = manager._session.stop_complete.wait def elected(session: rm._CaptureSession) -> None: entered.set() assert release.wait(2) stop(session) def waiter(timeout: float) -> bool: waiting.set() return wait(timeout) monkeypatch.setattr(manager, "_stop_session", elected) monkeypatch.setattr(manager._session.stop_complete, "wait", waiter) manager.state_callbacks.append(lambda state: manager.stop_recognition()) owner = threading.Thread(target=manager.stop_recognition) follower = threading.Thread(target=manager.stop_recognition) try: owner.start() assert entered.wait(1) follower.start() assert waiting.wait(1) finally: release.set() owner.join(1) if follower.ident is not None: follower.join(1) assert not owner.is_alive() and not follower.is_alive() queued = [] while not manager._segment_queue.empty(): item = manager._segment_queue.get_nowait() if item is not None: queued.append(item[0]) assert queued == [[b"one final segment"]] rm.play_stop_sound.assert_called_once() assert manager._session.stop_complete.is_set() @pytest.mark.parametrize("worker_name", ["audio_thread", "recognition_thread"]) def test_worker_can_stop_itself(manager: rm.SpeechRecognitionManager, worker_name: str) -> None: manager.state = RecognitionState.LISTENING manager.should_record = True worker = threading.Thread(target=manager.stop_recognition) setattr(manager, worker_name, worker) worker.start() worker.join(1) assert not worker.is_alive() assert manager.state == RecognitionState.IDLE @pytest.mark.parametrize("operation", ["unload_model", "reinitialize_after_resume", "reconfigure"]) def test_reload_callers_report_timeout_before_engine_handoff( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, operation: str ) -> None: manager.buffer_during_reload = True manager._idle_unloaded = True manager._model_initialized = False original_model = manager.model original_timeout = manager.silence_timeout settings = ( {"silence_timeout": 1.25, "audio_device_name": "Configured microphone"} if operation == "reconfigure" else {} ) if operation == "unload_model": settings = {"reason": "manual"} loading = threading.Event() release = threading.Event() def load() -> None: loading.set() assert release.wait(2) manager._model_initialized = True monkeypatch.setattr(manager, "_init_selected_engine", load) monkeypatch.setattr(manager, "_record_audio", lambda: None) assert manager.start_recognition() assert loading.wait(1) try: started = time.monotonic() with pytest.raises(TimeoutError, match="still loading"): getattr(manager, operation)(**settings) assert time.monotonic() - started < 0.5 assert manager.model is original_model assert manager.silence_timeout == original_timeout assert manager._buffered_reload_session assert manager._cancel_buffered_session.is_set() assert not manager.start_recognition() finally: release.set() manager.recognition_thread.join(1) assert not manager._buffered_reload_session assert manager.state == RecognitionState.IDLE if operation == "reconfigure": manager.reconfigure(**settings) assert manager.silence_timeout == 1.25 assert manager.start_recognition() class _AutoPauseFlow: """Exercise production monitor/tray callbacks with gated model workers.""" def __init__( self, manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: # The fixture isolates this import from other tests' mocked GLib modules. from vocalinux.ui.tray_indicator import TrayIndicator self.manager = manager self.loading = threading.Event() self.release_load = threading.Event() self.unload_started = threading.Event() self.release_unload = threading.Event() self.resume_started = threading.Event() self.release_resume = threading.Event() self.unload_threads: list[threading.Thread] = [] self.resume_threads: list[threading.Thread] = [] self.cleanup_during_reload: list[bool] = [] self.resumed_model = object() self.backend = Mock() self.http_session = Mock() self.transcribe = Mock() self.reinit = Mock(side_effect=self._reinit) manager.buffer_during_reload = True manager._idle_unloaded = True manager._model_initialized = False manager._faster_whisper_engine = self.backend manager._http_session = self.http_session def load() -> None: self.loading.set() assert self.release_load.wait(3) manager.model = object() manager._model_initialized = True unload_after_reload = manager._unload_after_reload resume_after_reload = manager._resume_after_reload def deferred_unload(worker: threading.Thread, request: object) -> None: self.unload_threads.append(threading.current_thread()) self.unload_started.set() assert self.release_unload.wait(3) unload_after_reload(worker, request) def deferred_resume(worker: threading.Thread, request: object) -> None: self.resume_threads.append(threading.current_thread()) self.resume_started.set() assert self.release_resume.wait(3) resume_after_reload(worker, request) monkeypatch.setattr(manager, "_init_selected_engine", load) monkeypatch.setattr(manager, "_init_vosk", self.reinit) monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_unload_after_reload", deferred_unload) monkeypatch.setattr(manager, "_resume_after_reload", deferred_resume) monkeypatch.setattr(manager, "_process_audio_buffer", self.transcribe) self.tray = TrayIndicator.__new__(TrayIndicator) self.tray.speech_engine = manager self.tray._model_keepalive = Mock() # The suspend path releases hotkey listeners before the machine sleeps. self.tray.shortcut_manager = Mock() self.tray._language_shortcut_managers = [] self.wake_callbacks: list[Callable[[], bool]] = [] def schedule(seconds: int, callback: Callable[..., bool], *args: object) -> int: assert seconds == 2 assert callback == self.tray._reinit_speech_after_resume self.wake_callbacks.append(partial(callback, *args)) return len(self.wake_callbacks) monkeypatch.setattr( sys.modules[TrayIndicator.__module__].GLib, "timeout_add_seconds", schedule ) monkeypatch.setattr(self.tray, "_external_activation_active", lambda: True) self.running: set[str] = set() self.monitor = AutoPauseMonitor( get_config=lambda: (True, ["game"], 5), on_pause=self.tray._on_auto_pause, on_resume=self.tray._on_auto_resume, process_snapshot=lambda: self.running, use_glib=False, ) self.tray._auto_pause_monitor = self.monitor self.backend.cleanup.side_effect = lambda: self.cleanup_during_reload.append( self.reload_worker.is_alive() ) def _reinit(self) -> None: self.manager.model = self.resumed_model self.manager._model_initialized = True def poll(self, running: bool) -> None: self.running.clear() if running: self.running.add("game") started = time.monotonic() assert self.monitor.check_once() is running assert time.monotonic() - started < 0.5 assert self.monitor.paused is running def finish_reload(self) -> None: self.release_load.set() self.reload_worker.join(1) assert not self.reload_worker.is_alive() assert not self.manager._buffered_reload_session assert self.manager.state == RecognitionState.IDLE self.transcribe.assert_not_called() def join(self, workers: list[threading.Thread]) -> None: for worker in workers: worker.join(1) assert not worker.is_alive() @contextmanager def _isolated_tray_import() -> Iterator[ModuleType]: """Restore local modules and package bindings, including newly imported dependencies.""" saved_modules = { name: module for name, module in sys.modules.copy().items() if name == "vocalinux" or name.startswith("vocalinux.") } saved_packages = { name: (module, vars(module).copy()) for name, module in saved_modules.items() if isinstance(module, ModuleType) and hasattr(module, "__path__") } try: # A cached tray may be bound to a different test's GLib mock. sys.modules.pop("vocalinux.ui.tray_indicator", None) yield importlib.import_module("vocalinux.ui.tray_indicator") finally: module_names = set(saved_modules) | { name for name in sys.modules.copy() if name.startswith("vocalinux.") } for name in module_names: if name in saved_modules: sys.modules[name] = saved_modules[name] else: sys.modules.pop(name, None) parent_name, _, attribute = name.rpartition(".") if parent_name in saved_packages: parent, attributes = saved_packages[parent_name] if attribute in attributes: setattr(parent, attribute, attributes[attribute]) else: vars(parent).pop(attribute, None) @pytest.fixture def auto_pause_flow( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> Generator[_AutoPauseFlow, None, None]: with _isolated_tray_import(): flow = _AutoPauseFlow(manager, monkeypatch) try: assert manager.start_recognition() flow.reload_worker = manager.recognition_thread assert flow.loading.wait(1) yield flow finally: flow.release_load.set() flow.release_unload.set() flow.release_resume.set() flow.join( [ worker for worker in (manager.audio_thread, manager.recognition_thread) if worker is not None ] + flow.unload_threads + flow.resume_threads ) @pytest.mark.parametrize("cached", [False, True]) @pytest.mark.parametrize("package_attribute", [False, True]) @pytest.mark.parametrize("fail", [False, True]) def test_tray_import_restores_modules_and_package_attributes( monkeypatch: pytest.MonkeyPatch, cached: bool, package_attribute: bool, fail: bool ) -> None: """An earlier GLib mock must not survive an isolated flow through import caching.""" import vocalinux.ui tray_name = "vocalinux.ui.tray_indicator" settings_name = "vocalinux.ui.settings_dialog" # Model a cached tray and a freshly imported dependency independently of # their package attributes; those bindings can be absent or out of sync. old_tray = ModuleType(tray_name) old_tray.GLib = MagicMock() old_attribute = ModuleType(tray_name) if cached: monkeypatch.setitem(sys.modules, tray_name, old_tray) else: monkeypatch.delitem(sys.modules, tray_name, raising=False) monkeypatch.delitem(sys.modules, settings_name, raising=False) for attribute in ("tray_indicator", "settings_dialog"): if package_attribute: monkeypatch.setattr(vocalinux.ui, attribute, old_attribute, raising=False) else: monkeypatch.delattr(vocalinux.ui, attribute, raising=False) for _ in range(2): glib = MagicMock() monkeypatch.setattr(sys.modules["gi.repository"], "GLib", glib) with pytest.raises(RuntimeError, match="flow failed") if fail else nullcontext(): with _isolated_tray_import() as tray: assert tray is not old_tray assert tray.GLib is glib assert vocalinux.ui.tray_indicator is tray assert vocalinux.ui.settings_dialog is sys.modules[settings_name] assert tray.TrayIndicator._reinit_keyboard_fallback(Mock()) is glib.SOURCE_REMOVE if fail: raise RuntimeError("flow failed") if cached: assert sys.modules[tray_name] is old_tray else: assert tray_name not in sys.modules assert settings_name not in sys.modules for attribute in ("tray_indicator", "settings_dialog"): if package_attribute: assert vars(vocalinux.ui)[attribute] is old_attribute else: assert attribute not in vars(vocalinux.ui) @pytest.mark.parametrize( "resume", ["after_unload", "during_reload", "during_cancel", "before_deferred_unload"] ) def test_monitor_auto_pause_survives_reload_timeout( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, resume: str, ) -> None: """A single monitor resume recovers automatically, including after timeout.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) assert flow.reload_worker.is_alive() assert manager.is_auto_paused assert not manager.is_idle_unloaded assert not manager.start_recognition() flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() flow.poll(True) assert len(flow.unload_threads) == 1 if resume == "during_reload": flow.poll(False) assert flow.resume_started.wait(1) assert manager.is_auto_paused assert not manager.start_recognition() # Allow the real deferred callback to wait for the captured worker. flow.release_resume.set() elif resume == "during_cancel": cancel_reload = manager._cancel_reload_recording def cancel_after_release() -> None: assert flow.reload_worker.is_alive() flow.release_load.set() cancel_reload() monkeypatch.setattr(manager, "_cancel_reload_recording", cancel_after_release) monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 1) flow.poll(False) assert not flow.resume_started.is_set() flow.finish_reload() if resume == "after_unload": flow.release_unload.set() flow.join(flow.unload_threads) assert manager.model is None assert manager.is_auto_paused assert not manager.is_idle_unloaded assert not manager.start_recognition() flow.poll(True) assert len(flow.unload_threads) == 1 flow.poll(False) elif resume == "before_deferred_unload": flow.poll(False) elif resume == "during_reload": flow.join(flow.resume_threads) # No further monitor transition or manual resume is needed. flow.poll(False) assert len(flow.resume_threads) == 1 assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused assert not manager.is_idle_unloaded monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() # Let the stale unload arrive after resumed dictation has started. flow.release_unload.set() flow.join(flow.unload_threads) assert manager.model is flow.resumed_model assert manager.state == RecognitionState.LISTENING assert manager.should_record flow.reinit.assert_called_once_with() flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] @pytest.mark.parametrize("supersede", ["pause", "resume"]) def test_new_monitor_transition_invalidates_deferred_resume( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, supersede: str ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) assert manager.is_auto_paused flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) assert len(flow.unload_threads) == 2 flow.finish_reload() if supersede == "resume": flow.poll(False) assert manager.model is flow.resumed_model assert not manager.is_auto_paused flow.release_resume.set() flow.join(flow.resume_threads) flow.release_unload.set() flow.join(flow.unload_threads) if supersede == "pause": flow.reinit.assert_not_called() assert manager.model is None assert manager.is_auto_paused assert not manager.start_recognition() else: flow.reinit.assert_called_once_with() assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] @pytest.mark.parametrize("pending", ["unload", "resume"]) @pytest.mark.parametrize("operation", ["manual", "idle_keepalive", "reconfigure", "system_resume"]) def test_lifecycle_operation_supersedes_deferred_auto_pause_work( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, pending: str, operation: str, ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) if pending == "resume": flow.poll(False) assert flow.resume_started.wait(1) if operation == "reconfigure": flow.resume_started.clear() if operation == "reconfigure" and pending == "unload": flow.unload_started.clear() started = time.monotonic() with pytest.raises(TimeoutError, match="still loading"): if operation == "reconfigure": manager.reconfigure(silence_timeout=1.25, audio_device_name="Configured microphone") elif operation == "system_resume": manager.reinitialize_after_resume() else: manager.unload_model(reason=operation) assert time.monotonic() - started < 0.5 assert manager.silence_timeout != 1.25 if operation == "reconfigure" and pending == "resume": assert flow.resume_started.wait(1) assert len(flow.resume_threads) == 2 if operation == "reconfigure" and pending == "unload": assert flow.unload_started.wait(1) assert len(flow.unload_threads) == 2 flow.finish_reload() loaded_model = manager.model flow.release_resume.set() flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) if operation == "reconfigure" and pending == "resume": flow.reinit.assert_called_once_with() flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused return flow.reinit.assert_not_called() if operation == "reconfigure" and pending == "unload": flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] assert manager.model is None assert manager.is_auto_paused return flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() assert manager.model is loaded_model assert manager.is_auto_paused @pytest.mark.parametrize("exit_before_wake", [False, True]) @pytest.mark.parametrize("suspends", [1, 2]) def test_suspend_retains_unload_while_game_persists( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, exit_before_wake: bool, suspends: int, ) -> None: """Sleep cannot abandon cleanup or allow dictation while the monitor stays paused.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) for _ in range(suspends): flow.unload_started.clear() started = time.monotonic() flow.tray._on_system_suspend() assert time.monotonic() - started < 0.5 assert flow.unload_started.wait(1) assert manager.is_auto_paused assert not manager.start_recognition() assert len(flow.unload_threads) == suspends + 1 flow.release_unload.set() flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() if exit_before_wake: flow.finish_reload() flow.join(flow.unload_threads) assert manager.model is None flow.tray._on_system_resume() assert not flow.wake_callbacks flow.poll(True) if not exit_before_wake: flow.finish_reload() flow.join(flow.unload_threads) flow.reinit.assert_not_called() assert len(flow.unload_threads) == suspends + 1 assert manager.model is None assert manager.is_auto_paused assert not manager.start_recognition() flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] flow.poll(False) flow.reinit.assert_called_once_with() assert manager.model_ready assert not manager.is_auto_paused @pytest.mark.parametrize("exit_before_wake", [False, True]) @pytest.mark.parametrize("game_exit", ["before_suspend", "during_suspend"]) def test_wake_recovers_auto_resume_without_monitor_transition( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, exit_before_wake: bool, game_exit: str, ) -> None: """Wake rearms canceled recovery, even when the native worker still owns the model.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) if game_exit == "before_suspend": flow.poll(False) assert flow.resume_started.wait(1) flow.unload_started.clear() flow.tray._on_system_suspend() if game_exit == "during_suspend": assert flow.unload_started.wait(1) flow.poll(False) assert not flow.resume_started.is_set() stale_resumes = len(flow.resume_threads) flow.release_resume.set() flow.release_unload.set() if exit_before_wake: flow.finish_reload() flow.join(flow.resume_threads + flow.unload_threads) flow.reinit.assert_not_called() assert manager.is_auto_paused assert not manager.start_recognition() flow.tray._on_system_resume() assert len(flow.wake_callbacks) == 1 flow.resume_started.clear() started = time.monotonic() flow.wake_callbacks[0]() assert time.monotonic() - started < 0.5 if not exit_before_wake: assert flow.resume_started.wait(1) assert len(flow.resume_threads) == stale_resumes + 1 assert manager.is_auto_paused assert not manager.start_recognition() flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() flow.finish_reload() flow.join(flow.resume_threads + flow.unload_threads) flow.poll(False) flow.reinit.assert_called_once_with() assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() @pytest.mark.parametrize("stage", ["delayed", "deferred"]) @pytest.mark.parametrize( "operation", [ "pause", "pause_resume", "suspend", "manual", "reconfigure", "reconfigure_model", "system_resume", ], ) def test_new_lifecycle_request_supersedes_wake_recovery( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, stage: str, operation: str, ) -> None: """Neither a timer nor deferred wake initialization can override a newer request.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.tray._on_system_suspend() flow.tray._on_system_resume() stale_wake = flow.wake_callbacks[0] if stage == "deferred": flow.resume_started.clear() stale_wake() assert flow.resume_started.wait(1) assert len(flow.resume_threads) == 2 flow.resume_started.clear() if operation in ("pause", "pause_resume"): flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) if operation == "pause_resume": flow.poll(False) assert flow.resume_started.wait(1) elif operation == "suspend": flow.tray._on_system_suspend() # Run the first wake's timer after a second wake, when a sleep flag # alone cannot reject it. Ownership must still belong to the new wake. flow.tray._on_system_resume() assert len(flow.wake_callbacks) == 2 else: with pytest.raises(TimeoutError, match="still loading"): if operation == "manual": manager.unload_model(reason="manual") elif operation in ("reconfigure", "reconfigure_model"): manager.reconfigure( silence_timeout=1.25, audio_device_name="Configured microphone", force_reinit=operation == "reconfigure_model", ) else: manager.reinitialize_after_resume() if operation in ("reconfigure", "reconfigure_model"): assert flow.resume_started.wait(1) assert manager.silence_timeout != 1.25 resumes = len(flow.resume_threads) if stage == "delayed": stale_wake() assert len(flow.resume_threads) == resumes flow.finish_reload() loaded_model = manager.model flow.release_resume.set() flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) recovered = operation in ("pause_resume", "reconfigure", "reconfigure_model") if recovered: flow.reinit.assert_called_once_with() assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused else: flow.reinit.assert_not_called() assert manager.is_auto_paused assert not manager.start_recognition() if operation == "pause": assert manager.model is None else: assert manager.model is loaded_model flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() if recovered or operation == "pause": flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] if operation == "suspend": flow.wake_callbacks[-1]() flow.reinit.assert_called_once_with() assert manager.model_ready assert not manager.is_auto_paused @pytest.mark.parametrize("stage", ["delayed", "deferred"]) @pytest.mark.parametrize("new_pause", [False, True]) def test_reconfigure_rollback_completes_inherited_wake_recovery( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, stage: str, new_pause: bool, ) -> None: """A restored engine recovers dictation unless a newer game pause owns it.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.tray._on_system_suspend() flow.tray._on_system_resume() stale_wake = flow.wake_callbacks[0] if stage == "deferred": flow.resume_started.clear() stale_wake() assert flow.resume_started.wait(1) flow.finish_reload() previous = manager._snapshot_reconfigure_state() previous_defer = manager._defer_download rolling_back = threading.Event() release_rollback = threading.Event() settings_error = RuntimeError("new engine failed") errors: list[Exception] = [] restored_settings: list[dict[str, object]] = [] def reject_settings() -> None: manager._model_initialized = False manager._set_engine_error() raise settings_error def restore() -> None: restored_settings.append(manager._snapshot_reconfigure_state()) rolling_back.set() assert release_rollback.wait(3) flow._reinit() def reconfigure() -> None: try: manager.reconfigure(engine="whisper", model_size="small", silence_timeout=1.25) except Exception as error: errors.append(error) flow.reinit.side_effect = restore monkeypatch.setattr(manager, "_init_whisper", reject_settings) monkeypatch.setattr( manager, "_init_selected_engine", rm.SpeechRecognitionManager._init_selected_engine.__get__(manager), ) settings_worker = threading.Thread(target=reconfigure) try: settings_worker.start() assert rolling_back.wait(1) assert manager.is_auto_paused assert not manager.start_recognition() if new_pause: flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) release_rollback.set() flow.join([settings_worker]) assert errors == [settings_error] assert restored_settings == [previous] assert manager._snapshot_reconfigure_state() == previous assert manager._defer_download is previous_defer assert manager.model is flow.resumed_model assert manager.model_ready assert manager.is_auto_paused is new_pause if stage == "delayed": stale_wake() flow.release_resume.set() flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) flow.reinit.assert_called_once_with() if new_pause: assert manager.model is None assert manager.is_auto_paused assert not manager.start_recognition() flow.poll(False) assert flow.reinit.call_count == 2 else: # The monitor already resumed before suspend; it need not # transition again to clear the guard after rejected settings. flow.poll(False) flow.reinit.assert_called_once_with() assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() finally: release_rollback.set() if settings_worker.ident is not None: flow.join([settings_worker]) def test_new_auto_resume_supersedes_waiting_resume( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.resume_started.clear() flow.tray._on_auto_resume() assert flow.resume_started.wait(1) assert len(flow.resume_threads) == 2 flow.finish_reload() flow.release_resume.set() flow.join(flow.resume_threads) flow.release_unload.set() flow.join(flow.unload_threads) flow.reinit.assert_called_once_with() assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused @pytest.mark.parametrize("restart", [False, True]) @pytest.mark.parametrize("attempts", [1, 2]) def test_failed_reconfigure_retains_deferred_resume( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, restart: bool, attempts: int, ) -> None: """Rejected settings recover the saved engine without another monitor transition.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) previous = manager._snapshot_reconfigure_state() loaded_model = manager.model initialized_settings: list[dict[str, object]] = [] def initialize() -> None: initialized_settings.append(manager._snapshot_reconfigure_state()) flow._reinit() flow.reinit.side_effect = initialize rejected_init = Mock() monkeypatch.setattr(manager, "_init_whisper", rejected_init) for attempt in range(attempts): flow.resume_started.clear() started = time.monotonic() with pytest.raises(TimeoutError, match="still loading"): manager.reconfigure( engine="whisper" if restart else None, model_size="small" if restart else None, silence_timeout=1.25, audio_device_name="Configured microphone", ) assert time.monotonic() - started < 0.5 assert flow.resume_started.wait(1) assert len(flow.resume_threads) == attempt + 2 assert manager._snapshot_reconfigure_state() == previous assert manager.model is loaded_model assert manager.is_auto_paused assert not manager.start_recognition() flow.reinit.assert_not_called() flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() # Both stale and current resumes can run, but must wait for the native worker. flow.release_resume.set() flow.finish_reload() flow.join(flow.resume_threads) flow.release_unload.set() flow.join(flow.unload_threads) flow.poll(False) assert len(flow.resume_threads) == attempts + 1 flow.reinit.assert_called_once_with() rejected_init.assert_not_called() assert initialized_settings == [previous] assert manager._snapshot_reconfigure_state() == previous assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() @pytest.mark.parametrize("restart", [False, True]) @pytest.mark.parametrize("operation", ["pause", "suspend", "manual", "system_resume"]) def test_new_request_supersedes_failed_reconfigure_recovery( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, restart: bool, operation: str, ) -> None: """A failed settings attempt cannot revive recovery superseded by newer work.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.resume_started.clear() with pytest.raises(TimeoutError, match="still loading"): manager.reconfigure( silence_timeout=1.25, audio_device_name="Configured microphone", force_reinit=restart ) assert flow.resume_started.wait(1) assert len(flow.resume_threads) == 2 started = time.monotonic() if operation == "pause": flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) elif operation == "suspend": flow.tray._on_system_suspend() else: with pytest.raises(TimeoutError, match="still loading"): if operation == "system_resume": manager.reinitialize_after_resume() else: manager.unload_model(reason="manual") assert time.monotonic() - started < 0.5 flow.finish_reload() loaded_model = manager.model flow.release_resume.set() flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) flow.reinit.assert_not_called() assert manager.is_auto_paused assert not manager.start_recognition() if operation == "pause": assert manager.model is None flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] else: assert manager.model is loaded_model flow.backend.cleanup.assert_not_called() flow.http_session.close.assert_not_called() @pytest.mark.parametrize("restart", [False, True]) @pytest.mark.parametrize("new_pause", [False, True]) def test_successful_reconfigure_takes_over_deferred_resume( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, restart: bool, new_pause: bool, ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.finish_reload() initializing = threading.Event() release_init = threading.Event() def initialize() -> None: initializing.set() assert release_init.wait(3) flow.reinit() monkeypatch.setattr(manager, "_init_vosk", initialize) monkeypatch.setattr(manager, "_init_selected_engine", initialize) reconfigure = threading.Thread( target=manager.reconfigure, kwargs={ "silence_timeout": 1.25, "audio_device_name": "Configured microphone", "force_reinit": restart, }, ) try: reconfigure.start() if not restart: assert flow.resume_started.wait(1) flow.release_resume.set() assert initializing.wait(1) if new_pause: flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) release_init.set() flow.join([reconfigure]) assert manager.silence_timeout == 1.25 flow.release_resume.set() flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) flow.reinit.assert_called_once_with() if new_pause: assert manager.is_auto_paused assert manager.model is None assert not manager.start_recognition() flow.poll(False) assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() finally: release_init.set() flow.join([reconfigure]) @pytest.mark.parametrize("operation", ["unload", "resume"]) @pytest.mark.parametrize("resource", ["model", "recognizer"]) def test_native_destruction_does_not_block_new_pause( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, operation: str, resource: str, ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) if operation == "resume": flow.poll(False) assert flow.resume_started.wait(1) flow.finish_reload() destroying = threading.Event() release_destroy = threading.Event() pause_done = threading.Event() class NativeResource: def __del__(self) -> None: destroying.set() assert release_destroy.wait(3) def pause() -> None: flow.tray._on_auto_pause() pause_done.set() # The manager holds the only reference, so clearing it runs __del__. setattr(manager, resource, NativeResource()) pausing = threading.Thread(target=pause) try: if operation == "resume": flow.release_resume.set() else: flow.release_unload.set() assert destroying.wait(1) flow.unload_started.clear() pausing.start() assert pause_done.wait(0.5) assert flow.unload_started.wait(1) assert manager.is_auto_paused assert not manager.start_recognition() assert not release_destroy.is_set() finally: release_destroy.set() if pausing.ident is not None: flow.join([pausing]) flow.release_unload.set() flow.join(flow.resume_threads + flow.unload_threads) flow.reinit.assert_not_called() assert manager.model is None assert manager.is_auto_paused @pytest.mark.parametrize("superseded", [False, True]) def test_initializer_failure_cannot_poison_newer_resume( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, tmp_path: Path, superseded: bool, ) -> None: flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.finish_reload() first_loading = threading.Event() release_first = threading.Event() second_loading = threading.Event() release_second = threading.Event() states: list[RecognitionState] = [] manager.state_callbacks.append(states.append) def load(*args: object, **kwargs: object) -> object: if not first_loading.is_set(): first_loading.set() assert release_first.wait(3) raise RuntimeError("old initializer failed") second_loading.set() if superseded: assert release_second.wait(3) return flow.resumed_model # Exercise the engine's own error write as well as the manager's handler. manager.engine = "whisper" manager._defer_download = False monkeypatch.setattr(rm, "MODELS_DIR", str(tmp_path)) monkeypatch.setattr(manager, "_download_whisper_model", Mock()) monkeypatch.setitem(sys.modules, "whisper", Mock(load_model=load)) torch = Mock() torch.cuda.is_available.return_value = False monkeypatch.setitem(sys.modules, "torch", torch) try: flow.release_resume.set() assert first_loading.wait(1) if superseded: flow.resume_started.clear() flow.tray._on_auto_resume() assert flow.resume_started.wait(1) assert len(flow.resume_threads) == 2 release_first.set() if not superseded: flow.join(flow.resume_threads) assert manager.state == RecognitionState.ERROR flow.tray._on_auto_resume() assert second_loading.wait(1) if superseded: assert manager.state == RecognitionState.IDLE assert RecognitionState.ERROR not in states release_second.set() flow.join(flow.resume_threads) flow.release_unload.set() flow.join(flow.unload_threads) assert manager.state == RecognitionState.IDLE assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() finally: release_first.set() release_second.set() @pytest.mark.parametrize("supersede", ["pause", "resume"]) def test_transition_while_deferred_resume_initializes( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, auto_pause_flow: _AutoPauseFlow, supersede: str, ) -> None: """Native initialization cannot delay a new pause or publish a stale guard.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) flow.poll(False) assert flow.resume_started.wait(1) flow.finish_reload() initializing = threading.Event() release_init = threading.Event() def initialize() -> None: initializing.set() assert release_init.wait(3) flow._reinit() monkeypatch.setattr(manager, "_init_vosk", initialize) try: flow.release_resume.set() assert initializing.wait(1) assert not manager._model_lock.acquire(blocking=False) flow.unload_started.clear() flow.poll(True) assert flow.unload_started.wait(1) if supersede == "resume": flow.resume_started.clear() flow.poll(False) assert flow.resume_started.wait(1) release_init.set() flow.join(flow.resume_threads) if supersede == "pause": assert manager.is_auto_paused assert not manager.start_recognition() else: assert not manager.is_auto_paused assert manager.model is flow.resumed_model flow.release_unload.set() flow.join(flow.unload_threads) if supersede == "pause": assert manager.model is None assert manager.is_auto_paused else: assert manager.model is flow.resumed_model assert manager.model_ready assert not manager.is_auto_paused finally: release_init.set() def test_late_recognizer_keeps_its_queue_and_cannot_change_next_session( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: decoding = threading.Event() release = threading.Event() attempts = [] def decode(audio: list[bytes], language: str) -> None: attempts.append(audio) decoding.set() assert release.wait(2) manager._update_state(RecognitionState.ERROR) manager.release_playback_duck() monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_process_audio_buffer", decode) assert manager.start_recognition() manager._segment_queue.put_nowait(([b"old speech"], "en")) assert decoding.wait(1) old_worker = manager.recognition_thread old_session = manager._session try: manager.stop_recognition() assert manager.is_transcribing() assert manager.cancel_transcription() monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() assert manager._session is not old_session new_queue = manager._segment_queue new_queue.put_nowait(([b"new speech"], "en")) restore = Mock() monkeypatch.setattr(manager._playback_duck, "restore", restore) finally: release.set() old_worker.join(1) assert not old_worker.is_alive() assert manager.state == RecognitionState.LISTENING assert manager.should_record assert new_queue.get_nowait() == ([b"new speech"], "en") with pytest.raises(queue.Empty): new_queue.get_nowait() assert attempts == [[b"old speech"]] restore.assert_not_called() def test_stale_recognizer_cannot_deliver_text_after_slow_corrections( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: correcting = threading.Event() release = threading.Event() delivered: list[str] = [] def correct(text: str) -> str: correcting.set() assert release.wait(2) return text manager.engine = "whisper" manager._voice_commands_enabled = False manager.text_callbacks.append(delivered.append) monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_transcribe_with_whisper", lambda audio, language: "old text") monkeypatch.setattr(manager, "_apply_dictionary_corrections", correct) assert manager.start_recognition() manager._segment_queue.put_nowait(([b"old speech"], "en")) assert correcting.wait(1) old_worker = manager.recognition_thread try: manager.stop_recognition() assert manager.is_transcribing() assert manager.cancel_transcription() monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() finally: release.set() old_worker.join(1) assert not old_worker.is_alive() assert delivered == [] assert manager.state == RecognitionState.LISTENING def test_stop_stays_bounded_and_restart_waits_for_active_text_callback( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: delivering = threading.Event() release = threading.Event() def deliver(_text: str) -> None: delivering.set() assert release.wait(2) manager.engine = "whisper" manager._voice_commands_enabled = False manager.text_callbacks.append(deliver) monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_transcribe_with_whisper", lambda audio, language: "old text") assert manager.start_recognition() manager._segment_queue.put_nowait(([b"old speech"], "en")) assert delivering.wait(1) old_worker = manager.recognition_thread try: started = time.monotonic() manager.stop_recognition() assert time.monotonic() - started < 0.5 assert not manager.start_recognition() finally: release.set() old_worker.join(1) assert not old_worker.is_alive() monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() def test_failed_start_restores_one_shot_language_after_buffer_timeout( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: original_language = manager.language monkeypatch.setattr(manager, "_serving_model_supports_language_switch", lambda: True) monkeypatch.setattr(manager, "_can_relanguage_without_reload", lambda: True) with manager._buffer_lock: assert not manager.start_recognition_with_language("de") assert manager.language == original_language assert manager._oneshot_language_restore is None monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() assert manager._session_language == original_language def test_healthy_stop_adds_no_polling_delay(manager: rm.SpeechRecognitionManager) -> None: manager.state = RecognitionState.LISTENING manager.should_record = True started = time.monotonic() manager.stop_recognition() assert time.monotonic() - started < 0.05 assert manager.state == RecognitionState.IDLE def test_stop_from_start_callback_does_not_launch_capture( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: capture = Mock() monkeypatch.setattr(manager, "_record_audio", capture) manager.state_callbacks.append( lambda state: manager.stop_recognition() if state == RecognitionState.LISTENING else None ) assert not manager.start_recognition() capture.assert_not_called() assert not manager.should_record assert manager.state == RecognitionState.IDLE def test_final_handoff_waits_for_vad_without_publishing_after_timeout( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: vad_entered = threading.Event() release = threading.Event() source = Mock(requires_pyaudio=False, sample_rate=16000, channels=1, audio=None) source.read_chunk.return_value = b"\x01\x00" * 1024 vad = Mock() def process(_chunk: object) -> float: vad_entered.set() assert release.wait(2) return 0.99 vad.process.side_effect = process manager._silero_vad = vad monkeypatch.setattr(manager, "_new_capture_source", lambda: source) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) assert manager.start_recognition() assert vad_entered.wait(1) try: assert not manager._buffer_lock.acquire(blocking=False) manager.stop_recognition() assert manager._session.stop_complete.is_set() finally: release.set() manager.audio_thread.join(1) assert not manager._recording_segment_has_speech assert manager._segment_queue.get_nowait() is None assert manager._segment_queue.get_nowait() is None with pytest.raises(queue.Empty): manager._segment_queue.get_nowait() @pytest.mark.parametrize("hook", ["state", "cue"]) def test_reentrant_restart_cannot_be_overwritten_by_original_start( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, hook: str ) -> None: captured_sessions = [] restarted = [] reentered = False def capture() -> None: captured_sessions.append(manager._worker_session.session) def restart() -> None: nonlocal reentered if reentered: return reentered = True manager.stop_recognition() restarted.append(manager.start_recognition()) monkeypatch.setattr(manager, "_record_audio", capture) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) if hook == "state": manager.state_callbacks.append( lambda state: restart() if state == RecognitionState.LISTENING else None ) else: monkeypatch.setattr(rm, "play_start_sound", restart) assert not manager.start_recognition() manager.audio_thread.join(1) manager.recognition_thread.join(1) assert restarted == [True] assert captured_sessions == [manager._session] assert manager.should_record assert manager.state == RecognitionState.LISTENING @pytest.mark.parametrize("cancelled", [False, True]) @pytest.mark.parametrize("superseded", [False, True]) def test_wake_after_pause_during_prefetch_keeps_current_model_request( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, cancelled: bool, superseded: bool, ) -> None: """Wake waits for Settings rollback/download; a newer pause still wins.""" downloading = threading.Event() release_download = threading.Event() resuming = threading.Event() resumed = threading.Event() resume_workers: list[threading.Thread] = [] errors: list[Exception] = [] initialized: list[tuple[str, str]] = [] selected_model = object() previous_engine, previous_size = manager.engine, manager.model_size resume_after_reconfigure = manager._resume_after_reconfigure def prefetch() -> None: downloading.set() assert release_download.wait(3) if cancelled: raise RuntimeError("Download cancelled") def initialize() -> None: initialized.append((manager.engine, manager.model_size)) manager.model = selected_model manager._model_initialized = True def reconfigure() -> None: try: manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=True) except Exception as error: errors.append(error) def resume(done: threading.Event, request: object) -> None: resume_workers.append(threading.current_thread()) resuming.set() try: resume_after_reconfigure(done, request) finally: resumed.set() monkeypatch.setattr(manager, "_download_selected_model_if_missing", prefetch) monkeypatch.setattr(manager, "_init_selected_engine", initialize) monkeypatch.setattr(manager, "_init_whispercpp", initialize) monkeypatch.setattr(manager, "_init_vosk", initialize) monkeypatch.setattr(manager, "_resume_after_reconfigure", resume) settings_worker = threading.Thread(target=reconfigure) settings_worker.start() try: assert downloading.wait(1) manager.unload_model(reason="auto_pause") assert manager.is_auto_paused manager.reinitialize_after_resume(auto_resume=True) assert resuming.wait(1) assert initialized == [] if superseded: manager.unload_model(reason="auto_pause") release_download.set() settings_worker.join(2) assert resumed.wait(2) finally: release_download.set() settings_worker.join(2) for worker in resume_workers: worker.join(2) assert not worker.is_alive() assert not settings_worker.is_alive() assert [str(error) for error in errors] == (["Download cancelled"] if cancelled else []) assert manager.is_auto_paused is superseded if superseded: assert initialized == [] assert manager.model is None else: assert initialized == [ (previous_engine, previous_size) if cancelled else ("whisper_cpp", "medium") ] assert manager.model is selected_model assert manager.model_ready @pytest.mark.parametrize("prefetch", [False, True]) def test_cancelled_settings_and_wake_cannot_mix_engine_and_model( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, prefetch: bool ) -> None: """A wake cannot load one engine while Settings rolls back to another.""" settings_waiting = threading.Event() release_settings = threading.Event() initializing = threading.Event() release_initializer = threading.Event() settings_finished = threading.Event() wake_workers: list[threading.Thread] = [] errors: list[Exception] = [] real_lock = threading.Lock() real_lock.acquire() gate_settings = True settings_worker: threading.Thread manager.model = (manager.engine, manager.model_size) resume_after_reload = manager._resume_after_reload class ModelLock: def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: nonlocal gate_settings if threading.current_thread() is settings_worker and gate_settings: gate_settings = False settings_waiting.set() assert release_settings.wait(3) if timeout < 0: return real_lock.acquire(blocking=blocking) return real_lock.acquire(blocking=blocking, timeout=timeout) def release(self) -> None: real_lock.release() def initialize() -> None: loaded_for = (manager.engine, manager.model_size) initializing.set() assert release_initializer.wait(3) manager.model = loaded_for manager._model_initialized = True def reconfigure() -> None: try: manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=prefetch) except Exception as error: errors.append(error) finally: settings_finished.set() def wake(worker: threading.Thread | None, request: object) -> None: wake_workers.append(threading.current_thread()) resume_after_reload(worker, request) monkeypatch.setattr(manager, "_model_lock", ModelLock()) monkeypatch.setattr(manager, "_init_selected_engine", initialize) monkeypatch.setattr(manager, "_init_whispercpp", initialize) monkeypatch.setattr(manager, "_init_vosk", initialize) monkeypatch.setattr(manager, "_resume_after_reload", wake) assert manager.try_begin_download() settings_worker = threading.Thread(target=reconfigure) settings_worker.start() try: assert settings_waiting.wait(1) manager.reinitialize_after_resume(auto_resume=True) manager.cancel_download() real_lock.release() # If wake owns a separate initializer, let it enter before rollback. # A correctly serialized wake stays pending until Settings settles. initializing.wait(0.2) release_settings.set() settings_finished.wait(0.3) finally: release_settings.set() release_initializer.set() settings_worker.join(2) for worker in wake_workers: worker.join(2) assert not worker.is_alive() manager.end_download() assert not settings_worker.is_alive() assert [str(error) for error in errors] == ["Download cancelled"] assert manager.model == (manager.engine, manager.model_size) assert manager.model_ready @pytest.mark.parametrize("prefetch", [False, True]) def test_settings_model_handoff_blocks_new_dictation_until_complete( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, prefetch: bool ) -> None: """A new engine name cannot dispatch audio through the previous model.""" waiting = threading.Event() errors: list[Exception] = [] old_model = manager.model old_engine = manager.engine selected_model = object() original_wait = manager._wait_for_model_settings_handoff def wait_for_handoff() -> None: waiting.set() original_wait() def initialize() -> None: manager.model = selected_model manager._model_initialized = True def apply() -> None: try: manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=prefetch) except Exception as error: errors.append(error) monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait_for_handoff) monkeypatch.setattr(manager, "_init_selected_engine", initialize) monkeypatch.setattr(manager, "_record_audio", lambda: None) monkeypatch.setattr(manager, "_perform_recognition", lambda: None) manager._model_lock.acquire() settings = threading.Thread(target=apply) settings.start() try: assert waiting.wait(1) assert manager.engine == old_engine assert manager.model is old_model assert manager.state is RecognitionState.IDLE assert not manager.start_recognition() rm._show_notification.assert_called_with( "Speech settings are changing", "Dictation will be available when the settings have finished applying.", "dialog-information", ) finally: manager._model_lock.release() settings.join(2) assert not settings.is_alive() assert errors == [] assert manager.engine == "whisper_cpp" assert manager.model is selected_model assert manager.start_recognition() manager.stop_recognition() @pytest.mark.parametrize("settings_during_reload", [False, True]) def test_settings_preserves_active_game_unload_obligation( manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, settings_during_reload: bool, ) -> None: """Hot Settings leave the original game unload and buffered worker intact.""" flow = auto_pause_flow flow.poll(True) assert flow.unload_started.wait(1) if not settings_during_reload: flow.finish_reload() request = manager._model_request worker = manager.recognition_thread manager.reconfigure(silence_timeout=1.25) assert manager.silence_timeout == 1.25 assert manager._model_request is request assert manager.recognition_thread is worker assert len(flow.unload_threads) == 1 if settings_during_reload: flow.finish_reload() flow.release_unload.set() flow.join(flow.unload_threads) assert manager.model is None assert manager.is_auto_paused assert flow.monitor.paused flow.backend.cleanup.assert_called_once_with() flow.http_session.close.assert_called_once_with() assert flow.cleanup_during_reload == [False] @pytest.mark.parametrize("new_pause", [False, True]) def test_fast_settings_defers_inherited_resume_without_blocking_caller( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, new_pause: bool ) -> None: """The GTK voice-command toggle stays responsive behind a native initializer.""" manager._auto_paused = True manager._auto_resume_pending = True manager.model = None manager._model_initialized = False returned = threading.Event() recovery_started = threading.Event() workers: list[threading.Thread] = [] errors: list[Exception] = [] initialized = Mock() resumed_model = object() original_resume = manager._resume_after_reload original_unload = manager._unload_after_reload def initialize() -> None: initialized() manager.model = resumed_model manager._model_initialized = True def resume(worker: threading.Thread | None, request: object) -> None: workers.append(threading.current_thread()) recovery_started.set() original_resume(worker, request) def unload(worker: threading.Thread | None, request: object) -> None: workers.append(threading.current_thread()) original_unload(worker, request) def toggle() -> None: try: manager.reconfigure(voice_commands_enabled=False, force_download=False) except Exception as error: errors.append(error) finally: returned.set() monkeypatch.setattr(manager, "_init_vosk", initialize) monkeypatch.setattr(manager, "_resume_after_reload", resume) monkeypatch.setattr(manager, "_unload_after_reload", unload) manager._model_lock.acquire() manager.reinitialize_after_resume(auto_resume=True) assert recovery_started.wait(1) request = manager._model_request caller = threading.Thread(target=toggle) caller.start() try: assert returned.wait(0.5) assert manager._model_request is request assert len(workers) == 1 assert manager._voice_commands_preference is False assert manager.is_auto_paused if new_pause: manager.unload_model(reason="auto_pause") finally: manager._model_lock.release() caller.join(2) for worker in workers: worker.join(2) assert not worker.is_alive() assert not caller.is_alive() assert errors == [] if new_pause: initialized.assert_not_called() assert manager.model is None assert manager.is_auto_paused else: initialized.assert_called_once_with() assert manager.model is resumed_model assert manager.model_ready assert not manager.is_auto_paused @pytest.mark.parametrize("failure", ["prefetch", "cancel", "initialize"]) def test_rejected_settings_preserves_active_pause_after_rollback( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, failure: str ) -> None: """Every rejected Settings path retains the configured game's unload request.""" manager._auto_paused = True old_model = manager.model unload_started = threading.Event() workers: list[threading.Thread] = [] original_unload = manager._unload_after_reload def unload(worker: threading.Thread | None, request: object) -> None: workers.append(threading.current_thread()) unload_started.set() original_unload(worker, request) def initialize() -> None: if manager.engine == "whisper_cpp": raise RuntimeError("initialization rejected") manager.model = old_model manager._model_initialized = True monkeypatch.setattr(manager, "_unload_after_reload", unload) monkeypatch.setattr(manager, "_init_selected_engine", initialize) if failure == "prefetch": monkeypatch.setattr( manager, "_download_selected_model_if_missing", Mock(side_effect=RuntimeError("prefetch rejected")), ) elif failure == "cancel": assert manager.try_begin_download() manager.cancel_download() try: with pytest.raises(RuntimeError): manager.reconfigure( engine="whisper_cpp", model_size="medium", force_download=failure == "prefetch" ) assert unload_started.wait(1) finally: for worker in workers: worker.join(2) assert not worker.is_alive() if failure == "cancel": manager.end_download() assert manager.engine == "vosk" assert manager.model is None assert manager.is_auto_paused assert not manager._auto_resume_pending def test_fast_settings_defers_even_uncontended_slow_resume( manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch ) -> None: """A free lock must not move a slow native reload onto the GTK caller.""" manager._auto_paused = True manager._auto_resume_pending = True manager.model = None manager._model_initialized = False loading = threading.Event() release = threading.Event() returned = threading.Event() errors: list[Exception] = [] resumed_model = object() def initialize() -> None: loading.set() assert release.wait(3) manager.model = resumed_model manager._model_initialized = True def toggle() -> None: try: manager.reconfigure(voice_commands_enabled=False, force_download=False) except Exception as error: errors.append(error) finally: returned.set() monkeypatch.setattr(manager, "_init_vosk", initialize) resuming = threading.Thread( target=manager.reinitialize_after_resume, kwargs={"auto_resume": True} ) caller = threading.Thread(target=toggle) resuming.start() try: assert loading.wait(1) request = manager._model_request caller.start() assert returned.wait(0.5) assert not release.is_set() assert manager.is_auto_paused assert manager._model_request is request finally: release.set() resuming.join(2) if caller.ident is not None: caller.join(2) assert not resuming.is_alive() assert not caller.is_alive() assert errors == [] assert manager.model is resumed_model assert manager.model_ready assert not manager.is_auto_paused