Skip to content

Commit 997e85e

Browse files
committed
Create asyncioEventLoop.runCoroutineSync helper function to call a coroutine, wait for the result and either return that result or any raised exceptions
1 parent 4d8bc8d commit 997e85e

File tree

4 files changed

+140
-31
lines changed

4 files changed

+140
-31
lines changed

source/asyncioEventLoop.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ def runCoroutine(coro: Coroutine) -> asyncio.Future:
5858
5959
:param coro: The coroutine to run.
6060
"""
61-
if not asyncioThread.is_alive():
61+
if asyncioThread is None or not asyncioThread.is_alive():
6262
raise RuntimeError("Asyncio event loop thread is not running")
6363
return asyncio.run_coroutine_threadsafe(coro, eventLoop)
64+
65+
66+
def runCoroutineSync(coro: Coroutine, timeout: float | None = None):
67+
"""Schedule a coroutine to be run on the asyncio event loop and wait for the result.
68+
69+
This is a synchronous wrapper around runCoroutine() that blocks until the coroutine
70+
completes and returns the result directly, or raises any exception that occurred.
71+
72+
:param coro: The coroutine to run.
73+
:param timeout: Optional timeout in seconds. If None, waits indefinitely.
74+
:return: The result of the coroutine.
75+
:raises: Any exception raised by the coroutine.
76+
:raises TimeoutError: If the timeout is exceeded.
77+
:raises RuntimeError: If the asyncio event loop thread is not running.
78+
"""
79+
future = runCoroutine(coro)
80+
try:
81+
# Wait for the future to complete and get the result
82+
# This will raise any exception that occurred in the coroutine
83+
return future.result(timeout)
84+
except asyncio.TimeoutError as e:
85+
# Cancel the coroutine since it timed out
86+
future.cancel()
87+
raise TimeoutError(f"Coroutine execution timed out after {timeout} seconds") from e

source/hwIo/ble/_io.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Callable, Iterator
1010
import weakref
1111

12-
from asyncioEventLoop import runCoroutine
12+
from asyncioEventLoop import runCoroutineSync
1313
from ..base import _isDebug, IoBase
1414
from ..ioThread import IoThread
1515
from logHandler import log
@@ -120,10 +120,7 @@ def __init__(
120120
daemon=True,
121121
)
122122
self._readerThread.start()
123-
f = runCoroutine(self._initAndConnect())
124-
f.result()
125-
if f.exception():
126-
raise f.exception()
123+
runCoroutineSync(self._initAndConnect(), timeout=CONNECT_TIMEOUT_SECONDS)
127124
self.waitForConnection(CONNECT_TIMEOUT_SECONDS)
128125

129126
async def _initAndConnect(self) -> None:
@@ -157,19 +154,16 @@ def write(self, data: bytes):
157154

158155
# Split the data into chunks that fit within the MTU
159156
for s in sliced(data, characteristic.max_write_without_response_size):
160-
f = runCoroutine(
157+
runCoroutineSync(
161158
self._client.write_gatt_char(characteristic, s, response=False),
162159
)
163-
f.result()
164-
if f.exception():
165-
raise f.exception()
166160

167161
def close(self) -> None:
168162
"""Disconnect the BLE peripheral and release resources."""
169163
if _isDebug():
170164
log.debug("Closing BLE connection")
171165
if self._client.is_connected:
172-
runCoroutine(self._client.disconnect()).result()
166+
runCoroutineSync(self._client.disconnect())
173167
self._queuedData.join()
174168
self._stopReaderEvent.set()
175169
self._readerThread.join()
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 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+
13+
14+
class TestRunCoroutineSync(unittest.TestCase):
15+
"""Tests for runCoroutineSync function."""
16+
17+
@classmethod
18+
def setUpClass(cls):
19+
"""Initialize the asyncio event loop before tests."""
20+
asyncioEventLoop.initialize()
21+
22+
@classmethod
23+
def tearDownClass(cls):
24+
"""Terminate the asyncio event loop after tests."""
25+
asyncioEventLoop.terminate()
26+
27+
def test_returnsResult(self):
28+
"""Test that runCoroutineSync returns the coroutine's result."""
29+
30+
async def simpleCoroutine():
31+
return 42
32+
33+
result = asyncioEventLoop.runCoroutineSync(simpleCoroutine())
34+
self.assertEqual(result, 42)
35+
36+
def test_returnsComplexResult(self):
37+
"""Test that runCoroutineSync returns complex objects."""
38+
39+
async def complexCoroutine():
40+
await asyncio.sleep(0.01)
41+
return {"key": "value", "number": 123}
42+
43+
result = asyncioEventLoop.runCoroutineSync(complexCoroutine())
44+
self.assertEqual(result, {"key": "value", "number": 123})
45+
46+
def test_raisesException(self):
47+
"""Test that runCoroutineSync raises exceptions from the coroutine."""
48+
49+
async def failingCoroutine():
50+
await asyncio.sleep(0.01)
51+
raise ValueError("Test error message")
52+
53+
with self.assertRaises(ValueError) as cm:
54+
asyncioEventLoop.runCoroutineSync(failingCoroutine())
55+
self.assertEqual(str(cm.exception), "Test error message")
56+
57+
def test_timeoutRaisesTimeoutError(self):
58+
"""Test that runCoroutineSync raises TimeoutError when timeout is exceeded."""
59+
60+
async def slowCoroutine():
61+
await asyncio.sleep(10)
62+
return "Should not reach here"
63+
64+
with self.assertRaises(TimeoutError) as cm:
65+
asyncioEventLoop.runCoroutineSync(slowCoroutine(), timeout=0.1)
66+
self.assertIn("timed out", str(cm.exception).lower())
67+
68+
def test_noTimeoutWaitsIndefinitely(self):
69+
"""Test that runCoroutineSync waits indefinitely when no timeout is specified."""
70+
71+
async def delayedCoroutine():
72+
await asyncio.sleep(0.1)
73+
return "completed"
74+
75+
# This should complete successfully even though it takes some time
76+
result = asyncioEventLoop.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.asyncioThread
83+
84+
# Temporarily set to None to simulate not running
85+
asyncioEventLoop.asyncioThread = None
86+
87+
async def anyCoroutine():
88+
return "test"
89+
90+
with self.assertRaises(RuntimeError) as cm:
91+
asyncioEventLoop.runCoroutineSync(anyCoroutine())
92+
self.assertIn("not running", str(cm.exception).lower())
93+
94+
# Restore original thread
95+
asyncioEventLoop.asyncioThread = originalThread

tests/unit/test_hwIo_ble.py

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,10 @@ def setUp(self):
216216
self.mockServices.services = mockServicesDict
217217
self.mockClient.services = self.mockServices
218218

219-
# Patch runCoroutine
220-
self.runCoroutinePatcher = patch("hwIo.ble._io.runCoroutine")
221-
self.mockRunCoroutine = self.runCoroutinePatcher.start()
222-
mockFuture = MagicMock()
223-
mockFuture.result.return_value = None
224-
mockFuture.exception.return_value = None
225-
self.mockRunCoroutine.return_value = mockFuture
219+
# Patch runCoroutineSync (just returns None, as it's a synchronous wrapper)
220+
self.runCoroutineSyncPatcher = patch("hwIo.ble._io.runCoroutineSync")
221+
self.mockRunCoroutineSync = self.runCoroutineSyncPatcher.start()
222+
self.mockRunCoroutineSync.return_value = None
226223

227224
# Import Ble after patching
228225
from hwIo.ble._io import Ble
@@ -231,7 +228,7 @@ def setUp(self):
231228

232229
def tearDown(self):
233230
"""Clean up patches."""
234-
self.runCoroutinePatcher.stop()
231+
self.runCoroutineSyncPatcher.stop()
235232
self.bleakClientPatcher.stop()
236233

237234
def test_connectionSuccess(self):
@@ -265,8 +262,8 @@ def onReceive(data):
265262
callArgs = self.mockBleakClientClass.call_args
266263
self.assertEqual(callArgs[0][0], mockDevice)
267264

268-
# Verify runCoroutine was called at least once (_initAndConnect contains connect + start_notify)
269-
self.assertGreaterEqual(self.mockRunCoroutine.call_count, 1)
265+
# Verify runCoroutineSync was called for initialization
266+
self.mockRunCoroutineSync.assert_called()
270267

271268
# Verify the connection is established
272269
self.assertTrue(ble.isConnected())
@@ -296,8 +293,8 @@ def test_writeData(self):
296293
self.mockServices.get_service.assert_called_with("service-uuid")
297294
self.mockService.get_characteristic.assert_called_with("write-char-uuid")
298295

299-
# Verify write was called (through runCoroutine)
300-
self.mockRunCoroutine.assert_called()
296+
# Verify write was called (through runCoroutineSync)
297+
self.assertGreater(self.mockRunCoroutineSync.call_count, 1) # At least init + write
301298

302299
def test_writeDataChunking(self):
303300
"""Test that large data is split into MTU-sized chunks."""
@@ -320,14 +317,14 @@ def test_writeDataChunking(self):
320317
)
321318

322319
# Reset call count after initialization
323-
initialCallCount = self.mockRunCoroutine.call_count
320+
initialCallCount = self.mockRunCoroutineSync.call_count
324321

325322
# Write 25 bytes (should split into 3 chunks: 10, 10, 5)
326323
testData = b"A" * 25
327324
ble.write(testData)
328325

329-
# Verify runCoroutine was called 3 times for writes (plus initial calls)
330-
writeCalls = self.mockRunCoroutine.call_count - initialCallCount
326+
# Verify runCoroutineSync was called 3 times for writes
327+
writeCalls = self.mockRunCoroutineSync.call_count - initialCallCount
331328
self.assertEqual(writeCalls, 3)
332329

333330
def test_receiveNotification(self):
@@ -353,9 +350,8 @@ def onReceive(data):
353350
ioThread=mockIoThread,
354351
)
355352

356-
# Get the notification callback that was registered
357-
# It should be in the call to start_notify via runCoroutine
358-
self.assertTrue(self.mockRunCoroutine.called)
353+
# Verify initialization occurred
354+
self.mockRunCoroutineSync.assert_called()
359355

360356
# Simulate notification by calling _notifyReceive directly
361357
testData = bytearray(b"notification data")
@@ -385,8 +381,8 @@ def test_closeCleanup(self):
385381
# Close the connection
386382
ble.close()
387383

388-
# Verify disconnect was called via runCoroutine
389-
self.assertTrue(self.mockRunCoroutine.called)
384+
# Verify disconnect was called via runCoroutineSync (init + close)
385+
self.assertGreater(self.mockRunCoroutineSync.call_count, 1)
390386

391387
# Verify onReceive callback was cleared
392388
self.assertIsNone(ble._onReceive)

0 commit comments

Comments
 (0)