-
-
Notifications
You must be signed in to change notification settings - Fork 769
Add private _asyncioEventLoop module #19816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| # 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 | ||
| """ | ||
|
|
||
| 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." | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.