Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
89 changes: 89 additions & 0 deletions source/_asyncioEventLoop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# A part of NonVisual Desktop Access (NVDA)
Comment thread
bramd marked this conversation as resolved.
Outdated
# 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
"""

import asyncio
from collections.abc import Coroutine
from threading import Thread

from logHandler import log

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."
Comment thread
bramd marked this conversation as resolved.
Outdated


def initialize():
"""Initialize and start the asyncio event loop."""
global eventLoop, asyncioThread
log.info("Initializing asyncio event loop")
eventLoop = asyncio.new_event_loop()
asyncio.set_event_loop(eventLoop)
asyncioThread = Thread(target=eventLoop.run_forever, daemon=True)
asyncioThread.start()


def terminate():
global eventLoop, asyncioThread
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:
eventLoop.call_soon_threadsafe(eventLoop.stop)

asyncioThread.join()
asyncioThread = None
eventLoop.close()


def runCoroutine(coro: Coroutine) -> asyncio.Future:
"""Schedule a coroutine to be run on the asyncio event loop.

:param coro: The coroutine to run.
"""
if asyncioThread is None or not asyncioThread.is_alive():
raise RuntimeError("Asyncio event loop thread is not running")
return asyncio.run_coroutine_threadsafe(coro, 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:
# Wait for the future to complete and get the result
# This will raise any exception that occurred in the coroutine
return future.result(timeout)
except asyncio.TimeoutError as e:
# Cancel the coroutine since it timed out
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


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 = _asyncioEventLoop.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 = _asyncioEventLoop.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:
_asyncioEventLoop.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:
_asyncioEventLoop.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"

# This should complete successfully even though it takes some time
result = _asyncioEventLoop.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.asyncioThread

# Temporarily set to None to simulate not running
_asyncioEventLoop.asyncioThread = None

async def anyCoroutine():
return "test"

with self.assertRaises(RuntimeError) as cm:
_asyncioEventLoop.runCoroutineSync(anyCoroutine())
self.assertIn("not running", str(cm.exception).lower())

# Restore original thread
_asyncioEventLoop.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 @@ -49,6 +49,7 @@ The triple-press keyboard shortcut (`NVDA+ctrl+r`) is not affected, as it is int
### Changes for Developers

* NVDA libraries built by the build system are now linked with the [/SETCOMPAT](https://learn.microsoft.com/en-us/cpp/build/reference/cetcompat) flag, improving protection against certain malware attacks. (#19435, @LeonarddeR)
* Added a private `_asyncioEventLoop` module that provides an asyncio event loop running on a background thread for use by NVDA components. (#19816, @bramd)
Comment thread
bramd marked this conversation as resolved.
Outdated

Please refer to [the developer guide](https://download.nvaccess.org/documentation/developerGuide.html#API) for information on NVDA's API deprecation and removal process.

Expand Down
Loading