Skip to content

Tear down a removed device after its leave request completes - #1892

Open
zigpy-review-bot wants to merge 1 commit into
devfrom
zigpy-bot/remove-device-teardown
Open

zigpy-review-bot wants to merge 1 commit into
devfrom
zigpy-bot/remove-device-teardown

Conversation

@zigpy-review-bot

Copy link
Copy Markdown
Collaborator

Proposed change

_remove_device() popped the device from the device table once the ZDO leave request finished, but never tore the Device object down. For a device that is not yet initialized, its own Mgmt_Leave_rsp goes through the uninitialized-device branch of packet_received(), which re-schedules initialization right before the pop. The orphaned initialize task then keeps sending Node_Desc_req to a device that has already left (three attempts of 28 s each on dev, five on older releases) and emits device_init_failure for a device that is no longer registered. This is the zigpy side of #1802.

This PR calls Device.on_remove() after the leave exchange, so the removed device's tasks and callbacks are cancelled the same way they are on shutdown and on re-interview swaps. on_remove() was never called for a device removed through remove(), so removed devices also leaked the PollControl check-in listener registered in Device.__init__. If a distinct object was registered under the same IEEE while the leave request was in flight (a re-interview shadow, or an application re-adding the device from its own database), that object is popped as before and now torn down as well. The task done-callback registered by Device.create_task() is changed from set.remove to set.discard, because on_remove() clears _tasks while the cancelled tasks are still completing, and their callbacks then raised KeyError in the event loop (on_remove() is also called on shutdown, so that was reachable before this change too).

Related: #1816 adds the same teardown for the original device when a quirk replaces it, and makes the identical remove to discard change in Device.create_task() (with a different comment), so whichever of the two merges second needs a trivial rebase on that hunk. The _remove_device() change itself does not overlap with it.

Removing a device while it is being re-interviewed used to re-register it: reinterview() restored the old object after a failed discovery, or handed the shadow to _device_reinterviewed() for finalization, regardless of the removal. With the teardown that would have registered a torn-down object, so both paths now check that the shadow is still the registered device (reinterview() after discovery, successful or not, and _device_reinterviewed() after the awaited DB delete) and otherwise discard it, tear down the old device as well (in that order remove() picks up the shadow, so nothing else would have torn the old object down) and emit device_reinterview_failure so callers get a completion signal.

Behaviour note for remove(..., rejoin=True): if the Mgmt_Leave_rsp is lost and the device rejoins and announces within the leave timeout, handle_join() reuses the still-registered object and schedules its initialization, which the teardown now cancels; previously that orphaned initialization could complete after the pop and silently re-register the device through _finalize_device(). The device now comes back through unknown-device discovery on its next packet. zha never passes rejoin=True.

Additional information

  • Fixes the zigpy part of Removing an uninitialized device restarts its initialization on the Mgmt_Leave_rsp #1802 (the second, interleaved initialization loop in that log is the plugin re-adding the device from its own database after zigpy popped it).
  • Tests: test_remove_tears_down_device_after_leave_response reproduces the issue log (uninitialized device, remove(), Mgmt_Leave_rsp fed through packet_received()) and asserts no event-loop callback errors; test_remove_tears_down_replacement_device covers a distinct object registered during removal; test_reinterview_discards_shadow_when_device_removed, test_reinterview_failure_after_device_removed, test_device_reinterviewed_skips_device_removed_during_swap and test_device_reinterviewed_keeps_rejoined_device_during_swap cover the re-interview races. All fail on dev without the fix. The existing direct callers of _device_reinterviewed() in the tests now register the shadow first, as reinterview() does.

Checklist

  • The code change is tested and works locally
  • Tests have been added to verify that the new code works

`_remove_device()` popped the device from the device table once the ZDO leave
request finished, but never tore the object down. For a device that is not yet
initialized, its own Mgmt_Leave_rsp goes through the uninitialized-device path of
`packet_received()`, which re-schedules initialization right before the pop. The
orphaned initialize task then keeps sending Node_Desc_req to a device that has
already left, and emits `device_init_failure` for a device that is no longer
registered.

Call `Device.on_remove()` after the leave exchange, so the removed device's tasks
and callbacks are cancelled like they are on shutdown and on re-interview swaps.
If a distinct object was registered under the same IEEE meanwhile (a re-interview
shadow, or an application re-adding the device), it is popped as before and now
torn down too.

Register `_tasks.discard` instead of `_tasks.remove` as the task done-callback:
`on_remove()` clears the set while the cancelled tasks are still completing, and
their callbacks raised `KeyError` in the event loop.

A re-interview that is in flight while the device is removed no longer registers
the device again: `reinterview()` discards the shadow when it is no longer the
registered device after discovery (successful or not), tears down the old device
whenever it ends up unregistered, and `_device_reinterviewed()` stops before
finalizing the shadow when it is no longer registered after the old device was
deleted from the database. Callers get `device_reinterview_failure` in that case.
@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.50%. Comparing base (512f3cf) to head (8e4a996).
⚠️ Report is 6 commits behind head on dev.

Additional details and impacted files
@@           Coverage Diff           @@
##              dev    #1892   +/-   ##
=======================================
  Coverage   99.50%   99.50%           
=======================================
  Files          59       59           
  Lines       12415    12433   +18     
=======================================
+ Hits        12353    12372   +19     
+ Misses         62       61    -1     

☔ 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.

@zigpy-review-bot

Copy link
Copy Markdown
Collaborator Author

This grew beyond the original two lines in _remove_device(): the re-interview guards (reinterview() and _device_reinterviewed() checking that the shadow is still the registered device, plus the four tests for them and the shadow registration in the existing direct-call tests) only became necessary because the teardown would otherwise register an already torn-down object in that race, but they are a separable change.

If you would rather review them separately, I can split this into two PRs: this one reduced to the _remove_device() teardown and the _tasks.discard change, and a second one with the re-interview guards on top of it. Ping me with @zigpy-review-bot split the re-interview guards into a separate PR and I will do that.

@zigpy-review-bot zigpy-review-bot added the bugfix This PR fixes a bug label Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix This PR fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant