* 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>
293 lines
11 KiB
Python
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
|