Skip to content

Possible issue: ThreadsafeProxy silently returns None when proxied event loop is closed, causing 'NoneType' object can't be awaited downstream #722

@zigpy-review-bot

Description

@zigpy-review-bot

⚠️ This is an investigation, not a fresh confirmed reproduction. I read the code path and matched it against an existing in-the-wild traceback. Filing as a tracking issue since the in-flight fix lives in #720 alongside several unrelated changes and there is no single issue covering it.

HA core home-assistant/core#168432 (April 2026, SLZB-06M over the network, Python 3.14, bellows 0.49.0) includes:

File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 224, in disconnect
    await self._gw.disconnect()
TypeError: 'NoneType' object can't be awaited

bellows/ezsp/__init__.py:221-225 already guards if self._gw: before the await:

async def disconnect(self):
    self.stop_ezsp()
    if self._gw:
        await self._gw.disconnect()
        self._gw = None

So self._gw itself is not the None. The None is the return value of self._gw.disconnect(). _gw is a ThreadsafeProxy, so the call routes through ThreadsafeProxy.__getattr__ (bellows/thread.py:81-110):

def func_wrapper(*args, **kwargs):
    loop = self._obj_loop
    curr_loop = asyncio.get_running_loop()
    call = functools.partial(func, *args, **kwargs)
    if loop == curr_loop:
        return call()
    if loop.is_closed():
        # Disconnected
        LOGGER.warning("Attempted to use a closed event loop")
        return                              # ← silent None
    if asyncio.iscoroutinefunction(func):
        future = asyncio.run_coroutine_threadsafe(call(), loop)
        return asyncio.wrap_future(future, loop=curr_loop)
    else:
        ...

If the proxied EventLoopThread's loop is closed (i.e. the worker thread has already exited or its loop has been closed) at the moment of the call, the proxy logs a warning and returns plain None. An async caller doing await proxy.some_async_method() then awaits None and gets TypeError: 'NoneType' object can't be awaited. The error propagates up _async_initializeshutdowndisconnect and leaves ZHA in an indefinite retry loop, since the cleanup itself fails.

There is also a small race between the loop.is_closed() check and run_coroutine_threadsafe(...) (the loop can become closed in between), which would today raise from run_coroutine_threadsafe and is otherwise survivable, but worth addressing in the same place.

Already-in-flight fix

@silenthooligan's open PR #720 fixes this as part of a larger bundle (Python 3.14 deprecation cleanup, EZSP startup-reset race for TCP serial bridges, and tolerance for _gw is None in _startup_reset / disconnect). The relevant change raises ConnectionError instead of returning None, and guards the loop-closed-mid-dispatch race.

Filing this mainly to give the bug a tracking point that is decoupled from the bundled PR. Happy to close as duplicate if maintainers prefer to track entirely from #720, or to split #720 into smaller PRs by topic.

What I have not verified

  • I have not produced a fresh reproducer log. The traceback is from the original #168432 report.
  • On a current Python 3.14.4 install I tested, asyncio.iscoroutinefunction works (it emits a DeprecationWarning but still returns the right boolean), so the proxy's coroutine-detection path itself is functioning. The closed-loop branch is the path most likely firing in the reported case, triggered by an ESPHome serial-bridge connection blip on the SLZB-06M.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions