Skip to content

fix: make the refresh_tree() cascade best-effort - #42

Merged
dcj merged 1 commit into
mainfrom
fix/refresh-tree-best-effort
Aug 7, 2026
Merged

dcj merged 1 commit into
mainfrom
fix/refresh-tree-best-effort

Conversation

@dcj

@dcj dcj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #36.

Device.publish() wraps its body, so the paths people usually hit are covered. Property.publish_value() does not, and Node.publish() had no guard, so an injected transport that raises on one property aborted the cascade from wherever it failed.

The blast radius is larger than it looks. Because #32 moved a device's own $state to after the recursion, an exception escaping a child now also suppresses the parent's state publish. So a single raising property could take out:

  • every sibling property on the same node
  • every later sibling device
  • every ancestor's $state, all the way to the root

which is the entire tree's reconnect, from one sick device.

The policy

Best-effort, contained at two grains: per property in Node.publish(), and per child in the refresh_tree() recursion. Reconnect is precisely when a partial refresh beats an aborted one. Logged as reason=nodePublishPropertyFailed / reason=deviceRefreshTreeChildFailed, not re-raised.

The point is less the try/except than that the behaviour is now stated in the refresh_tree() docstring, rather than being an accident of which methods happened to have a guard.

Tests

  • test_refresh_tree_continues_past_a_raising_child: a sibling ordered after the failure still republishes, and the parent still announces $state.
  • test_node_publish_continues_past_a_raising_property: a sibling property still publishes and the device still announces.

Both fail on main with the RuntimeError propagating out. 550 passed.

Property.publish_value() reaches the MQTT client without wrapping it and
Node.publish() had no guard, so a bring-your-own-transport client that raised on
a single property aborted the walk from wherever it failed. That took out every
later sibling, every ancestor's $state publish, and therefore the whole tree's
reconnect: one sick device could keep an entire enclosure off the broker.

Contain it at two grains: per property in Node.publish(), and per child in the
refresh_tree() recursion. Reconnect is exactly when a partial refresh beats an
aborted one. The policy is now stated in the docstring instead of being an
accident of which methods happened to have a try/except; the exception is logged
(reason=deviceRefreshTreeChildFailed / nodePublishPropertyFailed) and not
re-raised.

Closes #36.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@dcj
dcj force-pushed the fix/refresh-tree-best-effort branch from a3d5422 to b9a15b4 Compare August 7, 2026 14:33
@dcj
dcj merged commit c58c2e0 into main Aug 7, 2026
5 checks passed
@dcj
dcj deleted the fix/refresh-tree-best-effort branch August 7, 2026 14:34
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.

A raising transport in one descendant aborts the rest of the refresh_tree() cascade

1 participant