Handle the loop closing between ThreadsafeProxy's is_closed() check and dispatch - #741
Handle the loop closing between ThreadsafeProxy's is_closed() check and dispatch#741zigpy-review-bot wants to merge 4 commits into
ThreadsafeProxy's is_closed() check and dispatch#741Conversation
… and dispatch The worker thread can close the loop after `func_wrapper`'s `is_closed()` check but before `run_coroutine_threadsafe()` / `call_soon_threadsafe()`, raising `RuntimeError: Event loop is closed` at the caller -- the same race #727 suppressed in `force_stop()`. Treat close-during-dispatch like already-closed: log the existing warning and drop the call. Disconnected async proxy calls (both already-closed and closed-mid- dispatch) now return an already-completed future resolving to None instead of bare None, so `await proxy.method()` no longer raises `TypeError: object NoneType can't be used in 'await' expression`. Also raise a legible `RuntimeError` from `EventLoopThread.run_coroutine_threadsafe()` when the loop is already gone, instead of `AttributeError: 'NoneType' object has no attribute 'call_soon_threadsafe'`, closing the passed coroutine so it doesn't warn as never-awaited. Closes #740
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #741 +/- ##
=======================================
Coverage 99.55% 99.55%
=======================================
Files 64 64
Lines 4263 4292 +29
=======================================
+ Hits 4244 4273 +29
Misses 19 19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`asyncio.iscoroutinefunction()` is deprecated since Python 3.14 and slated for removal in 3.16; `inspect.iscoroutinefunction()` is a drop-in replacement for every case here (coroutine functions, plain callables, `functools.partial`, `AsyncMock`).
`EventLoopThread.run_coroutine_threadsafe`'s `None` guard covers the already-exited worker, but when the worker closes the loop after the snapshot, `asyncio.run_coroutine_threadsafe()` raises `RuntimeError` correctly while the passed coroutine leaks as never-awaited. Close it before re-raising, mirroring the proxy's async dispatch path.
|
There is one more member of the family that this PR does not close: the window where the
The caller ( Self-contained reproduction (Python 3.14, no bellows import needed): import asyncio, threading, time, contextlib
loop = asyncio.new_event_loop()
threading.Thread(target=lambda: (asyncio.set_event_loop(loop), loop.run_forever()),
daemon=True).start()
time.sleep(0.3)
loop.call_soon_threadsafe(loop.stop) # what force_stop() does
time.sleep(0.3)
print("is_closed:", loop.is_closed()) # False -> guard lets it through
async def victim(): return "ran"
fut = asyncio.run_coroutine_threadsafe(victim(), loop) # does NOT raise
try:
print("got:", fut.result(timeout=3))
except TimeoutError:
print("HANG: never resolves; caller has no timeout")
with contextlib.suppress(Exception):
loop.close()
try:
asyncio.run_coroutine_threadsafe(victim(), loop)
except RuntimeError as e:
print("after close:", e) # this branch IS handled by this PROutput: It also emits the same warning that shows up in the field, from the same place: I hit this variant in production on an EZSP-over-TCP coordinator (bellows 0.49.0, Returning an already-completed future in the disconnected branches (which this PR already I do not have a strong opinion on the fix, and the obvious cheap check is not quite right: Happy to test a patch — I can reproduce the real-world case on demand by cutting power to |
|
@zigpy-review-bot Review above comment, possibly implement here, or open new PR. Do review rounds, also on this PR. Also check #745 |
`loop.is_closed()` only flips once the loop has actually been closed, so it cannot answer the question the proxy is really asking: "will this loop still run what I hand it?". Between `EventLoopThread.force_stop()` and `loop.close()` the answer is no, but the loop still looks fine: `asyncio.run_coroutine_threadsafe()` accepts the coroutine without raising, the loop stops before running it, and closing drops the pending task — so the caller (`await self._gw.disconnect()`, which has no timeout) waits on a future that is never resolved. Unlike the `TypeError` and `RuntimeError` members of this family, that failure is completely silent: no exception, no log line, no retry. `force_stop()` now publishes `stopping = True` before it schedules anything — an explicit flag, because every loop-state check is racy here: `is_closed()` is `False` for the whole window and `is_running()` is `True` until `run_forever()` returns, so both only narrow it. `ThreadsafeProxy` takes the owning `EventLoopThread` (`uart._connect()` passes it through) and treats `stopping` exactly like an already-closed loop: sync calls dropped, async calls resolved to `None`, with a warning naming the shutdown. The check sits before the coroutine is constructed, so nothing leaks. `start()` clears the flag, and `EventLoopThread.run_coroutine_threadsafe()` applies the same guard. Reported by jetliuzhe on the PR, with a self-contained reproduction; verified against bellows itself (a proxy call dispatched right after `force_stop()` never resolves, with `Task was destroyed but it is pending!` at teardown). Three new tests, all failing on the unpatched code: `test_proxy_loop_stopping_async` pins the no-hang behavior end-to-end on a real worker thread (wedged so the window is reliably open, with a callee that cannot finish inside it, as `Gateway.disconnect()` cannot); `test_proxy_loop_stopping_sync` pins the dropped sync call; `test_thread_run_coroutine_threadsafe_stopping` pins the `RuntimeError` and the closed coroutine. `test_proxy_stopping_ignored_without_thread` and `test_thread_start_clears_stopping` pin the unchanged no-thread path and the restart path.
|
Confirmed, and fixed here in 7fb32e7 rather than in a new PR — it is the same window as the rest of this PR, one step earlier. I reproduced it against You were also right that One thing worth stating explicitly, since "the disconnect still does not actually run" is the reasonable worry about resolving to What is not fully closed, and is now named in the PR description: the proxy reads Your offer to test is very welcome: the branch is I answered #745 separately — short version: the ordering you identified is real, but flipping those two lines would not serialize the teardown against the application's cleanup, because the notification at Verification
|
Closes #740. Follow-up to #727 and #739 — this closes out the
RuntimeError: Event loop is closedteardown-race family, and, with the newest commit, the silent-hang variant of it reported on this PR (see also #745, which describes the same window from theuart.pyside).ThreadsafeProxy.func_wrappersnapshots the loop object at construction, so #739's ordering fix (publishself.loop = Nonebefore closing) can't reach it: the worker thread can close the loop between the proxy'sloop.is_closed()check and the dispatch viaasyncio.run_coroutine_threadsafe()/loop.call_soon_threadsafe(), and both raiseRuntimeError: Event loop is closedat the caller. This wraps both dispatches intry/except RuntimeErrorand treats close-during-dispatch exactly like already-closed: log the existing "Attempted to use a closed event loop" warning and drop the call. Theexceptis narrow — bothtryblocks wrap only the scheduling call (for the async path,call()merely constructs the coroutine; its body never runs there), so noRuntimeErrorfrom user code can be swallowed.Two findings from pre-open review (details below) are folded in:
is_closed()branch returned bareNonefor coroutine methods too, so real callers that await through a proxy (await self._gw.reset(),await self._gw.disconnect(),await self._gw.send_data(...)) gotTypeError: object NoneType can't be used in 'await' expressionwhen disconnected — trading one teardown exception for another. Both disconnected paths (already-closed and closed-mid-dispatch) now return an already-completed future for coroutine methods, soawait proxy.method()resolves cleanly toNone; sync calls still returnNoneas before. On the race path the never-to-be-scheduled coroutine is explicitly closed, so it doesn't warn as never-awaited.EventLoopThread.run_coroutine_threadsafe()raises legibly when the loop is gone. After ClearEventLoopThread.loopbefore closing it #739 the shutdown-window failure there wasAttributeError: 'NoneType' object has no attribute 'call_soon_threadsafe'; it now snapshotsself.loopand raisesRuntimeError("Event loop is not running"). The sole caller (uart.connect) already handles this viaexcept Exception.Two follow-up commits, from post-open review of the same family:
run_coroutine_threadsafe()'s failure paths. TheNoneguard closes it before raising, and when the worker closes the loop after the snapshot (soasyncio.run_coroutine_threadsafe()itself raisesRuntimeError), it is now closed before re-raising too — mirroring the proxy's async dispatch path — so neither failure mode leaks a "coroutine ... was never awaited"RuntimeWarningat teardown.inspect.iscoroutinefunction()replaces the deprecatedasyncio.iscoroutinefunction()(deprecated since Python 3.14, removal slated for 3.16; these were bellows' only two call sites). Drop-in for every case here: real coroutine functions, plain callables,functools.partial,AsyncMock.The third window: stopped, but not yet closed
jetliuzhe reported a third member of the same family on this PR, and it is the worst of the three because it is completely silent. Between
EventLoopThread.force_stop()(which schedulesloop.stop()) and_thread_main'sloop.close(), the worker loop reportsis_closed() == Falseandasyncio.run_coroutine_threadsafe()accepts work without raising — but the loop never runs it, and closing drops the pending task. The caller (await self._gw.disconnect(), which has no timeout) then waits on a future that is never resolved: no exception, no log line, no retry. Reproduced against bellows itself — a proxy call dispatched right afterforce_stop()never resolves, withTask was destroyed but it is pending!at teardown.The fix is an explicit flag rather than another loop-state check, because every loop-state check is racy here:
is_closed()staysFalsefor the whole window, andis_running()staysTrueuntilrun_forever()returns, so both only narrow it.force_stop()now publishesstopping = Truebefore it schedules anything;ThreadsafeProxytakes the owningEventLoopThread(uart._connect()passes it through, so theuse_thread=Falsepath and theapiproxy keep their old behavior) and treatsstoppingexactly like an already-closed loop: sync calls dropped, async calls resolved toNone, with a warning naming the shutdown. The check sits before the coroutine is constructed, so nothing leaks, andEventLoopThread.run_coroutine_threadsafe()applies the same guard.Dropping those calls costs nothing: by the time
force_stop()has run,connection_losthas already been through zigpy'sSerialProtocol, soGateway.disconnect()(close()+wait_until_closed()) is a no-op —_transportisNoneand the disconnected event is already set. All that mattered was that the call must neither hang nor raise, in any of the three windows.Known residual, deliberately not fixed here. The proxy reads
stoppingand then dispatches; ifforce_stop()lands between those two steps, the coroutine can still reach a loop that stops before running it. That is a few bytecodes wide instead of the entire shutdown — the reported failure, a dispatch well afterforce_stop(), is gone — but it is not zero, and it fails the same silent way. Closing it completely needs something that resolves the caller's future without depending on the loop running again, e.g. tracking dispatched futures and resolving them when the worker thread exits; a re-check on the worker loop's own thread before the task is created narrows it further, but still depends on that callback getting to run. Happy to add either here or as a follow-up if you want it.Tests
Ten new tests.
test_proxy_loop_closed_during_async_dispatchandtest_proxy_loop_closed_during_sync_dispatchpin the race window by makingloop.call_soon_threadsaferaiseRuntimeErrorwhileis_closed()still returnsFalse— a faithful stand-in, since a genuinely closed loop would trip the earlier guard.test_proxy_loop_closed_asyncpins the awaitable disconnected result on the already-closed branch;test_thread_run_coroutine_threadsafe_loop_not_runningpins theRuntimeErroron theNonepath;test_thread_run_coroutine_threadsafe_loop_closed_mid_dispatchpins the coroutine being closed (CORO_CLOSED) on the post-snapshot race path. All five fail ondevand pass with the change.Five more come with the stopping-loop commit.
test_proxy_loop_stopping_asyncpins the no-hang behavior end-to-end on a real worker thread: the thread is wedged so the stopping-but-not-closed window is reliably open, and the callee cannot finish inside it (asGateway.disconnect()cannot), so on the unpatched code the test fails with aTimeoutError— the reported hang itself.test_proxy_loop_stopping_syncpins the dropped sync call and the warning;test_thread_run_coroutine_threadsafe_stoppingpins theRuntimeErrorand the closed coroutine;test_proxy_stopping_ignored_without_threadandtest_thread_start_clears_stoppingpin the unchanged no-thread path and the restart path.Verification
pytest tests/→ 451 passed (Python 3.14.5), clean under-W error::RuntimeWarning -W error::DeprecationWarning(pins both the no-leaked-coroutine behavior and the deprecation removal); the 3 remainingAsyncMockwarnings intests/test_application.pyreproduce identically ondevbellows/thread.pyreverted todev: the five original tests fail (leakedRuntimeError×2,TypeErroronawait None,AttributeError, leaked never-awaited coroutine), rest passfunc_wrapper:test_proxy_loop_stopping_asyncfails withTimeoutError, i.e. it pins the actual hang rather than just the new code path;tests/test_thread.pyran 10× green for determinism (the wedge is what makes the window reliable)bellows.threadbefore the fix (proxy call afterforce_stop()never resolves;Task was destroyed but it is pending!) and re-run after it (resolves toNone, warning logged, no leaked coroutine under-W error::RuntimeWarning)except RuntimeErrorbreadth is safe and flagged the coroutine leak on the raise path) and a Copilot CLI second opinion (GPT-5.6 Sol; flagged theawait NoneTypeError) — both findings addressed as describedautoflake,black,flake8,isort,codespell,ruffpass;pyupgrade/mypyfail on untouched files in the local hook env only (same Python 3.14 breakage as noted on ClearEventLoopThread.loopbefore closing it #739) — CI is the real check