Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions source/_asyncioEventLoop/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
19 changes: 19 additions & 0 deletions source/_asyncioEventLoop/_state.py
Original file line number Diff line number Diff line change
@@ -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."""
44 changes: 44 additions & 0 deletions source/_asyncioEventLoop/utils.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions source/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/test_asyncioEventLoop.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading