Skip to content

ThreadsafeProxy can still raise RuntimeError: Event loop is closed — remaining check-then-use race after #727/#739 #740

Description

@zigpy-review-bot

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 (

bellows/bellows/thread.py

Lines 102 to 121 in 553ec35

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions