Skip to content

Serialize concurrent device entity initialization - #898

Open
zigpy-review-bot wants to merge 3 commits into
devfrom
zigpy-bot/serialize-device-entity-init
Open

zigpy-review-bot wants to merge 3 commits into
devfrom
zigpy-bot/serialize-device-entity-init

Conversation

@zigpy-review-bot

@zigpy-review-bot zigpy-review-bot commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #897

Problem

Device.async_initialize can be entered concurrently for the same device: the startup mains-polling task, a join / re-interview and the device becoming available again all call it independently. Both calls appended to and drained the same _pending_entities list, so the loser ended up passing the winner's own entity objects to _add_entity, which raised ValueError: Cannot add entity X, unique ID already taken by X with the same object on both sides. Details, callers and the deterministic tools/regenerate_diagnostics.py reproduction are in #897.

Changes

Serialize entity initialization per device

async_initialize and recompute_entities now run under a per-device asyncio.Lock, so discovery and registration run to completion before the next round starts; the second round then finds every entity already registered and drops its duplicates through the existing check.

Keep startup mains polling alive when one device fails

async_fetch_updated_state_mains now gathers with return_exceptions=True (cancellations at debug, other failures logged per device), and fetch_updated_state sets config.allow_polling = True in a finally. Before, the first failing device killed the background task before that flag was set, so every polled entity stayed silent for the session and the availability checker (gated on the same flag) never pinged silent mains devices.

Related PRs

Supersedes #841. That bot-authored PR fixes the same startup-polling failure isolation; the second change here is the minimal form of it. #841 stays open for now and is intended to be closed once this PR is the agreed way forward. Its other change, dropping ZHA's whole-device startup concurrency cap in favour of LOW-priority startup reads (puddly's suggestion there), is a separate design decision and is deliberately not included here. If that change is wanted, ping @zigpy-review-bot to open a fresh PR for it on top of this one (preferred), or to update #841 to contain only that part.

Complementary to #878, which fixes the reconfigure leak of the same _pending_entities list (second item of #725): that PR keeps a reconfigure from queueing entities at all, this one serializes the initialize rounds that drain the queue. Verified that the two merge cleanly in either order and that the full suite passes on the merged tree.

Known remaining gap (pre-existing, not addressed here; fixed in #899, stacked on this PR)

async_teardown / async_rebuild_from_zigpy_device do not take the lock, so a removal or a re-interview rebuild can land while an initialization round (startup mains polling, an availability refresh) is still registering entities. The window is the registration step of that round: _add_pending_entities awaits entity.on_remove() for every entity it drops, and a teardown landing there clears the pending list but cannot take back the entities the round has already collected, which are registered afterwards. On a removed device they are announced to consumers after the device is gone; after a rebuild they are bound to the old zigpy device, and the re-interview's own initialization drops its fresh entities as duplicates, so the stale ones stay registered. Only unregistered entities get collected, so the exposure is a startup poll or availability refresh of a device that has just joined; a teardown landing during the round's device reads is harmless, since the pending list is cleared before the round registers anything. Fix: #899.

Tests and verification
  • test_concurrent_initialize_does_not_add_entities_twice: gathers a join (async_configure + async_initialize) with a concurrent async_initialize(from_cache=False) on the IKEA GU10 bulb, which is the exact shape of the startup poll landing inside a join. Fails on dev with the same-object ValueError, passes with the lock, and the resulting entity set matches a plain join.
  • test_gateway_fetch_updated_state_mains_device_failure: one device raises and one is cancelled during startup polling; the remaining device is still initialized, both outcomes are logged, and allow_polling ends up True. Fails on dev.
  • Full suite passes, pre-commit clean, venv mypy zha/ clean, both new tests stable over 10 runs.
  • tools/regenerate_diagnostics.py now processes the fresh lumi.switch.agl011 dump from Add knob events and voltage fix to Aqara Dimmer Switch H2 EU quirk zha-device-handlers#5346 without the traceback.

`Device.async_initialize` can be entered concurrently for the same device:
the startup mains-polling task, a join / re-interview and the device becoming
available again all call it independently. Both calls appended to and drained
the same `_pending_entities` list, so the loser ended up passing the winner's
own entity objects to `_add_entity`, which raised
`ValueError: Cannot add entity ..., unique ID already taken by ...` with the
same object on both sides.

Guard `async_initialize` and `recompute_entities` with a per-device lock so
discovery and registration run to completion before the next round starts;
the second round then finds every entity already registered and drops its
duplicates as before.

Reproduced with `tools/regenerate_diagnostics.py` on a mains-powered dump
whose `last_seen` is under `consider_unavailable_mains` old and that has an
entity owning a task (any light, an `AggregatedClusterPoller` sensor): the
join's initialization and the mains-polling initialization overlap, and
dropping a duplicate of that entity suspends long enough for them to
interleave.
`async_fetch_updated_state_mains` gathered the per-device initializations
without `return_exceptions`, so the first device that raised made the gather
raise, and the background `fetch_updated_state` task then died before setting
`config.allow_polling = True`. Every polled entity (electrical measurement,
some lights, ...) stayed silent for the rest of the session, and the
availability checker, gated on the same flag, never pinged silent devices.

Log and skip a failing device instead (cancellations at debug level), and
always allow polling once startup polling has finished, whether or not it
succeeded.
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.19%. Comparing base (c556ae2) to head (137812a).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #898   +/-   ##
=======================================
  Coverage   97.19%   97.19%           
=======================================
  Files          57       57           
  Lines       10560    10569    +9     
=======================================
+ Hits        10264    10273    +9     
  Misses        296      296           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The locking and failure-isolation changes are focused, consistent, and adequately covered by regression tests.

Pull request overview

Serializes per-device entity initialization and isolates startup polling failures.

Changes:

  • Adds a per-device entity lifecycle lock.
  • Logs per-device polling failures without stopping siblings.
  • Ensures polling is always re-enabled and adds regression tests.
File summaries
File Description
zha/zigbee/device.py Serializes discovery and registration.
zha/application/gateway.py Isolates startup polling failures.
tests/test_device.py Tests concurrent initialization.
tests/test_gateway.py Tests polling failure recovery.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent async_initialize calls on one device raise "unique ID already taken" and stop startup mains polling

2 participants