Repository navigation
connection_lost() tears down the worker loop before notifying the application, so disconnect() races the teardown #745
Description
Activity
Checked this against
dev. The ordering you describe is real and your line numbers are right, but I do not think the proposed swap would fix the race — and I think the reason is worth writing down, because it also explains why the symptom is fixable elsewhere.The notification at
:89is itself an asynchronous cross-thread dispatch, so it does not serialize anything. In theuse_thread=Truepath,Gateway._apiis not theEZSPobject —uart.connect()wraps it asThreadsafeProxy(api, <main loop>)before handing it to_connect().EZSP.connection_lostis a plaindef, soself._api.connection_lost(exc)goes throughThreadsafeProxy.func_wrapper, findsloop != curr_loop, and doesloop.call_soon_threadsafe(check_result_wrapper)— it queues the notification on the main loop and returns immediately. Meanwhileconnection_donewas created on the worker loop inside_connect(), so its done-callback (thread.force_stop()) runs one worker-loop iteration afterset_result. Flipping:82and:89therefore only changes which of two callbacks is enqueued first on two different loops; the application still has to cross threads and unwind throughEZSP.connection_lost→ControllerApplication.connection_lost→listener_event→ the config-entry reload before anything reachesawait self._ezsp.disconnect(), and the teardown is in flight throughout. TheXXXcomment above is a genuinely different case:_startup_reset_futureis resolved synchronously on the same loop, so its ordering relative toset_resultis real.The good news is that nothing is actually skipped. By the time
force_stop()has run,connection_losthas already been through zigpy'sSerialProtocol, which clears_transportand sets the disconnected event.Gateway.disconnect()isSerialProtocol.disconnect()=close()+wait_until_closed(), and both are no-ops in that state. So "the disconnect does not actually run" costs nothing concrete — the only thing that mattered is that the call must neither hang nor raise, which is what makes this fixable in the proxy rather than in the ordering.The variant you found is the one that made it unfixable in practice, and that is now closed. Your stopped-but-not-yet-closed row is fixed on #741 (
7fb32e7):force_stop()publishes an explicitstoppingflag before it schedules anything, and the proxy treats it exactly like a closed loop, so the caller gets a resolvedNoneand a log line instead of an unbounded silent wait. With that, all three rows of your table degrade to "warn and continue" and the reload can proceed to reconnect. There is a named residual on that PR — aforce_stop()landing between the proxy's flag check and its dispatch — which I would rather close explicitly than leave implied.I verified the environment claims too:
CONF_USE_THREADis set toFalsein exactly one place in HAdev—components/zha/radio_manager.py:210, on thestart_radio=Falseprobe application — and the zha library only declares the constant without ever reading it, so every real ZHA connection does run through the worker thread. That matches your reading.What I have deliberately not done is change the teardown trigger, because that is a design call rather than a bug fix: making the worker loop outlive the application's cleanup means
force_stop()can no longer hang offconnection_done, and something else has to guarantee the thread is eventually stopped when the application never disconnects. The same goes for restoringuse_thread=Falseforsocket://paths — with the#598/#604history behind it, that is the maintainers' call, not something I would push. If they want either explored, the fastest way to get me on it is to re-mention me here with what they want built.What I checked
bellows/uart.py:132-148and:67-89ondev;bellows/ezsp/__init__.py:164-166, 228-232, 331-334;bellows/thread.pyThreadsafeProxy.func_wrapper;zigpy/serial.pySerialProtocol.{connection_lost,close,disconnect,wait_until_closed}(zigpy 2.1.0).EZSP.connection_lostis sync, so the proxy takes thecall_soon_threadsafebranch — the notification is queued, never awaited.connection_doneis created inside_connect(), which runs on the worker loop, soforce_stop()is dispatched by that loop.CONF_USE_THREAD:bellows/config/__init__.py:36defaults it toTrue;bellows/zigbee/application.py:189reads it; ha-coredevsets itFalseonly atcomponents/zha/radio_manager.py:210; the zha library declares it atzha/application/const.py:76with no reader.- The hang itself was reproduced directly against
bellows.threadbefore fixing it, and is pinned by a test that fails withTimeoutErroron the unpatched code.
Thanks — you are right about the ordering, and I verified the specific reason before accepting it.
uart.py:138wraps the application object before it ever reaches_connect():api = ThreadsafeProxy(api, asyncio.get_event_loop())
so
Gateway._apiis a proxy bound to the main loop, andEZSP.connection_lostis a plain
def(ezsp/__init__.py:331). The call at:89therefore takes the
call_soon_threadsafebranch — it queues the notification and returns immediately. Swapping
:82and:89only reorders two callbacks on two different loops. I had read:138while
tracingEventLoopThread/force_stopand did not register what it meant for_api.Your second point corrects something I overstated.
Gateway.connection_lostcalls
super().connection_lost(exc)first (uart.py:69), so by the time anything reaches
disconnect(),SerialProtocol.connection_losthas already cleared_transportand set
_disconnected_event—close()is a no-op andwait_until_closed()returns immediately.
"The disconnect never actually runs" costs nothing concrete; the only thing that mattered was
that the call must neither hang nor raise. So the useful half of what I filed was the variant
in the #741 comment, not the fix I proposed here. Feel free to close this issue, or keep it
for the teardown-trigger design question if that is worth tracking separately.Hardware results
I have a physical EZSP-over-TCP gateway, so I ran the fix on it. Environment:
homeassistant 2026.4.1, zigpy 1.2.2, bellows 0.49.0, zha 1.1.1,use_threadleft at its
default (True, confirmed by the presence of thebellows.thread_worker in/proc).First, the failure does occur on real hardware, unpatched. I captured it once, with both
signatures in the same incident:13:47:22 WARNING (MainThread) [zigpy.application] Failed to disconnect from radio Traceback (most recent call last): File ".../zigpy/application.py", line 577, in shutdown await self.disconnect() File ".../bellows/zigbee/application.py", line 614, in disconnect await self._ezsp.disconnect() File ".../bellows/ezsp/__init__.py", line 224, in disconnect await self._gw.disconnect() TypeError: 'NoneType' object can't be awaited 13:48:13 WARNING (bellows.thread_0) [py.warnings] .../asyncio/base_events.py:744: RuntimeWarning: coroutine 'SerialProtocol.disconnect' was never awaitedZHA did not recover: no reconnect attempts and no TCP connection attempts from the host for
the 60 s I sampled, and the config entry never left unload.Second, and this is the part I want to be straight about: my A/B came back negative.
I applied #741 (boththread.pyanduart.py— patching onlythread.pyleaves
loop_thread=Noneand the new check inert) and ran matched arms, changing nothing but the
two files:arm gateway power cuts wedged recovery patched 15 0 5–11 s unpatched 10 0 5–11 s Both arms are clean, so this does not validate the fix — I could not construct a red
baseline with power cuts, and the patch's new disconnected branch was never reached during
them (zeroAttempted to use a closed event looplines across all 15 cuts).It was reached in the other patched cycles: during 15 API-triggered disconnects and 3
firmware-reboot cycles the log shows fourWARNING (MainThread) [bellows.thread] Attempted to use a closed event looplines, and each time the caller carried on and the entry reloaded — which is at least
evidence that the disconnected path behaves as designed when it is exercised, rather than
merely never being hit. Across all of those cycles there was nonever awaitedand no
NoneTypeanywhere.The one incident I did capture was not a clean power cut. It happened when an
API-triggered disconnect overlapped a Home Assistant restart — i.e. under concurrent load,
which lines up with the 2026.1.0 VAD account in zigpy/zha#628. A gateway that simply
disappears and comes back does not seem to be enough to lose the race on its own here.Two caveats on my own numbers, for whatever they are worth to you: the pre-existing failure
rate I had been working from (roughly 1/3 to 1/2) came from n=3 and n=9 samples taken on an
older build of my own firmware that had two of its own network bugs, both since fixed. Those
bugs could plausibly have produced "Zigbee does not come back after a reboot" independently
of anything in bellows, so I would not lean on those rates. And 25 power cuts is not enough
to put a useful bound on a rare race.Happy to re-run against a build that makes the race more likely if you have one, or to leave
a longer soak running.@zigpy-review-bot Check above. (Also consider latest commits in related PR.)
Reacted by zigpy-review-botChecked the hardware results against the code — the #741 arm below is its current head,
7fb32e7. The negative A/B has a mechanical explanation, and it is not a sample-size problem: your power cuts never reached the code the PR changes, and your own logs say so.On a
socket://transport a hard power cut produces no FIN — the TCP connection just stalls — soGateway.connection_lostnever fires. The loss surfaces the other way instead: ASH exhausts its ACK retries →AshProtocol._enter_failed_state(ash.py:717) →Gateway.error_received(uart.py:46) →self._api.enter_failed_state(code)→EZSP.connection_lost(ezsp/__init__.py:331) →ControllerApplication.connection_lost→ HA'shandle_connection_lostschedules the entry reload. That route never touches_connection_done_future, so theforce_stop()done-callback atuart.py:148never runs at all: the worker loop is still fully alive when the reload'sEZSP.disconnect()dispatches through the proxy, and the disconnect simply works. None of the three rows of your table can occur.Which is exactly what you measured — zero
Attempted to use a closed event loopacross all 15 patched cuts, but four during the API-triggered disconnects and firmware reboots, i.e. the events where the socket really does close andGateway.connection_lostreally does fire.I verified this on real bellows rather than by reading it:
bellows.uart.connect()against a local TCP server, then each loss event, on both builds.build loss event await proxy.disconnect()devtransport close (FIN) TypeError: 'NoneType' object can't be awaited— your field trace, first trydevASH failed state clean — force_stop()never ran, worker loop still open#741 transport close clean, resolves #741 ASH failed state clean, unchanged So the fix is not invalidated by that experiment; it was not exercised by it. And the one incident you did capture —
TypeErroratezsp/__init__.py:224— is the already-closed row, which is precisely what #741's first commits fix.How to get a red baseline
Stop cutting power. Close the TCP connection from the bridge side instead: kill the bridge process, or
ss -Kthe socket on the gateway. That firesconnection_loston the transport, and on unpatcheddevit reproduced your exact field failure first try (row 1) with no timing luck involved.That buys you the
TypeErrorrow deterministically. The hang row needs one more thing, because the window really is tiny: measured againstEventLoopThreadhere,force_stop()→loop.is_closed()is min 0.33 ms, median 1.0 ms, max 3.3 ms (n=20). A reload-driven disconnect arrives tens of milliseconds later, so it lands after the close — that is why the field failure is normally theTypeError, and why your one hang needed a disconnect issued concurrently with the loss (the API disconnect overlapping the HA restart). To take the lottery out of it, widen the window:# bellows/thread.py, in EventLoopThread._thread_main's `finally`, before loop.close() time.sleep(2) # hold the loop stopped-but-not-closed
With that, plus the transport-close event above:
- unpatched
dev:await proxy.disconnect()hangs — the silent variant, on demand - Handle the loop closing between
ThreadsafeProxy'sis_closed()check and dispatch #741: resolves immediately, loggingWARNING bellows.thread: Attempted to use an event loop that is shutting down
Both confirmed here. If you want a soak that carries information, that is the arm to soak — the widening changes only the odds of landing in the window, not what is being tested.
One thing your power-cut path shows in passing: on the ASH-failure route, the ordering you proposed in this issue is what already happens — the application's
disconnect()completes on a live worker loop and the teardown follows it, because closing the transport is what finally resolvesconnection_done. I checked that it does not leak the thread (worker threads 1 → 0 acrossdisconnect()). So that ordering is fine where it occurs; it is the transport-close route that does not get it, and the proxy-side guard is what covers that route.Whether to close this or keep it for the teardown-trigger design question is a maintainer's call — not something I will act on either way.
What I checked
- Routing read on
7fb32e7anddev:ash.py:696/717(_enter_failed_stateon NAK/ACK-timeout exhaustion) andash.py:606(error frame) →uart.py:46Gateway.error_received→_api.enter_failed_state→ezsp/__init__.py:336,331→ControllerApplication.connection_lost(zigpy/application.py:952).uart.py:82(_connection_done_future.set_result) is only onGateway.connection_lost, i.e. the transport route. - zigpy's watchdog takes the same shortcut (
zigpy/application.py:946, "Treat the watchdog failure as a disconnect" →self.connection_lost(e)); bellows sets_watchdog_period = 10(bellows/zigbee/application.py:86). HA:components/zha/helpers.py:685→async_schedule_reload→ unload →app.shutdown()(zigpy/application.py:602) →disconnect()→EZSP.disconnect(). - The four-row table is a probe driving real
bellows.uart.connect(..., use_thread=True)overasyncio.start_serveron127.0.0.1, with a stub EZSP-shaped API object; loss event A closes the server side of the socket, loss event B callsGateway.error_received(ERROR_EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT)on the worker loop. Thenawait proxy.disconnect()— the callEZSP.disconnect()makes. Ondev, event A raisedTypeErrorand leftworker_loop_closed=True; event B leftworker_loop_closed=Falseand returned cleanly. - Window measurement: 20 iterations of start →
force_stop()→ poll toloop.is_closed(), on an otherwise idle machine; Python 3.14.5. - Thread-leak check: after event B,
threading.enumerate()shows onebellows.threadworker; afterawait proxy.disconnect(), zero, withstopping=Trueandself.loop is None.
- unpatched
Ran the experiment you described. It reproduces deterministically, and #741 fixes it.
First, a correction to my last comment that is worth more than the numbers: my power-cut A/B could not have tested this patch, and not only for the reason you gave. When I went to re-run it today there was no
bellows.thread_worker in/procat all — my own workaround,zha.zigpy_config.use_thread: false, was active inconfiguration.yaml. Without the worker thread the race is structurally impossible, so the arms were guaranteed to agree. I cannot now reconstruct whether that workaround was active for the whole of the run I reported, so please treat those numbers as withdrawn rather than merely uninformative. Flagging it because it is an easy trap for anyone else trying to reproduce this: the workaround that makes the bug go away also makes the test vacuous, and the fastest check is the thread fingerprint, not the config file.Setup
bellows 0.49.0, zigpy 1.2.2, zha 1.1.1, HA 2026.4.1, physical EZSP-over-TCP gateway. Worker thread confirmed present in
/procfor every trial.Loss event: closed the TCP connection from the bridge side (the gateway has an API to drop a client), which is the transport-close route you pointed at.
Window widened per your recipe —
time.sleep(2)inEventLoopThread._thread_main'sfinally, beforeloop.close()— in both arms, so the comparison is controlled.I applied #741 (
7fb32e7) on top of 0.49.0. One hunk needed hand-application:force_stop()differs between 0.49.0 anddev, soself.stopping = Truewas inserted manually at the top. The rest applied cleanly, and the patched arm is confirmed to be running the new code by the presence ofAttempted to use an event loop that is shutting down, which exists only on the PR.Each trial restarts HA first, so trials are independent. (My first pass did not, and once the first trial wedged there was no client left to disconnect — three "trials" were really one.)
Results
arm valid trials wedged never awaitedguard fired recovery 0.49.0 + widening 6 6 6 0 never (>120 s, every trial) #741 + widening 8 0 0 8 ~54 s Guard messages across the patched arm: 16 ×
Attempted to use an event loop that is shutting down, 12 ×Attempted to use a closed event loop. The new branch is being exercised, not merely present.Worth being precise about "recovery": the patch turns never into about a minute, not into instant. Most of that is HA's config-entry retry backoff, not bellows.
Two things I did not expect
1. What hangs is the reconnect, not the disconnect.
Every wedge in the unpatched arm hangs on connect-path coroutines:
coroutine 'Gateway.reset' was never awaited (4) coroutine 'Gateway.wait_for_startup_reset' was never awaited (3)Zero
SerialProtocol.disconnect. The discussion here and on #741 is framed arounddisconnect(), and that is where I found it originally, but in these runs the disconnect completes and it is the re-connect that gets dispatched into the stopping loop and never runs. That is the mechanism that leaves the entry stuck: the setup coroutine never finishes and never fails, so nothing retries.2. The two signatures have opposite consequences, which distorts any measurement of this bug.
signature what HA does TypeError: 'NoneType' object can't be awaitedlogged, entry marked not-ready, retried after 5 s → recovers coroutine ... was never awaited(the hang)nothing at all — zero retries, permanent wedge In the unpatched wedges:
Retrying incount 0,not readycount 0, across all four captured logs. Completely silent.So counting "did it wedge" misses every
TypeErrorevent, and reading the log for errors misses every hang — the dangerous one is the quiet one. I think this is why field reports of this are so inconsistent.One correction to the widening recipe
The
sleep(2)does not only widen the stopped-but-not-closed window. It also reliably landsEventLoopThread.start()in its early-return branch, so the caller reuses the stopped loop. I checked both arms in isolation (no device, justbellows.thread):arm start()reuses the stopped loopawait proxy.<coro>()0.49.0 + widening yes hangs #741 + widening yes returns NoneimmediatelyI first read that as a second defect the PR leaves open, and I was wrong: the comment you added in
start()says exactly this is intended, and becauseself.stopping = Falsesits after the early return,stoppingstaysTrueand the proxy rejects instead of hanging. So the widened experiment exercises both paths and the patch covers both — but anyone repeating it should know the widening is doing two things, not one.Applicability
On this hardware the transport-close route is not exotic — it fires on every gateway firmware update, every Zigbee-chip restart, and any deliberate client drop. So this is a routine event, not a corner case, which fits the original
TypeErrorI captured in the field better than the power-cut theory I was working from.Happy to run a longer soak, or the same arms without the widening if a rare-event count is useful.
@zigpy-review-bot Check the above comment. Anything further to fix/PR?
Reacted by zigpy-review-botChecked it against
7fb32e7anddev. The widened A/B holds up, and thanks for withdrawing the power-cut numbers rather than letting them stand — the thread fingerprint really is the right check, and it is worth saying thatuse_thread: falseis not just a workaround but the one setting that makes the experiment vacuous.One of your two "did not expect" items does not carry over to bellows, though — and going after it turned up a fourth outcome that is not in your table.
The reconnect cannot be dispatched into the stopping loop.
uart.connect()constructs a freshEventLoopThread()on every call (uart.py:139, identical in 0.49.0), and nothing else in bellows holds one — sostart()'s early-return branch is unreachable from bellows: a reconnect always gets a brand-new loop on a brand-new thread. It is reachable in your isolation arm, because that restarts a singleEventLoopThreadinstance, which bellows never does. Your widened scenario over a real TCP transport (close from the server side,sleep(2)beforeloop.close()), both arms:arm disconnect reconnect devhangs; coroutine 'SerialProtocol.disconnect' was never awaitedOK, 3 ms #741 OK, immediate — Attempted to use an event loop that is shutting downOK, 7 ms So it is still the disconnect that hangs. What makes it present as the reconnect is where that hang sits in HA: inside
shutdown()(zigpy/application.py:602), i.e. inside the unload — so the entry never finishes unloading, setup never runs, and nothing schedules a retry. That is your "zero retries, permanent wedge, completely silent" signature; only the attribution moves, not the conclusion.Two rows are still open after #741 — both pre-existing, neither introduced by it, and both requiring a disconnect issued concurrently with the loss, which is exactly the incident you captured in the field (an API disconnect overlapping an HA restart):
force_stop()landing between the proxy'sstoppingcheck and itsrun_coroutine_threadsafedispatch — the hang row again, through a window of a few microseconds. This is the residual named on the PR; I reproduced it by forcing that interleaving.force_stop()landing immediately after the dispatch —cancel_tasks_and_stop_loopcancels every task on the loop, including the one just created, so the caller gets a bareCancelledErrorout ofawait proxy.disconnect(). That is a fourth outcome, absent from your three-row table:zigpy/application.py:603catchesException, notBaseException, so it escapesshutdown()instead of being logged as "Failed to disconnect from radio" and continuing.
Both close with one change to
bellows/thread.py, which I have prototyped and verified locally but not pushed:EventLoopThreadtracks the futures it hands the worker loop and the worker thread resolves any the loop never ran on its way out — so no dispatch can wait forever, whatever the interleaving — and the proxy translates a teardown cancellation into the same disconnected result it already returns for a closed or stopping loop. With it, both forced interleavings return cleanly, the widened cycle is unchanged, andpytest tests/is 451 passed.On your offer of more hardware time: I do not think another soak buys anything for #741. The remaining two rows are microsecond interleavings that cannot be landed on demand from outside the process — they need the interleaving forced in-process, which is a unit test, not a device. Your widened arms already cover the part hardware can answer.
Whether the follow-up lands as commits on #741 or as its own PR is a maintainer's call. Re-mention me here or on #741 and I will push it.
What I checked
- Worktree at
7fb32e7withorigin/dev'sbellows/{thread,uart}.pyswapped in for thedevarm; Python 3.14.5, zigpy 2.1.0,pytest tests/green on both arms. - Reachability of
start()'s reuse branch:EventLoopThreadis referenced only atuart.py:9(import) anduart.py:139(construction insideconnect()), ondevand on0.49.0alike — everyconnect()gets a fresh instance whoseself.loopisNone, so the branch cannot be entered from bellows. - Cycle probe: real
bellows.uart.connect(..., use_thread=True)againstasyncio.start_serveron127.0.0.1with a stub EZSP-shaped API; loss event = server-side close of the client socket; thenawait proxy.disconnect()and a freshuart.connect(), both underasyncio.wait_for. Widening applied by wrapping the worker loop'sclose()withtime.sleep(2), in both arms. - Residual row 1:
run_coroutine_threadsafepatched to callforce_stop()and sleep 50 ms between the proxy's flag check and the dispatch →devand7fb32e7both hang; with the prototype the call returns once the worker thread exits. - Residual row 2 (the
CancelledError): same hook, butforce_stop()fires immediately after the dispatch, with no transport loss at all andstopping == Falseat the check →CancelledErrorpropagates out ofawait proxy.disconnect()on7fb32e7; the prototype returnsNonewith a warning instead.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Size
Area
Summary
All the current work on
ThreadsafeProxyteardown (#722, #727, #730, #739, #740, #741)makes the failure graceful — return an awaitable, swallow the
RuntimeError, log awarning. None of it addresses why the proxy is being used against a dead loop in the first
place, which is an ordering problem one layer up in
uart.py.Gateway.connection_lost()resolvesconnection_done— which triggersEventLoopThread.force_stop()— before it notifies the application that the connectionwas lost:
So the teardown of the loop is already in flight while
:89is still propagating upwardthrough
EZSP.connection_lost→ControllerApplication.connection_lost→ theapplication's listener. By the time the application unwinds and calls
await self._ezsp.disconnect(), it is racing that teardown — anddisconnect()has to gothrough the proxy bound to exactly the loop being torn down.
Making the proxy fail gracefully turns a hang into a no-op, which is a real improvement,
but the disconnect still does not actually run. Flipping the order — notify the
application first, resolve
connection_doneafter — would let the normal cleanup pathcomplete before the loop goes away, and would make the proxy's disconnected branches a
genuine edge case rather than the common path.
This hazard is in fact already documented a few lines above, for a different future
(
uart.py:74-77):That is the same mechanism — "the secondary thread has an attached callback to stop
itself" — recognised for
_startup_reset_futurebut not for the application notificationat
:89, which is also downstream of the sameset_resultat:82. Given that comment,I may well be missing a constraint that forces the current order.
I am also fairly confident this is the original bug behind
home-assistant/core#88202(2023), whose workaroundzigpy/zha#628removed in January2026 on the assumption that "the underlying bellows issue has been resolved" — while the
PR author notes in the same thread that the original issue was never actually found.
uart.pyhas been heavily refactored since 2023 (407 → 151 lines), but this ordering hassurvived every refactor untouched: the
force_stopdone-callback sat at:404inFeb 2023 (
95c1bee) and sits at:148today, withset_result(exc)still ahead of theapplication notification in both.
Versions
uart.pybyte-identical;thread.pyThreadsafeProxyunchanged)CONF_USE_THREADdeclared atapplication/const.py:76, zero references)dev(onlycomponents/zha/radio_manager.py:210sets it, on thestart_radio=Falseprobe app)Coordinator: EFR32MG24 NCP reached over a raw TCP byte bridge (
socket://<ip>:6638).The bridge does no ASH framing, so nothing on my side interferes with the protocol.
All line numbers in this issue are from bellows 1.0.0 /
dev.What the application then hits
When the application unwinds and calls
EZSP.disconnect():ThreadsafeProxy.func_wrapper(thread.py:96-107) then dispatches to the worker loop.Depending on exactly how far
force_stop()has progressed, there are three outcomes:is_closed()run_coroutine_threadsafeTrueNone→await None→TypeErrorFalsethen closesRuntimeErrorFalseThe third row is the one I hit, and it is the worst of the three because it is completely
silent — no exception, no log line, no retry.
force_stop()schedulesloop.stop();loop.close()only happens afterwards in_thread_main'sfinally, so there is a realwindow where the loop is unusable but reports
is_closed() == False.Self-contained reproduction of that window (Python 3.14, no bellows import required):
I have also left this as a comment on #741, since that PR states it closes out the last
members of this family and this variant slips past both its
is_closed()check and itsexcept RuntimeError.Evidence
The hang leaves a distinctive trace when the loop is finally closed and the un-run
coroutine is garbage collected:
base_events.py:744is insideBaseEventLoop.close(): the coroutine object was stillunwrapped when the loop was closed.
To be precise, this warning on its own does not distinguish the hang from the
RuntimeErrorvariant — in both casescall()has already constructed the coroutine andit is later collected unused (my reproduction above emits it on both paths). What
identifies the hang in my case is what came with it: no exception anywhere, no
Retrying in …fromconfig_entries, andss -tnshowing zero connection attempts fromthe host for the full 90 s I sampled. The
RuntimeErrorandTypeErrorvariants bothsurface an exception to the caller; this one surfaced nothing at all.
The
TypeErrorvariant has been reported by users as "NoneType errors", e.g.home-assistant/core#147509 (SLZB-06 Ethernet coordinator), and is already tracked in #722.
Why USB coordinators appear unaffected
The code path is identical, but a USB serial device essentially never raises
connection_lostduring normal operation, so this path is not exercised. Networkcoordinators hit it on every disconnect — cable, reboot, power loss, DHCP change.
Reproduction
socket://,use_threadleft at its default (True).power cut is the harsher and more realistic case).
config entry never leaves unload; no retries are scheduled and, in the hang variant,
nothing is logged at all.
Across the reboots I logged before finding the cause, the coordinator came back on its own
5 times out of 9. I would not read too much into the exact ratio (small sample, and I was
not controlling for which of the three variants fired), but it matches the intermittency
described in #88202 and reported by SLZB-06 users.
Verification that
use_thread=Falsestill fixes itWith
use_thread=False,_connect()runs on the caller's loop, soThreadsafeProxy._obj_loopis the main loop,:100(loop == curr_loop) is hit first,and
:102/:107are unreachable.EventLoopThreadis never created, so there is noforce_stop()at all.Measured on the setup above (power cut, nothing touched by hand):
Retrying in 5 s/10 sNo
never awaitedwarning, no failed unload. Same test with the defaultuse_thread=Trueis what produced the trace quoted above.Suggested fix
The minimal change is ordering: notify the application before the
connection_donefuture is resolved, so the teardown callback at:148cannot rununtil the upper layers have finished with the proxy — extending the invariant that the
XXXcomment quoted above already applies to_startup_reset_future.To be clear about what I am not proposing: removing the UART thread outright was
already tried in #598 and reverted in #604, because serial-port read notifications depend
on the event loop and any loop slowdown stalls radio traffic. That reasoning is sound and
I am not asking to revisit it — the bug here is the teardown ordering, which is
orthogonal to whether the thread exists.
Alternatives, if the ordering is load-bearing for something I have missed:
EZSP.disconnect()short-circuit when the gateway's loop is no longer usable,instead of awaiting through the proxy — at minimum,
ThreadsafeProxy.func_wrappershould not return a bare
Nonefrom a coroutine call site (:105), since that turnsinto a confusing
TypeErrorat theawait.run_coroutine_threadsafepath at:107a timeout so a dead loop degradesto an error rather than an unbounded hang.
use_thread=Falsedefault forsocket://device paths that
zigpy/zha#628removed. Note this is narrower than Remove UART thread #598: Revert "Remove UART thread (#598)" #604'sobjection is specifically about serial fd read notifications, which does not apply to
a TCP transport with kernel-side buffering. It is not free either — it moves ASH ACK
timing onto the main loop, which is the same failure mode described in Prevent task cancellation from propagating to ASH #628's comments
about the 2026.1.0 VAD CPU regression — but as a fallback it is strictly better than the
current unbounded hang.
Happy to test any patch against the reproduction above.