fix(threading): harden thread safety for Remote API, IBus, and text injection (#452)
* fix(threading): harden thread safety for HTTP session, IBus, and text injector Four critical thread-safety fixes identified during PR #335 review: 1. Remote API: snapshot _http_session under _model_lock in _process_audio_buffer() to prevent use-after-close races with reconfigure/reinitialize. Pass session as explicit parameter to _transcribe_with_remote_api, _try_whispercpp_server_api, and _try_openai_api. Call _http_session.close() before setting to None in reinitialize_after_resume(). Remove blocking connection probe from _init_remote_api() (settings dialog has a Test Connection button for that). 2. Text injector: extend _state_lock to protect stop() (writes _ibus_injector=None and _ibus_ready=False) and inject_text() reads of environment/_ibus_injector. Also guard environment/wayland_tool writes in _switch_to_non_ibus_backend() and WAYLAND_XDOTOOL fallback in inject_text's exception handler. 3. IBus engine: snapshot cls._active_instance into a local variable before the do_inject closure in the socket server handler, closing the TOCTOU race with do_destroy(). 4. Test suspend handler: add _http_session=None to _make_manager() fixture so reinitialize_after_resume() finds the attribute. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) * test(threading): update tests for thread-safety changes - test_recognition_manager_remote_api: pass session param to _transcribe_with_remote_api, _try_openai_api, and _try_whispercpp_server_api. Update assert_called_once_with patterns for the new session argument. Simplify init tests that no longer need connection probe mocks (removed from _init_remote_api). - test_text_injector: add threading.Lock to all TextInjector.__new__() instances so _state_lock is available for lock-guarded methods. - test_suspend_handler: add _http_session=None to _make_manager() fixture so reinitialize_after_resume() finds the attribute. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) * test(threading): improve coverage for thread-safety fixes Add tests for _process_audio_buffer remote API dispatch (session snapshot under lock, early return when session is None) and reinitialize_after_resume session close. Mark server_thread as pragma: no cover (blocked by conftest to prevent CI hangs).
This commit is contained in:
@@ -1390,34 +1390,11 @@ class SpeechRecognitionManager:
|
||||
self._http_session.close()
|
||||
self._http_session = requests.Session()
|
||||
|
||||
# Try connection test
|
||||
try:
|
||||
test_url = self.remote_api_url
|
||||
headers = {}
|
||||
if self.remote_api_key:
|
||||
headers["Authorization"] = f"Bearer {self.remote_api_key}"
|
||||
|
||||
response = self._http_session.get(test_url, headers=headers, timeout=5)
|
||||
if response.ok:
|
||||
logger.info(
|
||||
f"Remote server connection test successful (status={response.status_code})"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Remote server connection test returned non-success status: "
|
||||
f"{response.status_code}. Will try again during recognition."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Remote server connection test failed: {e}. "
|
||||
"Will try to connect again during recognition."
|
||||
)
|
||||
|
||||
# Remote API does not need local models, directly mark as ready
|
||||
self._model_initialized = True
|
||||
logger.info("Remote API engine setup complete.")
|
||||
|
||||
def _transcribe_with_remote_api(self, audio_buffer: list[bytes]) -> str:
|
||||
def _transcribe_with_remote_api(self, audio_buffer: list[bytes], session) -> str:
|
||||
"""Transcribe audio via remote API.
|
||||
|
||||
Package audio buffer into WAV format and send to remote server via HTTP POST.
|
||||
@@ -1426,6 +1403,7 @@ class SpeechRecognitionManager:
|
||||
|
||||
Args:
|
||||
audio_buffer: Audio data chunk list (16-bit PCM at 16kHz)
|
||||
session: A requests.Session snapshot (obtained under _model_lock)
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
@@ -1476,9 +1454,9 @@ class SpeechRecognitionManager:
|
||||
|
||||
text = None
|
||||
if self.remote_api_endpoint == "/inference":
|
||||
text = self._try_whispercpp_server_api(wav_bytes, lang, headers)
|
||||
text = self._try_whispercpp_server_api(wav_bytes, lang, headers, session)
|
||||
else:
|
||||
text = self._try_openai_api(wav_bytes, lang, headers)
|
||||
text = self._try_openai_api(wav_bytes, lang, headers, session)
|
||||
|
||||
# If both formats fail
|
||||
if text is None:
|
||||
@@ -1518,13 +1496,14 @@ class SpeechRecognitionManager:
|
||||
logger.error(f"Remote API transcription error: {e} ({audio_info})", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _try_openai_api(self, wav_bytes: bytes, lang, headers: dict):
|
||||
def _try_openai_api(self, wav_bytes: bytes, lang, headers: dict, session):
|
||||
"""Try to transcribe using OpenAI compatible API format.
|
||||
|
||||
Args:
|
||||
wav_bytes: Audio data in WAV format
|
||||
lang: Language core (e.g. "en", None for auto detect)
|
||||
headers: HTTP request headers
|
||||
session: A requests.Session snapshot (obtained under _model_lock)
|
||||
|
||||
Returns:
|
||||
Transcribed text, or None if format is not supported
|
||||
@@ -1539,9 +1518,7 @@ class SpeechRecognitionManager:
|
||||
data["language"] = lang
|
||||
|
||||
try:
|
||||
response = self._http_session.post(
|
||||
url, headers=headers, files=files, data=data, timeout=30
|
||||
)
|
||||
response = session.post(url, headers=headers, files=files, data=data, timeout=30)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.debug("OpenAI API endpoint does not exist, try other formats")
|
||||
@@ -1560,13 +1537,14 @@ class SpeechRecognitionManager:
|
||||
logger.debug(f"OpenAI API format attempt failed: {e}")
|
||||
return None
|
||||
|
||||
def _try_whispercpp_server_api(self, wav_bytes: bytes, lang, headers: dict):
|
||||
def _try_whispercpp_server_api(self, wav_bytes: bytes, lang, headers: dict, session):
|
||||
"""Try to transcribe using whisper.cpp server API format.
|
||||
|
||||
Args:
|
||||
wav_bytes: Audio data in WAV format
|
||||
lang: Language core (e.g. "en", None for auto detect)
|
||||
headers: HTTP request headers
|
||||
session: A requests.Session snapshot (obtained under _model_lock)
|
||||
|
||||
Returns:
|
||||
Transcribed text, or None if format is not supported
|
||||
@@ -1585,9 +1563,7 @@ class SpeechRecognitionManager:
|
||||
data["language"] = lang
|
||||
|
||||
try:
|
||||
response = self._http_session.post(
|
||||
url, headers=headers, files=files, data=data, timeout=30
|
||||
)
|
||||
response = session.post(url, headers=headers, files=files, data=data, timeout=30)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.debug("whisper.cpp server endpoint does not exist")
|
||||
@@ -2550,7 +2526,18 @@ class SpeechRecognitionManager:
|
||||
text = self._transcribe_with_whispercpp(audio_buffer)
|
||||
|
||||
elif self.engine == "remote_api":
|
||||
text = self._transcribe_with_remote_api(audio_buffer)
|
||||
# Snapshot the HTTP session under lock to prevent race with
|
||||
# reconfigure() / reinitialize_after_resume() which close/recreate
|
||||
# the session under _model_lock. The snapshot (a local reference)
|
||||
# remains valid even if another thread closes the old session —
|
||||
# urllib3's PoolManager keeps existing connections alive until the
|
||||
# in-flight request completes.
|
||||
with self._model_lock:
|
||||
session = self._http_session
|
||||
if session is None:
|
||||
logger.error("Remote API HTTP session not initialized")
|
||||
return
|
||||
text = self._transcribe_with_remote_api(audio_buffer, session)
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown engine: {self.engine}")
|
||||
@@ -2920,6 +2907,8 @@ class SpeechRecognitionManager:
|
||||
with self._model_lock:
|
||||
self.model = None
|
||||
self.recognizer = None
|
||||
if self._http_session is not None:
|
||||
self._http_session.close()
|
||||
self._http_session = None
|
||||
self._model_initialized = False
|
||||
|
||||
|
||||
@@ -646,7 +646,7 @@ class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object):
|
||||
|
||||
cls._server_running = True
|
||||
|
||||
def server_thread():
|
||||
def server_thread(): # pragma: no cover
|
||||
try:
|
||||
cls._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
cls._server_socket.bind(str(SOCKET_PATH))
|
||||
@@ -674,11 +674,12 @@ class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object):
|
||||
continue
|
||||
|
||||
text = data.decode("utf-8")
|
||||
# Schedule injection on main thread
|
||||
if cls._active_instance:
|
||||
# Snapshot active instance to avoid TOCTOU race with do_destroy
|
||||
instance = cls._active_instance
|
||||
if instance:
|
||||
|
||||
def do_inject(t):
|
||||
cls._active_instance.inject_text(t)
|
||||
def do_inject(t, inst=instance):
|
||||
inst.inject_text(t)
|
||||
return False # Run only once
|
||||
|
||||
GLib.idle_add(do_inject, text)
|
||||
|
||||
@@ -113,10 +113,11 @@ class TextInjector:
|
||||
|
||||
Call this when shutting down Vocalinux.
|
||||
"""
|
||||
if self._ibus_injector:
|
||||
logger.info("Stopping IBus text injector")
|
||||
self._ibus_injector.stop()
|
||||
self._ibus_injector = None
|
||||
with self._state_lock:
|
||||
if self._ibus_injector:
|
||||
logger.info("Stopping IBus text injector")
|
||||
self._ibus_injector.stop()
|
||||
self._ibus_injector = None
|
||||
self._ibus_ready = False
|
||||
|
||||
def _detect_environment(self) -> DesktopEnvironment:
|
||||
@@ -330,7 +331,8 @@ class TextInjector:
|
||||
"""Switch from IBus mode to a non-IBus backend for runtime fallback."""
|
||||
if self.environment == DesktopEnvironment.X11_IBUS:
|
||||
if shutil.which("xdotool"):
|
||||
self.environment = DesktopEnvironment.X11
|
||||
with self._state_lock:
|
||||
self.environment = DesktopEnvironment.X11
|
||||
logger.warning("IBus injection failed, switching to X11 xdotool fallback")
|
||||
return True
|
||||
|
||||
@@ -350,8 +352,9 @@ class TextInjector:
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=2,
|
||||
)
|
||||
self.wayland_tool = "ydotool"
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
with self._state_lock:
|
||||
self.wayland_tool = "ydotool"
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
logger.warning("IBus injection failed, switching to Wayland ydotool fallback")
|
||||
return True
|
||||
except (
|
||||
@@ -362,13 +365,15 @@ class TextInjector:
|
||||
logger.debug("ydotool fallback unavailable (daemon not running)")
|
||||
|
||||
if wtype_available:
|
||||
self.wayland_tool = "wtype"
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
with self._state_lock:
|
||||
self.wayland_tool = "wtype"
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
logger.warning("IBus injection failed, switching to Wayland wtype fallback")
|
||||
return True
|
||||
|
||||
if xdotool_available:
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
with self._state_lock:
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
logger.warning("IBus injection failed, switching to XWayland xdotool fallback")
|
||||
return True
|
||||
|
||||
@@ -564,12 +569,16 @@ class TextInjector:
|
||||
self._try_recover_from_fallback()
|
||||
|
||||
try:
|
||||
with self._state_lock:
|
||||
current_env = self.environment
|
||||
ibus_injector = self._ibus_injector
|
||||
|
||||
if (
|
||||
self.environment == DesktopEnvironment.WAYLAND_IBUS
|
||||
or self.environment == DesktopEnvironment.X11_IBUS
|
||||
current_env == DesktopEnvironment.WAYLAND_IBUS
|
||||
or current_env == DesktopEnvironment.X11_IBUS
|
||||
):
|
||||
if self._ibus_injector is not None:
|
||||
result = self._ibus_injector.inject_text(text)
|
||||
if ibus_injector is not None:
|
||||
result = ibus_injector.inject_text(text)
|
||||
if result:
|
||||
logger.info("Text injection completed successfully")
|
||||
if self._should_copy_to_clipboard():
|
||||
@@ -594,9 +603,12 @@ class TextInjector:
|
||||
"IBus injector not initialized and no non-IBus fallback is available"
|
||||
)
|
||||
|
||||
with self._state_lock:
|
||||
current_env = self.environment
|
||||
|
||||
if (
|
||||
self.environment == DesktopEnvironment.X11
|
||||
or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
current_env == DesktopEnvironment.X11
|
||||
or current_env == DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
):
|
||||
self._inject_with_xdotool(text)
|
||||
else:
|
||||
@@ -613,7 +625,8 @@ class TextInjector:
|
||||
logger.info(
|
||||
"Switching to XWayland fallback - will re-check for better tools"
|
||||
)
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
with self._state_lock:
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
self._inject_with_xdotool(text)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -46,9 +46,7 @@ def _mock_heavy_deps(monkeypatch):
|
||||
|
||||
def _import_manager():
|
||||
"""Delayed import of SpeechRecognitionManager to ensure fixture mocks take effect before execution."""
|
||||
from vocalinux.speech_recognition.recognition_manager import (
|
||||
SpeechRecognitionManager,
|
||||
)
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
return SpeechRecognitionManager
|
||||
|
||||
@@ -179,7 +177,6 @@ class TestRemoteAPIEngine(unittest.TestCase):
|
||||
def test_remote_api_init_with_connection_failure(self):
|
||||
"""Test remote API initialization handles connection failure gracefully."""
|
||||
SpeechRecognitionManager = _import_manager()
|
||||
_setup_requests_get_error(Exception("Connection refused"))
|
||||
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="remote_api",
|
||||
@@ -191,7 +188,6 @@ class TestRemoteAPIEngine(unittest.TestCase):
|
||||
def test_remote_api_init_with_server_error(self):
|
||||
"""Test remote API initialization handles non-OK server response."""
|
||||
SpeechRecognitionManager = _import_manager()
|
||||
_setup_requests_get_non_ok(status_code=500)
|
||||
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="remote_api",
|
||||
@@ -203,7 +199,6 @@ class TestRemoteAPIEngine(unittest.TestCase):
|
||||
def test_remote_api_init_with_api_key(self):
|
||||
"""Test remote API initialization includes API key in headers."""
|
||||
SpeechRecognitionManager = _import_manager()
|
||||
mock_get = _setup_requests_get_ok()
|
||||
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="remote_api",
|
||||
@@ -212,12 +207,6 @@ class TestRemoteAPIEngine(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(manager._model_initialized)
|
||||
self.assertEqual(manager.remote_api_key, "test-api-key")
|
||||
# Verify the API key was sent in the request headers
|
||||
mock_get.assert_called_once_with(
|
||||
"http://localhost:9090",
|
||||
headers={"Authorization": "Bearer test-api-key"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
def test_remote_api_init_with_inference_endpoint(self):
|
||||
"""Test remote API initialization with whisper.cpp server endpoint."""
|
||||
@@ -283,13 +272,13 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
|
||||
def test_transcribe_with_empty_buffer(self):
|
||||
"""Test transcription with empty audio buffer."""
|
||||
result = self.manager._transcribe_with_remote_api([])
|
||||
result = self.manager._transcribe_with_remote_api([], None)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_transcribe_with_no_url(self):
|
||||
"""Test transcription when URL is not set."""
|
||||
self.manager.remote_api_url = ""
|
||||
result = self.manager._transcribe_with_remote_api([b"test"])
|
||||
result = self.manager._transcribe_with_remote_api([b"test"], None)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_transcribe_with_openai_endpoint_success(self):
|
||||
@@ -301,7 +290,9 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
|
||||
# Change endpoint to OpenAI
|
||||
self.manager.remote_api_endpoint = "/v1/audio/transcriptions"
|
||||
result = self.manager._transcribe_with_remote_api([b"test-audio-data"])
|
||||
result = self.manager._transcribe_with_remote_api(
|
||||
[b"test-audio-data"], self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertEqual(result, "test transcription")
|
||||
|
||||
@@ -312,7 +303,9 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
|
||||
mock_server.return_value = "hello world"
|
||||
|
||||
result = self.manager._transcribe_with_remote_api([b"audio-bytes"])
|
||||
result = self.manager._transcribe_with_remote_api(
|
||||
[b"audio-bytes"], self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertEqual(result, "hello world")
|
||||
|
||||
@@ -323,7 +316,9 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
|
||||
mock_server.return_value = None
|
||||
|
||||
result = self.manager._transcribe_with_remote_api([b"some-audio"])
|
||||
result = self.manager._transcribe_with_remote_api(
|
||||
[b"some-audio"], self.manager._http_session
|
||||
)
|
||||
|
||||
# Should return empty string when API returns None
|
||||
self.assertEqual(result, "")
|
||||
@@ -337,7 +332,9 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
with patch.object(self.manager, "_try_openai_api") as mock_openai:
|
||||
mock_openai.return_value = None
|
||||
|
||||
result = self.manager._transcribe_with_remote_api([b"some-audio"])
|
||||
result = self.manager._transcribe_with_remote_api(
|
||||
[b"some-audio"], self.manager._http_session
|
||||
)
|
||||
|
||||
# Should return empty string when API returns None
|
||||
self.assertEqual(result, "")
|
||||
@@ -351,10 +348,10 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
mock_server.return_value = "test"
|
||||
|
||||
self.manager.language = "en-us"
|
||||
self.manager._transcribe_with_remote_api([b"audio"])
|
||||
self.manager._transcribe_with_remote_api([b"audio"], self.manager._http_session)
|
||||
|
||||
# Should have been called with "en" (converted from "en-us")
|
||||
mock_server.assert_called_once_with(ANY, "en", ANY)
|
||||
mock_server.assert_called_once_with(ANY, "en", ANY, ANY)
|
||||
|
||||
def test_transcribe_with_language_auto(self):
|
||||
"""Test with auto language detection."""
|
||||
@@ -364,10 +361,10 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
mock_server.return_value = "test"
|
||||
|
||||
self.manager.language = "auto"
|
||||
self.manager._transcribe_with_remote_api([b"audio"])
|
||||
self.manager._transcribe_with_remote_api([b"audio"], self.manager._http_session)
|
||||
|
||||
# Should pass None for auto detection
|
||||
mock_server.assert_called_once_with(ANY, None, ANY)
|
||||
mock_server.assert_called_once_with(ANY, None, ANY, ANY)
|
||||
|
||||
def test_transcribe_unexpected_exception_returns_empty(self):
|
||||
"""An unexpected exception inside transcription is caught and returns ''."""
|
||||
@@ -376,7 +373,9 @@ class TestRemoteAPITranscription(unittest.TestCase):
|
||||
with patch.object(self.manager, "_try_whispercpp_server_api") as mock_server:
|
||||
mock_server.side_effect = RuntimeError("boom")
|
||||
|
||||
result = self.manager._transcribe_with_remote_api([b"audio"])
|
||||
result = self.manager._transcribe_with_remote_api(
|
||||
[b"audio"], self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertEqual(result, "")
|
||||
|
||||
@@ -400,7 +399,9 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test successful transcription via OpenAI API format."""
|
||||
_setup_requests_post_ok({"text": "transcribed text"})
|
||||
|
||||
result = self.manager._try_openai_api(b"wav-bytes", "en", {"Authorization": "Bearer key"})
|
||||
result = self.manager._try_openai_api(
|
||||
b"wav-bytes", "en", {"Authorization": "Bearer key"}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertEqual(result, "transcribed text")
|
||||
|
||||
@@ -408,7 +409,7 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test OpenAI API format returns None for 404."""
|
||||
_setup_requests_post_status(404)
|
||||
|
||||
result = self.manager._try_openai_api(b"wav", "en", {})
|
||||
result = self.manager._try_openai_api(b"wav", "en", {}, self.manager._http_session)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -416,7 +417,7 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test OpenAI API handles connection errors gracefully."""
|
||||
_setup_requests_post_error(Exception("Connection refused"))
|
||||
|
||||
result = self.manager._try_openai_api(b"wav", "en", {})
|
||||
result = self.manager._try_openai_api(b"wav", "en", {}, self.manager._http_session)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -424,7 +425,7 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test OpenAI API includes language in request."""
|
||||
mock_post = _setup_requests_post_ok({"text": "result"})
|
||||
|
||||
self.manager._try_openai_api(b"wav-bytes", "fr", {})
|
||||
self.manager._try_openai_api(b"wav-bytes", "fr", {}, self.manager._http_session)
|
||||
|
||||
# Check that language was included in the data
|
||||
mock_post.assert_called_once()
|
||||
@@ -435,7 +436,7 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test OpenAI API handles 500 server error via raise_for_status."""
|
||||
_setup_requests_post_status(500, Exception("500 Server Error"))
|
||||
|
||||
result = self.manager._try_openai_api(b"wav", "en", {})
|
||||
result = self.manager._try_openai_api(b"wav", "en", {}, self.manager._http_session)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -443,7 +444,7 @@ class TestOpenAIAPIFormat(unittest.TestCase):
|
||||
"""Test OpenAI API handles 401 unauthorized via raise_for_status."""
|
||||
_setup_requests_post_status(401, Exception("401 Unauthorized"))
|
||||
|
||||
result = self.manager._try_openai_api(b"wav", "en", {})
|
||||
result = self.manager._try_openai_api(b"wav", "en", {}, self.manager._http_session)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -468,7 +469,7 @@ class TestWhisperCppServerAPIFormat(unittest.TestCase):
|
||||
_setup_requests_post_ok({"text": "spoken words"})
|
||||
|
||||
result = self.manager._try_whispercpp_server_api(
|
||||
b"audio-wav", "de", {"Authorization": "Bearer key"}
|
||||
b"audio-wav", "de", {"Authorization": "Bearer key"}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertEqual(result, "spoken words")
|
||||
@@ -477,7 +478,9 @@ class TestWhisperCppServerAPIFormat(unittest.TestCase):
|
||||
"""Test whisper.cpp server returns None for 404."""
|
||||
_setup_requests_post_status(404)
|
||||
|
||||
result = self.manager._try_whispercpp_server_api(b"wav", "es", {})
|
||||
result = self.manager._try_whispercpp_server_api(
|
||||
b"wav", "es", {}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -485,7 +488,9 @@ class TestWhisperCppServerAPIFormat(unittest.TestCase):
|
||||
"""Test whisper.cpp server handles connection errors."""
|
||||
_setup_requests_post_error(Exception("Connection refused"))
|
||||
|
||||
result = self.manager._try_whispercpp_server_api(b"wav", "es", {})
|
||||
result = self.manager._try_whispercpp_server_api(
|
||||
b"wav", "es", {}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -493,7 +498,9 @@ class TestWhisperCppServerAPIFormat(unittest.TestCase):
|
||||
"""Test whisper.cpp server handles 500 error via raise_for_status."""
|
||||
_setup_requests_post_status(500, Exception("500 Server Error"))
|
||||
|
||||
result = self.manager._try_whispercpp_server_api(b"wav", "en", {})
|
||||
result = self.manager._try_whispercpp_server_api(
|
||||
b"wav", "en", {}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -501,7 +508,9 @@ class TestWhisperCppServerAPIFormat(unittest.TestCase):
|
||||
"""Test whisper.cpp server handles 401 unauthorized via raise_for_status."""
|
||||
_setup_requests_post_status(401, Exception("401 Unauthorized"))
|
||||
|
||||
result = self.manager._try_whispercpp_server_api(b"wav", "en", {})
|
||||
result = self.manager._try_whispercpp_server_api(
|
||||
b"wav", "en", {}, self.manager._http_session
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
@@ -637,7 +646,7 @@ class TestHTTPSession(unittest.TestCase):
|
||||
mock_response.json.return_value = {"text": "hello"}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
result = manager._try_whispercpp_server_api(b"wav-bytes", "en", {})
|
||||
result = manager._try_whispercpp_server_api(b"wav-bytes", "en", {}, manager._http_session)
|
||||
|
||||
self.assertEqual(result, "hello")
|
||||
mock_session.post.assert_called_once()
|
||||
@@ -693,5 +702,70 @@ class TestHTTPSession(unittest.TestCase):
|
||||
self.assertIs(manager._http_session, second_session)
|
||||
|
||||
|
||||
class TestProcessAudioBufferRemoteAPI(unittest.TestCase):
|
||||
"""Test _process_audio_buffer dispatching to remote API engine."""
|
||||
|
||||
def setUp(self):
|
||||
SpeechRecognitionManager = _import_manager()
|
||||
_setup_requests_get_ok()
|
||||
|
||||
self.manager = SpeechRecognitionManager(
|
||||
engine="remote_api",
|
||||
remote_api_url="http://localhost:9090",
|
||||
)
|
||||
self.manager._voice_commands_enabled = False
|
||||
self.manager.text_callbacks = []
|
||||
self.manager.action_callbacks = []
|
||||
|
||||
def test_process_audio_buffer_remote_api_success(self):
|
||||
"""_process_audio_buffer snapshots session under lock and dispatches to remote API."""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(
|
||||
self.manager, "_transcribe_with_remote_api", return_value="hello"
|
||||
) as mock_transcribe:
|
||||
self.manager._process_audio_buffer([b"audio-data"])
|
||||
|
||||
mock_transcribe.assert_called_once()
|
||||
args, _ = mock_transcribe.call_args
|
||||
self.assertEqual(args[0], [b"audio-data"])
|
||||
self.assertIs(args[1], self.manager._http_session)
|
||||
|
||||
def test_process_audio_buffer_remote_api_session_none_returns_early(self):
|
||||
"""_process_audio_buffer returns early when _http_session is None."""
|
||||
from unittest.mock import patch
|
||||
|
||||
self.manager._http_session = None
|
||||
|
||||
with patch.object(self.manager, "_transcribe_with_remote_api") as mock_transcribe:
|
||||
self.manager._process_audio_buffer([b"audio-data"])
|
||||
|
||||
mock_transcribe.assert_not_called()
|
||||
|
||||
|
||||
class TestReinitializeAfterResumeSessionClose(unittest.TestCase):
|
||||
"""Test that reinitialize_after_resume closes the HTTP session."""
|
||||
|
||||
def test_reinitialize_after_resume_closes_http_session(self):
|
||||
"""reinitialize_after_resume calls session.close() before nulling _http_session."""
|
||||
from unittest.mock import patch
|
||||
|
||||
SpeechRecognitionManager = _import_manager()
|
||||
_setup_requests_get_ok()
|
||||
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="remote_api",
|
||||
remote_api_url="http://localhost:9090",
|
||||
)
|
||||
|
||||
session = manager._http_session
|
||||
|
||||
with patch.object(manager, "_init_remote_api"):
|
||||
manager.reinitialize_after_resume()
|
||||
|
||||
session.close.assert_called_once()
|
||||
self.assertIsNone(manager._http_session)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -214,6 +214,7 @@ class TestReinitializeAfterResume(unittest.TestCase):
|
||||
mgr._model_lock = MagicMock()
|
||||
mgr._model_lock.__enter__ = MagicMock(return_value=None)
|
||||
mgr._model_lock.__exit__ = MagicMock(return_value=False)
|
||||
mgr._http_session = None
|
||||
mgr._reconnection_attempts = 3
|
||||
mgr._update_state = MagicMock()
|
||||
mgr._init_vosk = MagicMock()
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests for text injection functionality.
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -317,6 +318,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
# Clear all environment variables
|
||||
with patch.object(TextInjector, "_check_dependencies"):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
env = injector._detect_environment()
|
||||
# Should default to X11 when unknown
|
||||
self.assertEqual(env, DesktopEnvironment.X11)
|
||||
@@ -326,6 +328,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
|
||||
with patch.object(TextInjector, "_check_dependencies"):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
env = injector._detect_environment()
|
||||
self.assertEqual(env, DesktopEnvironment.WAYLAND)
|
||||
|
||||
@@ -334,6 +337,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
|
||||
with patch.object(TextInjector, "_check_dependencies"):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
env = injector._detect_environment()
|
||||
self.assertEqual(env, DesktopEnvironment.X11)
|
||||
|
||||
@@ -835,6 +839,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
clean_env = {"XDG_SESSION_TYPE": "", "PATH": os.environ.get("PATH", "")}
|
||||
with patch.dict("os.environ", clean_env, clear=True):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
# Don't call __init__, just test _detect_environment
|
||||
result = injector._detect_environment()
|
||||
# Should default to X11 when unknown (no WAYLAND_DISPLAY or DISPLAY)
|
||||
@@ -845,6 +850,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
# Clear session type but set WAYLAND_DISPLAY
|
||||
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "", "WAYLAND_DISPLAY": "wayland-0"}):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
result = injector._detect_environment()
|
||||
self.assertEqual(result, DesktopEnvironment.WAYLAND)
|
||||
|
||||
@@ -860,6 +866,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
|
||||
with patch.dict("os.environ", env_copy, clear=True):
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
result = injector._detect_environment()
|
||||
self.assertEqual(result, DesktopEnvironment.X11)
|
||||
|
||||
@@ -1005,6 +1012,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
|
||||
# Create injector in WAYLAND_XDOTOOL mode
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
|
||||
# Mock subprocess.run
|
||||
@@ -1263,6 +1271,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
|
||||
# Create injector directly in WAYLAND_XDOTOOL mode
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
|
||||
# Mock subprocess.run
|
||||
@@ -1504,6 +1513,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
"""X11 IBus fallback should fail cleanly when xdotool is unavailable."""
|
||||
self.mock_which.return_value = None
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.X11_IBUS
|
||||
|
||||
result = injector._switch_to_non_ibus_backend()
|
||||
@@ -1515,6 +1525,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
"""Wayland IBus fallback should prefer ydotool when its daemon responds."""
|
||||
self.mock_which.side_effect = lambda cmd: {"ydotool": "/usr/bin/ydotool"}.get(cmd)
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
||||
injector.wayland_tool = None
|
||||
|
||||
@@ -1535,6 +1546,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
}.get(cmd)
|
||||
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, ["ydotool"])
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
||||
injector.wayland_tool = None
|
||||
|
||||
@@ -1548,6 +1560,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
"""Wayland IBus fallback should use xdotool/XWayland when native tools are absent."""
|
||||
self.mock_which.side_effect = lambda cmd: {"xdotool": "/usr/bin/xdotool"}.get(cmd)
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
||||
injector.wayland_tool = None
|
||||
|
||||
@@ -1560,6 +1573,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
"""Wayland IBus fallback should fail when no non-IBus tools are available."""
|
||||
self.mock_which.return_value = None
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
||||
injector.wayland_tool = None
|
||||
|
||||
@@ -1571,6 +1585,7 @@ class TestIBusRuntimeFallback(unittest.TestCase):
|
||||
def test_switch_to_non_ibus_backend_noop_outside_ibus_mode(self):
|
||||
"""Non-IBus modes are already on fallback backends."""
|
||||
injector = TextInjector.__new__(TextInjector)
|
||||
injector._state_lock = threading.Lock()
|
||||
injector.environment = DesktopEnvironment.X11
|
||||
|
||||
self.assertTrue(injector._switch_to_non_ibus_backend())
|
||||
|
||||
Reference in New Issue
Block a user