Files
voculinux/tests/test_start_stop_race.py
T
90adde05a4 fix(recognition): never lose a stop that races start, and read capture outside the buffer lock (#1023)
* fix(recognition): never lose a stop that races start, and read capture outside the buffer lock

start_recognition() published LISTENING (and played the start cue) before setting should_record. A stop arriving in that window, e.g. a push-to-talk release handled on another thread, was undone by the later should_record = True, leaving the capture thread recording with no way to stop it. The session is now armed under _buffer_lock before LISTENING is visible.

The capture loop held _buffer_lock across source.read_chunk(). When a read blocks (the audio server reconfigures the device, a USB/Bluetooth mic stalls), stop_recognition() gives up its bounded join and then blocks forever on that lock, stuck in PROCESSING. The read now happens outside the lock and the session is re-checked before the chunk is appended.

* test(recognition): wait for the stalled read before stopping

Signal from inside the blocking read instead of sleeping after the first, non-blocking one, so the test always exercises the stalled-read path.

* test(recognition): run the stalled-read test against the real NumPy

Older test modules leave sys.modules["numpy"] as a MagicMock. Under that leak the capture loop's first chunk raised before the stalled read was reached, so the test failed in the full-suite order on CI. Restore the real module for these tests, as test_playback_ducker.py already does.

---------

Co-authored-by: AAG Projects Team <293887937+aagprojectsteam-max@users.noreply.github.com>
2026-10-26 20:18:50 -07:00

156 lines
5.9 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.
if state is RecognitionState.LISTENING and not stops:
stop = threading.Thread(target=_stop_session, args=(manager,))
stops.append(stop)
stop.start()
stop.join(timeout=5.0)
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 and not stops[0].is_alive()
# The stop must survive the rest of start_recognition().
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() -> 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()