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:
Jatin K Malik
2026-06-06 16:26:28 -07:00
committed by GitHub
parent 84d2e1a929
commit f417c28efa
6 changed files with 185 additions and 92 deletions
@@ -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
+6 -5
View File
@@ -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)
+30 -17
View File
@@ -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
+109 -35
View File
@@ -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()
+1
View File
@@ -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()
+15
View File
@@ -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())