Skip to content

Handle async proxy calls after event loop closes - #730

Open
Wackymax wants to merge 2 commits into
zigpy:devfrom
Wackymax:agent/handle-closed-async-proxy
Open

Handle async proxy calls after event loop closes#730
Wackymax wants to merge 2 commits into
zigpy:devfrom
Wackymax:agent/handle-closed-async-proxy

Conversation

@Wackymax

Copy link
Copy Markdown

What changed

  • make coroutine methods exposed by ThreadsafeProxy consistently return an awaitable wrapper
  • treat calls made after the worker event loop has closed as a disconnected no-op
  • add a regression test covering an async proxy method after loop closure

Why

When a threaded EZSP transport loses its connection, the worker event loop can already be closed by the time ZHA asks bellows to disconnect. The existing proxy logs Attempted to use a closed event loop and returns plain None, even when the proxied method is asynchronous. The caller then attempts to await that value and raises:

TypeError: object NoneType can't be used in 'await' expression

This secondary cleanup exception can prevent Home Assistant's ZHA config-entry reload from recovering after the original transport failure.

The async wrapper now remains awaitable in every state. If the target loop is closed, awaiting it returns None, matching the existing disconnected/no-op behavior without raising a second exception.

Related reports:

Validation

  • added test_proxy_async_loop_closed
  • python -m pytest -q: 425 passed
  • git diff --check

@Wackymax
Wackymax marked this pull request as ready for review July 16, 2026 05:40
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.54%. Comparing base (cd3378f) to head (a33c704).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #730   +/-   ##
=======================================
  Coverage   99.54%   99.54%           
=======================================
  Files          61       61           
  Lines        4181     4191   +10     
=======================================
+ Hits         4162     4172   +10     
  Misses         19       19           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@zigpy-review-bot zigpy-review-bot left a comment

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.

Thanks for tracking this down — the bug is real and I reproduced it against dev.

On dev, ThreadsafeProxy.func_wrapper is a sync function, so for a coroutine method on a closed loop it falls into the # Disconnected branch and returns a bare None. The only in-repo caller that hits this is EZSP.disconnect() (bellows/ezsp/__init__.py:224, await self._gw.disconnect()disconnect is async via zigpy.serial.SerialProtocol), which then raises TypeError: 'NoneType' object can't be awaited. Confirmed with a minimal repro on cd3378f:

Attempted to use a closed event loop
RAISED: TypeError 'NoneType' object can't be awaited

Splitting the coroutine path into its own async def wrapper fixes that cleanly, and the whole suite passes on the PR head (425 passed, bellows/thread.py at 99% coverage).

One substantive thing I'd like a maintainer call on before this lands, plus two nits — details inline.

The return None is now a silent success for every async proxy method, not just disconnect(). disconnect() genuinely wants a no-op, but reset() and send_data() share the wrapper and now report success on a dead loop. That trades a loud (if ugly) TypeError for a quiet wrong-state. Worth deciding deliberately rather than inheriting it from the sync path's no-op convention — see the inline comment on bellows/thread.py:102.

Not a problem, but worth recording: the refactor also changes when the call is scheduled. On dev the wrapper was sync, so proxy.some_async_method(...) called run_coroutine_threadsafe immediately and returned a Future; now it returns a coroutine and nothing runs until it's awaited. I verified this empirically against a live EventLoopThread (dev: scheduled-without-await True, return type Future; PR: False, return type coroutine). I checked all four in-repo call sites — wait_for_startup_reset (ezsp/__init__.py:124), reset (:165), disconnect (:224), send_data (ezsp/protocol.py:124) — and every one awaits immediately, so nothing regresses today. It does mean a future fire-and-forget call site would silently never run, and the returned object no longer supports Future APIs (add_done_callback, cancel, asyncio.wait).

A second opinion from GitHub Copilot (GPT-5.5) was run on this PR; it independently flagged the same closed-loop silent-success concern, which I then verified against the code.

(Unrelated and pre-existing: the stopped-but-not-yet-closed window in EventLoopThread.force_stop — between loop.stop() and _thread_main's finally: self.loop.close() — still passes is_closed(), so run_coroutine_threadsafe schedules a callback that never runs and the await hangs with no timeout. Same on dev; not something this PR needs to solve.)

Comment thread bellows/thread.py Outdated
if loop.is_closed():
# Disconnected
LOGGER.warning("Attempted to use a closed event loop")
return None

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.

This makes a closed loop a silent success for all async proxy methods, which is broader than the bug being fixed.

EZSP.disconnect() wants exactly this no-op, but two other callers share the wrapper and now get a false success:

  • EZSP.reset() (bellows/ezsp/__init__.py:162-169) does await self._gw.reset() and then unconditionally calls self.start_ezsp(). With None returned, EZSP is marked running even though no reset frame was ever sent.
  • ProtocolHandler.command() (bellows/ezsp/protocol.py:124) does await self._gw.send_data(data) and then async with asyncio_timeout(EZSP_CMD_TIMEOUT): return await future. The frame was never written, but the caller now blocks for the full command timeout instead of failing immediately.

On dev both of those raised TypeError right away — noisy, but at least the failure surfaced at the failing operation. In practice all of this happens on the teardown path, so the impact is probably small, but it's a deliberate choice worth making rather than inheriting from the sync path's no-op convention.

One option: raise a ConnectionResetError (or a zigpy ControllerException) here instead, and let EZSP.disconnect() suppress it — that keeps disconnect() idempotent while reset()/send_data() still fail loudly. Entirely your call, though; if the blanket None is the intent, a short comment saying so would help the next reader.

Comment thread bellows/thread.py

if asyncio.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.

Comment thread bellows/thread.py Outdated
)
)

if asyncio.iscoroutinefunction(func):

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.

Optional drive-by, since this line is being moved anyway: asyncio.iscoroutinefunction is deprecated and slated for removal in Python 3.16. It's already emitting a DeprecationWarning in the test suite on this file:

bellows/thread.py:91: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and
slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead

I checked that inspect.iscoroutinefunction is a drop-in for every callable shape this proxy sees — bound async def methods (the real case, e.g. Gateway.send_data), plain async def, AsyncMock, and MagicMock attributes all agree between the two. Pre-existing, so feel free to leave it for a separate PR.

Comment thread tests/test_thread.py Outdated
proxy = ThreadsafeProxy(obj, loop)
loop.close()

assert await proxy.test() is None

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.

The test proves the call is awaitable and yields None, which is the regression that matters. Two small strengthening ideas:

  • The sibling test_proxy_loop_closed asserts obj.test.call_count == 0 — i.e. that the target was genuinely not invoked. This test can't, because test is a plain function. Wrapping it (obj.test = mock.AsyncMock(wraps=test) or a nonlocal counter) would let you assert the same thing here, which is the other half of "treat it as a disconnected no-op".
  • mock.sentinel.result is currently unreachable — it only exists to make the assertion meaningful if the guard ever regresses. That's fine, but a counter would make the intent clearer.

Also worth considering: a test pinning the new eager-vs-lazy semantics (that calling without awaiting schedules nothing), so a future refactor back to a sync wrapper doesn't silently flip it.

@realKim-dotcom

Copy link
Copy Markdown

Field report: this reproduces roughly daily on bellows 1.0.0 / HA 2026.8.2, and this PR fixes the cleanup half of it.

@puddly asked on #720 for logs from a real installation showing the problem — here is one, on a non-SMLIGHT coordinator.

Setup. SONOFF Dongle Max (EFR32MG24 NCP) used as a network coordinator over WiFi, socket://<lan-ip>:6638 (raw TCP bridge, single client slot). Home Assistant 2026.8.2 (Container), Python 3.14.6, bellows 1.0.0, zigpy 2.1.0, zha 2.1.0. Same site as my comment on home-assistant/core#168432 from August — it was on bellows 0.49.2 then, and the behaviour did not change across the upgrade to 1.0.0 (consistent with #727/#739 not touching the proxy).

Frequency. An external watchdog polls the ZHA config entry state every 5 minutes. Since 2026-08-11 the entry has been stuck in a non-loaded state on 14 of 26 days (08-11, 13, 14, 15, 16, 19, 20, 21, 26, 30, 09-01, 02, 03, 05). Reloading the entry has never recovered it; a full Home Assistant restart always has.

Latest episode, 2026-09-05 (WARNING level; three of the failure modes discussed in #722 / #740 / #745 appear in one incident):

16:21:22.511 WARNING (bellows.thread_0) [bellows.uart] Received an unexpected reset: <NcpResetCode.RESET_SOFTWARE: 11>
16:26:49.145 WARNING (bellows.thread_0) [py.warnings] ...: RuntimeWarning: coroutine 'Gateway.wait_for_startup_reset' was never awaited
16:26:51.140 WARNING (MainThread) [bellows.thread] Attempted to use a closed event loop   (x5)
16:26:51.142 WARNING (MainThread) [zigpy.application] Failed to disconnect from radio
Traceback (most recent call last):
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 149, in startup_reset
    await self._startup_reset()
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 131, in _startup_reset
    await self._gw.wait_for_startup_reset()
TypeError: 'NoneType' object can't be awaited

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.14/site-packages/bellows/zigbee/application.py", line 189, in connect
    await self._ezsp.connect(use_thread=self.config[CONF_USE_THREAD])
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 167, in connect
    await self.startup_reset()
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 161, in startup_reset
    await self.disconnect()
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 231, in disconnect
    await self._gw.disconnect()
TypeError: 'NoneType' object can't be awaited

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.14/site-packages/zigpy/application.py", line 329, in startup
    await self.connect()
  File "/usr/local/lib/python3.14/site-packages/bellows/zigbee/application.py", line 198, in connect
    await self._ezsp.disconnect()
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 231, in disconnect
    await self._gw.disconnect()
TypeError: 'NoneType' object can't be awaited

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.14/site-packages/zigpy/application.py", line 602, in shutdown
    await self.disconnect()
  File "/usr/local/lib/python3.14/site-packages/bellows/zigbee/application.py", line 615, in disconnect
    await self._ezsp.disconnect()
  File "/usr/local/lib/python3.14/site-packages/bellows/ezsp/__init__.py", line 231, in disconnect
    await self._gw.disconnect()
TypeError: 'NoneType' object can't be awaited

The identical cascade repeated at 16:30:51. The never awaited RuntimeWarning two seconds before it is the check-then-use race from #740/#741 (coroutine created, run_coroutine_threadsafe raised on the closing loop, coroutine never closed). After that the config entry went setup_retrysetup_in_progress and stayed in setup_in_progress for 205 minutes — a hang, not a crash, which matches the third row of the table in #745 (loop stopped but not yet closed → the proxied future never resolves). It cleared only with an HA restart at 20:01.

I agree with the point made on core#168432 that the hiccup is the coordinator's doing (the unexpected RESET_SOFTWARE above is the NCP's). What is bellows' doing is that the hiccup cannot be recovered from without a process restart: every teardown path — reconnect, unload, reload — dies on ezsp/__init__.py:231 before self._gw = None, and ControllerApplication.disconnect() before self._ezsp = None.

On this PR. Before finding this thread I had patched the same three sites locally (proxy raises on a closed loop; EZSP.disconnect() tolerates it and always drops _gw). #730 applies cleanly to the 1.0.0 tag and the full test suite passes with it; the six-line reproducer (ThreadsafeProxy(obj, loop), loop.close(), await proxy.disconnect()) goes from TypeError to the intended ConnectionResetError. Two small suggestions:

  • ControllerApplication.connect()'s except branch and ControllerApplication.disconnect() (zigbee/application.py around lines 196–199 and 612–616) have the same shape — await self._ezsp.disconnect() then self._ezsp = None — so if disconnect() raises for any other reason the application keeps a dead EZSP. Moving the assignment into a finally closes that.
  • As connection_lost() tears down the worker loop before notifying the application, so disconnect() races the teardown #745 points out, this makes teardown complete but does not by itself make the old transport get closed when the worker loop is torn down first, so the reconnect may still not succeed. Happy to test a patch for that ordering here — this installation reproduces the wedge often enough to give an answer within days.

Workaround in the meantime, if useful to anyone reading: zha: → zigpy_config: → use_thread: false in configuration.yaml (bellows' CONF_USE_THREAD) runs the serial link on HA's main loop, so there is no worker loop for the proxy to lose. Running that as of today; I will report back whether the wedges stop.

(Log triage and the bench checks above were done with Claude Code; the installation, the watchdog data and the workaround run are real.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants