Skip to content
Open
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
6 changes: 5 additions & 1 deletion bellows/ezsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ async def get_xncp_features(self) -> xncp.FirmwareFeatures:
async def disconnect(self):
self.stop_ezsp()
if self._gw:
await self._gw.disconnect()
try:
await self._gw.disconnect()
except ConnectionResetError:
# A closed gateway loop is already disconnected.
pass
self._gw = None

async def _command(self, name: str, *args: Any, **kwargs: Any) -> Any:
Expand Down
46 changes: 30 additions & 16 deletions bellows/thread.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
import functools
import inspect
import logging

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -88,6 +89,23 @@ def __getattr__(self, name):
)
)

if inspect.iscoroutinefunction(func):

async def async_func_wrapper(*args, **kwargs):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour note for the record — no change requested.

Making this an async def moves the run_coroutine_threadsafe call from call time to await time. Previously proxy.some_async_method(...) scheduled the coroutine on the worker loop immediately and handed back a Future; now it hands back a coroutine that does nothing until awaited.

All four in-repo call sites await immediately, so nothing breaks today. But it does mean:

  • a future fire-and-forget call (proxy.send_data(x) with no await) would silently never run, and only surface as a RuntimeWarning: coroutine ... was never awaited;
  • the return value is no longer a Future, so add_done_callback, cancel, and bare asyncio.wait() no longer work on it.

Probably worth a one-line docstring or comment on the wrapper so the eager-vs-lazy distinction isn't rediscovered later.

"""Run async proxy calls when this returned coroutine is awaited."""
loop = self._obj_loop
curr_loop = asyncio.get_running_loop()
call = functools.partial(func, *args, **kwargs)
if loop == curr_loop:
return await call()
if loop.is_closed():
LOGGER.warning("Attempted to use a closed event loop")
raise ConnectionResetError("Attempted to use a closed event loop")
future = asyncio.run_coroutine_threadsafe(call(), loop)
return await asyncio.wrap_future(future, loop=curr_loop)

return async_func_wrapper

def func_wrapper(*args, **kwargs):
loop = self._obj_loop
curr_loop = asyncio.get_running_loop()
Expand All @@ -98,21 +116,17 @@ def func_wrapper(*args, **kwargs):
# Disconnected
LOGGER.warning("Attempted to use a closed event loop")
return
if asyncio.iscoroutinefunction(func):
future = asyncio.run_coroutine_threadsafe(call(), loop)
return asyncio.wrap_future(future, loop=curr_loop)
else:

def check_result_wrapper():
result = call()
if result is not None:
raise TypeError(
(
"ThreadsafeProxy can only wrap functions with no return"
"value \nUse an async method to return values: {}.{}"
).format(self._obj.__class__.__name__, name)
)

loop.call_soon_threadsafe(check_result_wrapper)

def check_result_wrapper():
result = call()
if result is not None:
raise TypeError(
(
"ThreadsafeProxy can only wrap functions with no return"
"value \nUse an async method to return values: {}.{}"
).format(self._obj.__class__.__name__, name)
)

loop.call_soon_threadsafe(check_result_wrapper)

return func_wrapper
8 changes: 8 additions & 0 deletions tests/test_ezsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ async def test_disconnect(ezsp_f):
assert len(gw_disconnect.mock_calls) == 1


async def test_disconnect_closed_gateway_loop(ezsp_f):
ezsp_f._gw.disconnect = AsyncMock(side_effect=ConnectionResetError)

await ezsp_f.disconnect()

assert ezsp_f._gw is None


def test_attr(ezsp_f):
m = ezsp_f.getValue
assert isinstance(m, functools.partial)
Expand Down
18 changes: 17 additions & 1 deletion tests/test_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ async def magic():
return mock.sentinel.result

obj.test = magic
result = await proxy.test()
call = proxy.test()
assert call_count == 0
result = await call

assert call_count == 1
assert result == mock.sentinel.result
Expand Down Expand Up @@ -161,6 +163,20 @@ async def test_proxy_loop_closed():
assert obj.test.call_count == 0


async def test_proxy_async_loop_closed():
loop = asyncio.new_event_loop()
obj = mock.MagicMock()

obj.test = mock.AsyncMock()
proxy = ThreadsafeProxy(obj, loop)
loop.close()

with pytest.raises(ConnectionResetError, match="closed event loop"):
await proxy.test()

obj.test.assert_not_awaited()


async def test_thread_task_cancellation_after_stop(thread):
loop = asyncio.get_event_loop()
obj = mock.MagicMock()
Expand Down