From f417c28efa93245761ca3c3229e6421c9e0ab4d5 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 6 Jun 2026 16:26:28 -0700 Subject: [PATCH] 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). --- .../speech_recognition/recognition_manager.py | 59 +++---- src/vocalinux/text_injection/ibus_engine.py | 11 +- src/vocalinux/text_injection/text_injector.py | 47 +++--- tests/test_recognition_manager_remote_api.py | 144 +++++++++++++----- tests/test_suspend_handler.py | 1 + tests/test_text_injector.py | 15 ++ 6 files changed, 185 insertions(+), 92 deletions(-) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index bd73b2d..a6ecaa3 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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 diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index ac9ad50..c643465 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 2f67834..42b83a7 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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 diff --git a/tests/test_recognition_manager_remote_api.py b/tests/test_recognition_manager_remote_api.py index 49dc3a8..e8837af 100644 --- a/tests/test_recognition_manager_remote_api.py +++ b/tests/test_recognition_manager_remote_api.py @@ -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() diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py index 3addda9..1101850 100644 --- a/tests/test_suspend_handler.py +++ b/tests/test_suspend_handler.py @@ -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() diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 5de33e2..08a729c 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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())