Files
voculinux/tests/test_start_stop_race.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

160 lines
6.1 KiB
Python

"""Regression tests: a stop must never be lost, and a stalled mic must not hang it.
1. A stop that arrives while start_recognition() is still running (key
released on another thread right after the state became LISTENING) used
to be undone by the later ``should_record = True``: the capture thread
then recorded with nothing left to stop it.
2. A capture read that blocks (audio server reconfigures the device, a
USB/Bluetooth mic stalls) used to hold ``_buffer_lock``, so
stop_recognition() blocked forever on that lock after its bounded join.
"""
import sys
import threading
import time
from typing import Iterator
from unittest.mock import MagicMock, patch
import pytest
import vocalinux.speech_recognition.recognition_manager as recognition_module
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
@pytest.fixture(autouse=True)
def _numpy_is_real_while_capturing() -> Iterator[None]:
"""Run the real capture loop against the real NumPy.
Older tests assign ``sys.modules["numpy"] = MagicMock()`` at import and
never put the real module back. ``_record_audio`` imports numpy lazily, so
under that leak its first chunk raises and the loop exits before the
stalled read this file depends on.
"""
current = sys.modules.get("numpy")
real = getattr(sys, "_vocalinux_real_numpy", None)
if isinstance(current, MagicMock) and real is not None:
sys.modules["numpy"] = real
try:
yield
finally:
sys.modules["numpy"] = current
else:
yield
def _make_manager() -> SpeechRecognitionManager:
duck = MagicMock()
duck.enabled.return_value = False
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
manager = SpeechRecognitionManager(
engine="whisper_cpp",
model_size="small",
language="en-us",
playback_duck=duck,
)
manager.model = MagicMock()
manager._model_initialized = True
manager._silero_vad = None
return manager
def _stop_session(manager: SpeechRecognitionManager) -> None:
with patch.object(recognition_module, "play_stop_sound"):
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.2):
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.05):
manager.stop_recognition()
def _wait_for_state(manager: SpeechRecognitionManager, state: RecognitionState) -> None:
deadline = time.monotonic() + 3.0
while manager.state is not state and time.monotonic() < deadline:
time.sleep(0.005)
assert manager.state is state
def test_stop_racing_start_is_not_lost() -> None:
manager = _make_manager()
stops: list[threading.Thread] = []
def record_audio() -> None:
# Stand-in for the capture loop: records while should_record is set.
deadline = time.monotonic() + 3.0
while manager.should_record and time.monotonic() < deadline:
time.sleep(0.005)
def release_key_on_another_thread(state: RecognitionState) -> None:
# The first LISTENING notification is the earliest point another
# thread can observe the session and stop it. The stop elects on the
# lifecycle lock the moment the start's hold ends, so it completes
# right after start_recognition() returns; joining it inside the
# callback would stall the start it must outlive.
if state is RecognitionState.LISTENING and not stops:
stop = threading.Thread(target=_stop_session, args=(manager,))
stops.append(stop)
stop.start()
manager._record_audio = record_audio
manager.register_state_callback(release_key_on_another_thread)
try:
with patch.object(recognition_module, "play_start_sound"):
with patch.object(manager, "_arm_playback_duck"):
assert manager.start_recognition(mode="push_to_talk")
assert stops
stops[0].join(timeout=5.0)
# The stop must survive the rest of start_recognition().
assert not stops[0].is_alive()
assert manager.should_record is False
assert manager.audio_thread is not None
manager.audio_thread.join(timeout=1.0)
assert not manager.audio_thread.is_alive()
_wait_for_state(manager, RecognitionState.IDLE)
finally:
manager.should_record = False
def test_stop_completes_while_capture_read_is_blocked() -> None:
manager = _make_manager()
first_read_done = threading.Event()
read_blocking = threading.Event()
release_read = threading.Event()
source = MagicMock()
source.requires_pyaudio = False
source.sample_rate = 16000
def read_chunk(*args: object, **kwargs: object) -> bytes:
# The first read returns normally, every later one stalls like a
# device that disappeared under the audio server.
if first_read_done.is_set():
read_blocking.set()
release_read.wait(timeout=10.0)
first_read_done.set()
return b"\x00\x00" * 1024
source.read_chunk.side_effect = read_chunk
manager._new_capture_source = MagicMock(return_value=source)
try:
with patch.object(recognition_module, "play_start_sound"):
with patch.object(manager, "_arm_playback_duck"):
assert manager.start_recognition(mode="push_to_talk")
# Start the stop only once the capture thread is inside the stalled read.
assert read_blocking.wait(timeout=2.0)
stop = threading.Thread(target=_stop_session, args=(manager,), daemon=True)
stop.start()
# stop_recognition() joins the capture thread for at most 2 s; it
# must then finish instead of waiting on the buffer lock forever.
stop.join(timeout=5.0)
assert not stop.is_alive(), "stop_recognition() hung behind a blocked capture read"
_wait_for_state(manager, RecognitionState.IDLE)
finally:
release_read.set()
manager.should_record = False
if manager.audio_thread is not None:
manager.audio_thread.join(timeout=2.0)
assert not manager.audio_thread.is_alive()