diff --git a/source/_asyncioEventLoop/__init__.py b/source/_asyncioEventLoop/__init__.py new file mode 100644 index 00000000000..40585ab8b65 --- /dev/null +++ b/source/_asyncioEventLoop/__init__.py @@ -0,0 +1,51 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +""" +Provide an asyncio event loop running on a background thread for use by NVDA components. +""" + +import asyncio +from threading import Thread + +from logHandler import log + +from .utils import runCoroutineSync + +from . import _state + +TERMINATE_TIMEOUT_SECONDS = _state.TERMINATE_TIMEOUT_SECONDS + + +def initialize(): + """Initialize and start the asyncio event loop.""" + log.info("Initializing asyncio event loop") + _state.eventLoop = asyncio.new_event_loop() + asyncio.set_event_loop(_state.eventLoop) + _state.asyncioThread = Thread(target=_state.eventLoop.run_forever, daemon=True) + _state.asyncioThread.start() + + +def terminate(): + """Terminate the asyncio event loop and cancel all running tasks.""" + log.info("Terminating asyncio event loop") + + async def cancelAllTasks(): + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + log.debug(f"Stopping {len(tasks)} tasks") + [task.cancel() for task in tasks] + await asyncio.gather(*tasks, return_exceptions=True) + log.debug("Done stopping tasks") + + try: + runCoroutineSync(cancelAllTasks(), TERMINATE_TIMEOUT_SECONDS) + except TimeoutError: + log.debugWarning("Timeout while stopping async tasks") + finally: + _state.eventLoop.call_soon_threadsafe(_state.eventLoop.stop) + + _state.asyncioThread.join() + _state.asyncioThread = None + _state.eventLoop.close() diff --git a/source/_asyncioEventLoop/_state.py b/source/_asyncioEventLoop/_state.py new file mode 100644 index 00000000000..892a554aebc --- /dev/null +++ b/source/_asyncioEventLoop/_state.py @@ -0,0 +1,19 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +""" +Shared mutable state for the asyncio event loop module. +""" + +import asyncio +from threading import Thread + +TERMINATE_TIMEOUT_SECONDS = 5 +"""Time to wait for tasks to finish while terminating the event loop.""" + +eventLoop: asyncio.BaseEventLoop +"""The asyncio event loop used by NVDA.""" +asyncioThread: Thread +"""Thread running the asyncio event loop.""" diff --git a/source/_asyncioEventLoop/utils.py b/source/_asyncioEventLoop/utils.py new file mode 100644 index 00000000000..91ae7bbce38 --- /dev/null +++ b/source/_asyncioEventLoop/utils.py @@ -0,0 +1,44 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +""" +Utility functions for scheduling coroutines on the asyncio event loop. +""" + +import asyncio +from collections.abc import Coroutine + +from . import _state + + +def runCoroutine(coro: Coroutine) -> asyncio.Future: + """Schedule a coroutine to be run on the asyncio event loop. + + :param coro: The coroutine to run. + """ + if _state.asyncioThread is None or not _state.asyncioThread.is_alive(): + raise RuntimeError("Asyncio event loop thread is not running") + return asyncio.run_coroutine_threadsafe(coro, _state.eventLoop) + + +def runCoroutineSync(coro: Coroutine, timeout: float | None = None): + """Schedule a coroutine to be run on the asyncio event loop and wait for the result. + + This is a synchronous wrapper around runCoroutine() that blocks until the coroutine + completes and returns the result directly, or raises any exception that occurred. + + :param coro: The coroutine to run. + :param timeout: Optional timeout in seconds. If None, waits indefinitely. + :return: The result of the coroutine. + :raises: Any exception raised by the coroutine. + :raises TimeoutError: If the timeout is exceeded. + :raises RuntimeError: If the asyncio event loop thread is not running. + """ + future = runCoroutine(coro) + try: + return future.result(timeout) + except asyncio.TimeoutError as e: + future.cancel() + raise TimeoutError(f"Coroutine execution timed out after {timeout} seconds") from e diff --git a/source/core.py b/source/core.py index a6ff433c74f..db28c0afcaa 100644 --- a/source/core.py +++ b/source/core.py @@ -758,6 +758,10 @@ def main(): log.debug("Initializing appModule Handler") appModuleHandler.initialize() + log.debug("Initializing asyncio event loop") + import _asyncioEventLoop + + _asyncioEventLoop.initialize() log.debug("initializing background i/o") import hwIo @@ -1100,6 +1104,7 @@ def _doPostNvdaStartupAction(): _terminate(characterProcessing) _terminate(bdDetect) _terminate(hwIo) + _terminate(_asyncioEventLoop, name="asyncio event loop") _terminate(addonHandler) _terminate(dataManager, name="addon dataManager") _terminate(garbageHandler) diff --git a/tests/unit/test_asyncioEventLoop.py b/tests/unit/test_asyncioEventLoop.py new file mode 100644 index 00000000000..e70e0f44550 --- /dev/null +++ b/tests/unit/test_asyncioEventLoop.py @@ -0,0 +1,95 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Bram Duvigneau, Dot Incorporated +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Unit tests for _asyncioEventLoop module.""" + +import asyncio +import unittest + +import _asyncioEventLoop +from _asyncioEventLoop.utils import runCoroutineSync + + +class TestRunCoroutineSync(unittest.TestCase): + """Tests for runCoroutineSync function.""" + + @classmethod + def setUpClass(cls): + """Initialize the asyncio event loop before tests.""" + _asyncioEventLoop.initialize() + + @classmethod + def tearDownClass(cls): + """Terminate the asyncio event loop after tests.""" + _asyncioEventLoop.terminate() + + def test_returnsResult(self): + """Test that runCoroutineSync returns the coroutine's result.""" + + async def simpleCoroutine(): + return 42 + + result = runCoroutineSync(simpleCoroutine()) + self.assertEqual(result, 42) + + def test_returnsComplexResult(self): + """Test that runCoroutineSync returns complex objects.""" + + async def complexCoroutine(): + await asyncio.sleep(0.01) + return {"key": "value", "number": 123} + + result = runCoroutineSync(complexCoroutine()) + self.assertEqual(result, {"key": "value", "number": 123}) + + def test_raisesException(self): + """Test that runCoroutineSync raises exceptions from the coroutine.""" + + async def failingCoroutine(): + await asyncio.sleep(0.01) + raise ValueError("Test error message") + + with self.assertRaises(ValueError) as cm: + runCoroutineSync(failingCoroutine()) + self.assertEqual(str(cm.exception), "Test error message") + + def test_timeoutRaisesTimeoutError(self): + """Test that runCoroutineSync raises TimeoutError when timeout is exceeded.""" + + async def slowCoroutine(): + await asyncio.sleep(10) + return "Should not reach here" + + with self.assertRaises(TimeoutError) as cm: + runCoroutineSync(slowCoroutine(), timeout=0.1) + self.assertIn("timed out", str(cm.exception).lower()) + + def test_noTimeoutWaitsIndefinitely(self): + """Test that runCoroutineSync waits indefinitely when no timeout is specified.""" + + async def delayedCoroutine(): + await asyncio.sleep(0.1) + return "completed" + + result = runCoroutineSync(delayedCoroutine()) + self.assertEqual(result, "completed") + + def test_raisesRuntimeErrorWhenEventLoopNotRunning(self): + """Test that runCoroutineSync raises RuntimeError when event loop is not running.""" + # Save original thread reference + originalThread = _asyncioEventLoop._state.asyncioThread + + # Temporarily set to None to simulate not running + _asyncioEventLoop._state.asyncioThread = None + + async def anyCoroutine(): + return "test" + + with self.assertRaises(RuntimeError) as cm: + runCoroutineSync(anyCoroutine()) + self.assertIn("not running", str(cm.exception).lower()) + + # Restore original thread + _asyncioEventLoop._state.asyncioThread = originalThread diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index e473eaade15..e7193093caf 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -59,6 +59,7 @@ It only ran the translation string comment check, which is equivalent to `scons The `scons checkPot` target has also been replaced with `runcheckpot.bat`. Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, `runsystemtests.bat`, `runlint.bat`. (#19606, #19676, @bramd) * Updated Python 3.13.11 to 3.13.12 (#19572, @dpy013) +* Added a private `_asyncioEventLoop` module that provides an asyncio event loop running on a background thread for use by NVDA components. (#19816, @bramd) #### Deprecations