fix: five defects in the declarative builder, all silent in production - #79
Merged
Conversation
Reported by a consumer converting a multi-device adapter onto the builder, each with a runnable reproduction. All five verified against the reproductions before and after. #72 conditionally_settable was inert: _materialize never read it. The property did come out not-settable, which is the half that looked right; the entity_setter was registered only when settable was true, so the caller's later set_settable(True) opened a /set topic with no translator behind it. The property advertised that it accepts commands and discarded them, which is the exact failure the field exists to avoid, one step further along. The translator is now wired at build time even though the property starts not-settable. The test that shipped with the feature asserted only the not-settable half. A test written from the design rationale checks the rationale, not the feature. #77 A value the model already held never reached the wire. The binding is on-change and a fresh twin starts empty, so a producer whose model predates the tree announced its declared default on every property, and it did not self-heal, because set_value fires only on an actual change. Reachable only since 0.22.0: before the reuse fix the model property was replaced, so twin and model started equally empty. Also: a declared initial_value now seeds rather than overwrites, since a live model value is fresher than a declaration. #73 remove() raised when the producer's model had already dropped the group. delete_group removes it BEFORE firing GROUP_DELETED and dispatch is synchronous, so a consumer driving remove() from that event always hit it, after device.delete() and before the bookkeeping pop: gone from the broker, still a corpse in the builder short-circuiting the next add(). #75 remove() left deferred descendants queued, so resolve_deferred() rebuilt a device that had been torn down. #76 add() recorded bookkeeping after materializing, so a raise left a live broker-visible device with no record, and the same window admitted re-entry from a producer observing its own model. Closes #72 Closes #73 Closes #75 Closes #76 Closes #77 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #72, #73, #75, #76, #77. Each came with a runnable reproduction; all five were verified by execution before and after, not by reading.
#72 —
conditionally_settablewas inert_materializenever read the field. The half that looked right is that the property did come out not-settable. The half that bit:So the caller's later
set_settable(True)opened a/settopic with no translator, and the property advertised that it accepts commands and discarded them. That is the exact failure the field exists to avoid, one step further along — #58's PR argued that advertising settable without subscribing publishes a lie, and then shipped subscribing without a handler.It is also the only route the API offers for per-instance settability decided at runtime, so the feature was 100% broken for its stated purpose.
The test that shipped with it asserted only the not-settable half, which is why it survived review. A test written from the design rationale checks the rationale, not the feature.
#77 — a live model value never reached the wire
The binding is on-change and a fresh Homie twin starts empty, so nothing pushed the model's current value onto it:
A producer whose model predates the tree — the arrangement
DeviceTreeBuilderdocuments as its whole reason for accepting an external model — announced its declared default on every property, and it did not self-heal, becauseset_valuefires callbacks only on an actual change. A value written once at group creation stayed wrong for the process lifetime.This became reachable in 0.22.0. Before the #66 reuse fix, the model property was replaced, so twin and model started equally empty. Fixing one defect exposed another.
Relatedly, a declared
initial_valuenow seeds rather than overwrites: a model already holding a value holds a fresher one than the declaration. An explicitvalues=entry still wins, being a statement about this run.#73, #75, #76 — teardown and bookkeeping
delete_groupremoves the group before firingGROUP_DELETED, and dispatch is synchronous, so a consumer drivingremove()from that event was guaranteed to hit the raise. It landed afterdevice.delete()and before the bookkeeping pop: the device was gone from the broker while the builder still held a corpse that short-circuited the nextadd(). Bookkeeping now drops in afinally.resolve_deferred()rebuilt a device that had been torn down.remove()now prunes deferred descendants.device_for()→ None,remove()→ silent no-op, retained topics stranded. Recorded before materializing now, which also closes the re-entry window.Verification
692 tests pass (680 before, 12 new). Each issue's own reproduction is a test. ruff and markdownlint clean.
Not in this PR
#74 (idempotence keyed on device id rather than
DeviceSpecidentity) and #78 (node-level removal). #74 changes an API contract and #78 is completeness rather than correctness; both deserve their own design pass.🤖 Generated with Claude Code