Skip to content

Commit 69c1fa0

Browse files
authored
Add private _asyncioEventLoop module (#19816)
N/A. This is a follow-up from #19122 (DotPad BLE support), splitting out the asyncio event loop module as requested during review. Summary of the issue: NVDA currently has no built-in way to run asyncio coroutines. Components that need async functionality (e.g. BLE communication) require a managed asyncio event loop running on a background thread. Description of user facing changes: N/A. This is a developer-facing module only. Description of developer facing changes: Added a private _asyncioEventLoop module that provides: initialize() / terminate() — lifecycle management, wired into NVDA core startup/shutdown runCoroutine(coro) — schedule a coroutine on the event loop (fire-and-forget) runCoroutineSync(coro, timeout) — schedule a coroutine and block until completion The module is prefixed with an underscore to keep it internal to NVDA core and not part of the public add-on API. Description of development approach: The asyncio event loop runs on a daemon thread, started during NVDA initialization (after appModuleHandler) and terminated during shutdown (after hwIo). The terminate() function cancels all pending tasks before stopping the loop. runCoroutineSync wraps asyncio.run_coroutine_threadsafe with optional timeout support and converts asyncio.TimeoutError to the built-in TimeoutError for a cleaner API.
1 parent 0df8c10 commit 69c1fa0

File tree

6 files changed

+215
-0
lines changed

6 files changed

+215
-0
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
"""
7+
Provide an asyncio event loop running on a background thread for use by NVDA components.
8+
"""
9+
10+
import asyncio
11+
from threading import Thread
12+
13+
from logHandler import log
14+
15+
from .utils import runCoroutineSync
16+
17+
from . import _state
18+
19+
TERMINATE_TIMEOUT_SECONDS = _state.TERMINATE_TIMEOUT_SECONDS
20+
21+
22+
def initialize():
23+
"""Initialize and start the asyncio event loop."""
24+
log.info("Initializing asyncio event loop")
25+
_state.eventLoop = asyncio.new_event_loop()
26+
asyncio.set_event_loop(_state.eventLoop)
27+
_state.asyncioThread = Thread(target=_state.eventLoop.run_forever, daemon=True)
28+
_state.asyncioThread.start()
29+
30+
31+
def terminate():
32+
"""Terminate the asyncio event loop and cancel all running tasks."""
33+
log.info("Terminating asyncio event loop")
34+
35+
async def cancelAllTasks():
36+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
37+
log.debug(f"Stopping {len(tasks)} tasks")
38+
[task.cancel() for task in tasks]
39+
await asyncio.gather(*tasks, return_exceptions=True)
40+
log.debug("Done stopping tasks")
41+
42+
try:
43+
runCoroutineSync(cancelAllTasks(), TERMINATE_TIMEOUT_SECONDS)
44+
except TimeoutError:
45+
log.debugWarning("Timeout while stopping async tasks")
46+
finally:
47+
_state.eventLoop.call_soon_threadsafe(_state.eventLoop.stop)
48+
49+
_state.asyncioThread.join()
50+
_state.asyncioThread = None
51+
_state.eventLoop.close()

source/_asyncioEventLoop/_state.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
"""
7+
Shared mutable state for the asyncio event loop module.
8+
"""
9+
10+
import asyncio
11+
from threading import Thread
12+
13+
TERMINATE_TIMEOUT_SECONDS = 5
14+
"""Time to wait for tasks to finish while terminating the event loop."""
15+
16+
eventLoop: asyncio.BaseEventLoop
17+
"""The asyncio event loop used by NVDA."""
18+
asyncioThread: Thread
19+
"""Thread running the asyncio event loop."""

source/_asyncioEventLoop/utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
"""
7+
Utility functions for scheduling coroutines on the asyncio event loop.
8+
"""
9+
10+
import asyncio
11+
from collections.abc import Coroutine
12+
13+
from . import _state
14+
15+
16+
def runCoroutine(coro: Coroutine) -> asyncio.Future:
17+
"""Schedule a coroutine to be run on the asyncio event loop.
18+
19+
:param coro: The coroutine to run.
20+
"""
21+
if _state.asyncioThread is None or not _state.asyncioThread.is_alive():
22+
raise RuntimeError("Asyncio event loop thread is not running")
23+
return asyncio.run_coroutine_threadsafe(coro, _state.eventLoop)
24+
25+
26+
def runCoroutineSync(coro: Coroutine, timeout: float | None = None):
27+
"""Schedule a coroutine to be run on the asyncio event loop and wait for the result.
28+
29+
This is a synchronous wrapper around runCoroutine() that blocks until the coroutine
30+
completes and returns the result directly, or raises any exception that occurred.
31+
32+
:param coro: The coroutine to run.
33+
:param timeout: Optional timeout in seconds. If None, waits indefinitely.
34+
:return: The result of the coroutine.
35+
:raises: Any exception raised by the coroutine.
36+
:raises TimeoutError: If the timeout is exceeded.
37+
:raises RuntimeError: If the asyncio event loop thread is not running.
38+
"""
39+
future = runCoroutine(coro)
40+
try:
41+
return future.result(timeout)
42+
except asyncio.TimeoutError as e:
43+
future.cancel()
44+
raise TimeoutError(f"Coroutine execution timed out after {timeout} seconds") from e

source/core.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,10 @@ def main():
758758

759759
log.debug("Initializing appModule Handler")
760760
appModuleHandler.initialize()
761+
log.debug("Initializing asyncio event loop")
762+
import _asyncioEventLoop
763+
764+
_asyncioEventLoop.initialize()
761765
log.debug("initializing background i/o")
762766
import hwIo
763767

@@ -1100,6 +1104,7 @@ def _doPostNvdaStartupAction():
11001104
_terminate(characterProcessing)
11011105
_terminate(bdDetect)
11021106
_terminate(hwIo)
1107+
_terminate(_asyncioEventLoop, name="asyncio event loop")
11031108
_terminate(addonHandler)
11041109
_terminate(dataManager, name="addon dataManager")
11051110
_terminate(garbageHandler)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2025-2026 NV Access Limited, Bram Duvigneau, Dot Incorporated
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
"""Unit tests for _asyncioEventLoop module."""
7+
8+
import asyncio
9+
import unittest
10+
11+
import _asyncioEventLoop
12+
from _asyncioEventLoop.utils import runCoroutineSync
13+
14+
15+
class TestRunCoroutineSync(unittest.TestCase):
16+
"""Tests for runCoroutineSync function."""
17+
18+
@classmethod
19+
def setUpClass(cls):
20+
"""Initialize the asyncio event loop before tests."""
21+
_asyncioEventLoop.initialize()
22+
23+
@classmethod
24+
def tearDownClass(cls):
25+
"""Terminate the asyncio event loop after tests."""
26+
_asyncioEventLoop.terminate()
27+
28+
def test_returnsResult(self):
29+
"""Test that runCoroutineSync returns the coroutine's result."""
30+
31+
async def simpleCoroutine():
32+
return 42
33+
34+
result = runCoroutineSync(simpleCoroutine())
35+
self.assertEqual(result, 42)
36+
37+
def test_returnsComplexResult(self):
38+
"""Test that runCoroutineSync returns complex objects."""
39+
40+
async def complexCoroutine():
41+
await asyncio.sleep(0.01)
42+
return {"key": "value", "number": 123}
43+
44+
result = runCoroutineSync(complexCoroutine())
45+
self.assertEqual(result, {"key": "value", "number": 123})
46+
47+
def test_raisesException(self):
48+
"""Test that runCoroutineSync raises exceptions from the coroutine."""
49+
50+
async def failingCoroutine():
51+
await asyncio.sleep(0.01)
52+
raise ValueError("Test error message")
53+
54+
with self.assertRaises(ValueError) as cm:
55+
runCoroutineSync(failingCoroutine())
56+
self.assertEqual(str(cm.exception), "Test error message")
57+
58+
def test_timeoutRaisesTimeoutError(self):
59+
"""Test that runCoroutineSync raises TimeoutError when timeout is exceeded."""
60+
61+
async def slowCoroutine():
62+
await asyncio.sleep(10)
63+
return "Should not reach here"
64+
65+
with self.assertRaises(TimeoutError) as cm:
66+
runCoroutineSync(slowCoroutine(), timeout=0.1)
67+
self.assertIn("timed out", str(cm.exception).lower())
68+
69+
def test_noTimeoutWaitsIndefinitely(self):
70+
"""Test that runCoroutineSync waits indefinitely when no timeout is specified."""
71+
72+
async def delayedCoroutine():
73+
await asyncio.sleep(0.1)
74+
return "completed"
75+
76+
result = runCoroutineSync(delayedCoroutine())
77+
self.assertEqual(result, "completed")
78+
79+
def test_raisesRuntimeErrorWhenEventLoopNotRunning(self):
80+
"""Test that runCoroutineSync raises RuntimeError when event loop is not running."""
81+
# Save original thread reference
82+
originalThread = _asyncioEventLoop._state.asyncioThread
83+
84+
# Temporarily set to None to simulate not running
85+
_asyncioEventLoop._state.asyncioThread = None
86+
87+
async def anyCoroutine():
88+
return "test"
89+
90+
with self.assertRaises(RuntimeError) as cm:
91+
runCoroutineSync(anyCoroutine())
92+
self.assertIn("not running", str(cm.exception).lower())
93+
94+
# Restore original thread
95+
_asyncioEventLoop._state.asyncioThread = originalThread

user_docs/en/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ It only ran the translation string comment check, which is equivalent to `scons
6161
The `scons checkPot` target has also been replaced with `runcheckpot.bat`.
6262
Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, `runsystemtests.bat`, `runlint.bat`. (#19606, #19676, @bramd)
6363
* Updated Python 3.13.11 to 3.13.12 (#19572, @dpy013)
64+
* Added a private `_asyncioEventLoop` module that provides an asyncio event loop running on a background thread for use by NVDA components. (#19816, @bramd)
6465

6566
#### Deprecations
6667

0 commit comments

Comments
 (0)