Files
voculinux/tests/test_reconfigure_model_handoff.py
e66bd218d9 fix(audio): prevent dictation hangs on stalled microphones (#974)
* fix(audio): bound capture reads and support cancellation

* fix(recognition): bound and serialize capture shutdown

* fix(recognition): cancel retries and report stalled microphones

* fix(audio): prefer pulse when the ALSA default is unavailable

* fix(recognition): guard late callbacks and finalize capture tests

* docs(audio): explain input selection and stall recovery

* fix(speech): enforce auto-pause across buffered reload timeouts

* fix(speech): recover auto-resume after reload timeout

* test(speech): fix auto-pause follow-up test regressions

* fix(speech): retain auto-resume after reconfigure timeout

* fix(tray): preserve auto-pause recovery across suspend

* test(capture-shutdown): give the bare tray the suspend path's shortcut managers

_on_system_suspend now releases the main and per-language hotkey
listeners before sleep (#1025); the fixture builds TrayIndicator without
__init__, so it needs the two attributes present.

* fix(tray): release hotkey listeners on suspend even while auto-paused

Merging #1025's suspend cleanup with this branch's auto-pause handoff
left an early return that skipped shortcut_manager.stop() whenever a
configured game was active, keeping EVIOCGRABs held through sleep. The
paused branch now only owns the model-lifecycle work; listener release
and stopping active recognition run for every suspend.

* fix(tray): hold the apply lock only around the download's verify and save

Wrapping the whole recommended-model download in _apply_settings_lock
blocked every GTK-thread settings apply (e.g. Test Dictation) for the
length of the transfer. The engine already serializes reconfigures, so
the lock only needs to cover verifying the download still owns the
receipt and saving it; an apply that lands mid-download now proceeds
instead of freezing the app.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-26 21:25:50 -07:00

293 lines
11 KiB
Python

"""Settings must not publish load-time fields into a superseded initializer."""
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from test_reconfigure_worker_handoff import _make_manager
from vocalinux.speech_recognition import recognition_manager as rm
def _track_deferred_workers(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
) -> list[threading.Thread]:
workers: list[threading.Thread] = []
for method_name in ("_resume_after_reload", "_unload_after_reload"):
original = getattr(manager, method_name)
def run(*args: object, target: Callable[..., None] = original) -> None:
workers.append(threading.current_thread())
target(*args)
monkeypatch.setattr(manager, method_name, run)
return workers
def _gate_whispercpp_initializer(
manager: rm.SpeechRecognitionManager,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> tuple[threading.Event, threading.Event, list[tuple[str, str]]]:
entered, release = threading.Event(), threading.Event()
loads: list[tuple[str, str]] = []
checkpoint = tmp_path / "ggml-tiny.bin"
checkpoint.write_bytes(b"model")
def preload() -> None:
entered.set()
assert release.wait(3)
def load_whispercpp(path: str) -> None:
loads.append(("whisper_cpp", manager.model_size))
manager.model = ("whisper_cpp", manager.model_size)
manager._model_initialized = True
def create_parakeet(**kwargs: object) -> tuple[str, str]:
loads.append(("parakeet", manager.model_size))
return ("parakeet", manager.model_size)
monkeypatch.setattr(rm, "_preload_pywhispercpp_shared_libraries", preload)
monkeypatch.setitem(sys.modules, "pywhispercpp.model", SimpleNamespace(Model=Mock()))
monkeypatch.setitem(
sys.modules,
"sherpa_onnx",
SimpleNamespace(OfflineRecognizer=SimpleNamespace(from_transducer=create_parakeet)),
)
monkeypatch.setattr(rm, "get_model_path", lambda size: str(checkpoint))
monkeypatch.setattr(manager, "_whispercpp_model_is_verified", lambda path: True)
monkeypatch.setattr(manager, "_load_whispercpp_model", load_whispercpp)
monkeypatch.setattr(rm.parakeet, "is_model_downloaded", lambda size: True)
monkeypatch.setattr(manager, "_parakeet_model_is_verified", lambda *args: True)
return entered, release, loads
def test_superseded_resume_cannot_replace_the_requested_parakeet_model(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch)
entered, release, loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
# Enter the production deferred path, then block the real whisper.cpp
# initializer immediately before its model-name validation.
manager._model_lock.acquire()
try:
manager.reinitialize_after_resume(auto_resume=True)
finally:
manager._model_lock.release()
assert entered.wait(1)
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
assert (manager.engine, manager.model_size) == ("whisper_cpp", "small")
finally:
release.set()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert errors == []
assert loads == [("whisper_cpp", "small"), ("parakeet", "v2-english")]
assert (manager.engine, manager.model_size) == ("parakeet", "v2-english")
assert manager.model == ("parakeet", "v2-english")
assert manager.model_ready
assert not manager.is_auto_paused
def test_cancel_during_initial_handoff_does_not_restore_active_initializer_fields(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch, model_size="obsolete")
entered, release, _loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
waiting, loading, release_load = threading.Event(), threading.Event(), threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
original_load = manager._load_whispercpp_model
def wait() -> None:
waiting.set()
original_wait()
def load(path: str) -> None:
loading.set()
assert release_load.wait(3)
original_load(path)
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
monkeypatch.setattr(manager, "_load_whispercpp_model", load)
initializer = threading.Thread(target=manager.reinitialize_after_resume)
initializer.start()
assert entered.wait(1)
assert manager.try_begin_download()
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
release.set()
assert loading.wait(1)
# This write is the real old initializer's invalid-model fallback.
assert manager.model_size == "tiny"
manager.cancel_download()
settings_worker.join(0.5)
assert not settings_worker.is_alive()
assert len(errors) == 1
assert str(errors[0]) == "Download cancelled"
assert (manager.engine, manager.model_size) == ("whisper_cpp", "tiny")
assert initializer.is_alive()
finally:
release.set()
release_load.set()
settings_worker.join(2)
initializer.join(2)
manager.end_download()
assert not initializer.is_alive()
assert manager.model == ("whisper_cpp", "tiny")
def test_settings_releases_initial_handoff_lock_before_network_prefetch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _make_manager(monkeypatch)
prefetched: list[tuple[str, str]] = []
def download() -> None:
assert manager._model_lock.acquire(blocking=False)
try:
prefetched.append((manager.engine, manager.model_size))
finally:
manager._model_lock.release()
def initialize() -> None:
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
manager.reconfigure(model_size="medium")
assert prefetched == [("whisper_cpp", "medium")]
assert manager.model == ("whisper_cpp", "medium")
def test_resume_queued_before_settings_cannot_initialize_after_its_handoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _make_manager(monkeypatch)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
loads: list[tuple[str, str]] = []
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def initialize() -> None:
loads.append((manager.engine, manager.model_size))
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(
manager, "_init_whispercpp", lambda: pytest.fail("stale resume initialized")
)
manager._model_lock.acquire()
settings_worker = threading.Thread(target=reconfigure)
try:
manager.reinitialize_after_resume(auto_resume=True)
settings_worker.start()
assert waiting.wait(1)
finally:
manager._model_lock.release()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert errors == []
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert loads == [("parakeet", "v2-english")]
assert manager.model == ("parakeet", "v2-english")
def test_auto_pause_after_settings_reservation_still_owns_the_unload(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch)
entered, release, loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
manager._model_lock.acquire()
try:
manager.reinitialize_after_resume(auto_resume=True)
finally:
manager._model_lock.release()
assert entered.wait(1)
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
manager.unload_model(reason="auto_pause")
assert manager.is_auto_paused
finally:
release.set()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert errors == []
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert loads == [("whisper_cpp", "small")]
assert (manager.engine, manager.model_size) == ("parakeet", "v2-english")
assert manager.model is None
assert not manager.model_ready
assert manager.is_auto_paused
assert not manager._auto_resume_pending