Follow-up to #727 and #739. Those two fixed the read side (force_stop, #727) and the write side (_thread_main, #739) of the RuntimeError: Event loop is closed shutdown race in EventLoopThread. One member of the family remains, in ThreadsafeProxy — and it is structurally out of reach of #739's fix.
The race
ThreadsafeProxy.func_wrapper does a check-then-use on the loop it dispatches to (
|
if loop.is_closed(): |
|
# 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) |
):
if loop.is_closed():
# 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:
...
loop.call_soon_threadsafe(check_result_wrapper)
The worker thread can close the loop between the is_closed() check and the dispatch, and both asyncio.run_coroutine_threadsafe() and loop.call_soon_threadsafe() raise RuntimeError: Event loop is closed on a closed loop — the exact exception #727 suppressed in force_stop (which even documents the pattern: "The worker thread may close the loop after our is_closed() check").
#739 can't help here: it guarantees EventLoopThread.loop (the attribute) is never a closed loop, but the proxy captures the loop object at construction (
|
thread_safe_protocol = ThreadsafeProxy(gateway, loop) |
and
|
api = ThreadsafeProxy(api, asyncio.get_event_loop()) |
), so publishing
None before closing never reaches the proxy — its snapshot just becomes a closed loop.
When it fires
The threaded path is the production default (use_thread=True; zha wires it through CONF_USE_THREAD). When the connection dies, connection_done's callback runs thread.force_stop(), which cancels tasks, stops the worker loop, and lets the worker thread close it. Any call still going through a ThreadsafeProxy at that moment — e.g. application teardown invoking close() on the gateway/EZSP proxies while the radio just disconnected — can pass the is_closed() check and then hit the closed loop. Same flake family as the #727 traceback, different entry point.
Suggested fix
Treat close-during-dispatch identically to already-closed: catch RuntimeError around the dispatch, log the existing "Attempted to use a closed event loop" warning, and return None (which is what the is_closed() branch already returns). One subtlety: a naive contextlib.suppress around the coroutine branch would leave call()'s coroutine created-but-never-awaited (→ RuntimeWarning: coroutine ... was never awaited), so the coroutine needs an explicit .close() on the failure path, e.g.:
if asyncio.iscoroutinefunction(func):
coro = call()
try:
future = asyncio.run_coroutine_threadsafe(coro, loop)
except RuntimeError:
coro.close()
LOGGER.warning("Attempted to use a closed event loop")
return
return asyncio.wrap_future(future, loop=curr_loop)
(and the plain try/except RuntimeError equivalent around the call_soon_threadsafe() branch.)
Related cleanup for whoever picks this up
EventLoopThread.run_coroutine_threadsafe (
|
def run_coroutine_threadsafe(self, coroutine): |
|
current_loop = asyncio.get_event_loop() |
|
future = asyncio.run_coroutine_threadsafe(coroutine, self.loop) |
|
return asyncio.wrap_future(future, loop=current_loop) |
) passes
self.loop straight through, and after
#739 the shutdown-window failure mode there is
AttributeError: 'NoneType' object has no attribute 'call_soon_threadsafe' (when
self.loop is already
None) rather than the old
RuntimeError — verified on 3.14. The sole caller (
uart.connect) wraps it in
except Exception, so nothing misbehaves today, but an explicit
if self.loop is None: raise RuntimeError("Event loop is not running") guard would keep that error legible.
Follow-up to #727 and #739. Those two fixed the read side (
force_stop, #727) and the write side (_thread_main, #739) of theRuntimeError: Event loop is closedshutdown race inEventLoopThread. One member of the family remains, inThreadsafeProxy— and it is structurally out of reach of #739's fix.The race
ThreadsafeProxy.func_wrapperdoes a check-then-use on the loop it dispatches to (bellows/bellows/thread.py
Lines 102 to 121 in 553ec35
The worker thread can close the loop between the
is_closed()check and the dispatch, and bothasyncio.run_coroutine_threadsafe()andloop.call_soon_threadsafe()raiseRuntimeError: Event loop is closedon a closed loop — the exact exception #727 suppressed inforce_stop(which even documents the pattern: "The worker thread may close the loop after ouris_closed()check").#739 can't help here: it guarantees
EventLoopThread.loop(the attribute) is never a closed loop, but the proxy captures the loop object at construction (bellows/bellows/uart.py
Line 132 in 553ec35
bellows/bellows/uart.py
Line 138 in 553ec35
Nonebefore closing never reaches the proxy — its snapshot just becomes a closed loop.When it fires
The threaded path is the production default (
use_thread=True; zha wires it throughCONF_USE_THREAD). When the connection dies,connection_done's callback runsthread.force_stop(), which cancels tasks, stops the worker loop, and lets the worker thread close it. Any call still going through aThreadsafeProxyat that moment — e.g. application teardown invokingclose()on the gateway/EZSP proxies while the radio just disconnected — can pass theis_closed()check and then hit the closed loop. Same flake family as the #727 traceback, different entry point.Suggested fix
Treat close-during-dispatch identically to already-closed: catch
RuntimeErroraround the dispatch, log the existing "Attempted to use a closed event loop" warning, and returnNone(which is what theis_closed()branch already returns). One subtlety: a naivecontextlib.suppressaround the coroutine branch would leavecall()'s coroutine created-but-never-awaited (→RuntimeWarning: coroutine ... was never awaited), so the coroutine needs an explicit.close()on the failure path, e.g.:(and the plain
try/except RuntimeErrorequivalent around thecall_soon_threadsafe()branch.)Related cleanup for whoever picks this up
EventLoopThread.run_coroutine_threadsafe(bellows/bellows/thread.py
Lines 17 to 20 in 553ec35
self.loopstraight through, and after #739 the shutdown-window failure mode there isAttributeError: 'NoneType' object has no attribute 'call_soon_threadsafe'(whenself.loopis alreadyNone) rather than the oldRuntimeError— verified on 3.14. The sole caller (uart.connect) wraps it inexcept Exception, so nothing misbehaves today, but an explicitif self.loop is None: raise RuntimeError("Event loop is not running")guard would keep that error legible.