⚠️ This is an investigation, not a confirmed reproduction. I read the code and identified a window that seems reachable, but I do not have a debug log proving the exact interleaving. Filing in case it helps maintainers triage a recent user report.
A user report in home-assistant/core (home-assistant/core#172247, Home Assistant 2026.5.4 with bellows 0.49.1 / zigpy 1.4.1 / Python 3.14.2, ZBT-1 over USB) includes this traceback from zigpy.zcl:
AttributeError: 'NoneType' object has no attribute 'set_extended_timeout'
File ".../bellows/zigbee/application.py", line 1030, in send_packet
await self._ezsp.set_extended_timeout(...)
This signature does not match the symptom of home-assistant/core#130548 (which raises ControllerError("ApplicationController is not running") from the explicit guard) and I cannot find any other report of set_extended_timeout failing on None. It looks like a separate, undocumented bug.
The window
In bellows/zigbee/application.py (master at 0.49.1):
async def send_packet(self, packet):
if not self.is_controller_running: # L957: check passes
raise ControllerError("ApplicationController is not running")
...
async with self._limit_concurrency(priority=...): # awaits semaphore
...
async with self._req_lock: # awaits lock
if packet.dst.addr_mode == zigpy.types.AddrMode.NWK:
if device is not None:
await self._ezsp.set_extended_timeout( # L1030
...
)
disconnect() (line 611-615):
async def disconnect(self):
self.controller_event.clear() # L612
if self._ezsp is not None:
await self._ezsp.disconnect() # L614 — awaits
self._ezsp = None # L615
disconnect() does not acquire _req_lock. So between send_packet's guard at L957 and its use of self._ezsp at L1030 there are two await boundaries (_limit_concurrency, _req_lock). If disconnect() runs in the gap, it clears controller_event, awaits the EZSP disconnect, and sets _ezsp = None. When send_packet resumes inside _req_lock, the dereference at L1030 raises AttributeError.
Note that is_controller_running (L113-114) is controller_event.is_set() and self._ezsp.is_ezsp_running, so if _ezsp were already None at check time the guard would itself NPE — meaning the race window is strictly after the guard, not at the guard.
Why this can fire in practice (hypothesis)
A few concurrent paths reach disconnect():
- Watchdog failure →
_watchdog_loop (zigpy/application.py:886-902) calls connection_lost(exc) → ZHA emits ConnectionLostEvent → HA core helper handle_connection_lost calls hass.config_entries.async_schedule_reload(...) → unload → shutdown() → disconnect().
- ZHA setup failure:
zha.application.gateway.async_initialize catches and does await self.shutdown() on any exception during _async_initialize.
- Home Assistant's bootstrap stage-2 cancel (300 s,
homeassistant/bootstrap.py:141) cancelling a slow ZHA setup, which propagates into the same shutdown path.
In all three, ZCL requests already issued by device interviews or by user actions can still be in send_packet past the guard and not yet at L1030. The #172247 reporter sees the crash during setup and also reports HA's "Bootstrap stage 2 timeout", consistent with path 3.
What I have not verified
- No debug log captured showing the interleaving in flight. The reporter only included excerpts.
- I have not attempted a synthetic reproducer.
- I have not checked whether HA core or ZHA somehow ensures all in-flight
send_packet tasks are awaited before disconnect() is reached — zigpy.application.shutdown() does cancel self._tasks, but user-initiated request coroutines are not registered there.
Possible directions for a fix
Sketching without picking one:
- Re-check
is_controller_running (or self._ezsp is not None) inside _req_lock in send_packet, raising ControllerError cleanly instead of NPE-ing.
- Acquire
_req_lock inside disconnect() so it serialises with in-flight sends. Tradeoff: _ezsp.disconnect() may be slow, so holding _req_lock could delay legitimate cleanup, and a misbehaving radio could deadlock the lock acquisition.
- Wrap the L1030 dereference in a small helper that handles the
_ezsp is None case explicitly.
Happy to provide whatever extra context or logs would help, and I can ask the original reporter for a full debug log if useful.
A user report in home-assistant/core (home-assistant/core#172247, Home Assistant 2026.5.4 with bellows 0.49.1 / zigpy 1.4.1 / Python 3.14.2, ZBT-1 over USB) includes this traceback from
zigpy.zcl:This signature does not match the symptom of home-assistant/core#130548 (which raises
ControllerError("ApplicationController is not running")from the explicit guard) and I cannot find any other report ofset_extended_timeoutfailing onNone. It looks like a separate, undocumented bug.The window
In
bellows/zigbee/application.py(master at 0.49.1):disconnect()(line 611-615):disconnect()does not acquire_req_lock. So betweensend_packet's guard at L957 and its use ofself._ezspat L1030 there are twoawaitboundaries (_limit_concurrency,_req_lock). Ifdisconnect()runs in the gap, it clearscontroller_event, awaits the EZSP disconnect, and sets_ezsp = None. Whensend_packetresumes inside_req_lock, the dereference at L1030 raisesAttributeError.Note that
is_controller_running(L113-114) iscontroller_event.is_set() and self._ezsp.is_ezsp_running, so if_ezspwere alreadyNoneat check time the guard would itself NPE — meaning the race window is strictly after the guard, not at the guard.Why this can fire in practice (hypothesis)
A few concurrent paths reach
disconnect():_watchdog_loop(zigpy/application.py:886-902) callsconnection_lost(exc)→ ZHA emitsConnectionLostEvent→ HA core helperhandle_connection_lostcallshass.config_entries.async_schedule_reload(...)→ unload →shutdown()→disconnect().zha.application.gateway.async_initializecatches and doesawait self.shutdown()on any exception during_async_initialize.homeassistant/bootstrap.py:141) cancelling a slow ZHA setup, which propagates into the same shutdown path.In all three, ZCL requests already issued by device interviews or by user actions can still be in
send_packetpast the guard and not yet at L1030. The #172247 reporter sees the crash during setup and also reports HA's "Bootstrap stage 2 timeout", consistent with path 3.What I have not verified
send_packettasks are awaited beforedisconnect()is reached —zigpy.application.shutdown()does cancelself._tasks, but user-initiated request coroutines are not registered there.Possible directions for a fix
Sketching without picking one:
is_controller_running(orself._ezsp is not None) inside_req_lockinsend_packet, raisingControllerErrorcleanly instead of NPE-ing._req_lockinsidedisconnect()so it serialises with in-flight sends. Tradeoff:_ezsp.disconnect()may be slow, so holding_req_lockcould delay legitimate cleanup, and a misbehaving radio could deadlock the lock acquisition._ezsp is Nonecase explicitly.Happy to provide whatever extra context or logs would help, and I can ask the original reporter for a full debug log if useful.