Skip to content

Possible race in send_packet: AttributeError on _ezsp.set_extended_timeout when disconnect() runs concurrently #721

Description

@zigpy-review-bot

⚠️ 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():

  1. 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().
  2. ZHA setup failure: zha.application.gateway.async_initialize catches and does await self.shutdown() on any exception during _async_initialize.
  3. 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.

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