fix: two thread-safety fixes (0.20.1) — property publish lock, per-thread bulk context - #56
Conversation
The publish-on-change memo added in 0.20.0 could record an older payload than the one that actually reached the wire last, after which the gate suppressed the publish that would have corrected the broker. Two threads reach the sequence: the application thread via set_value(), and the MQTT loop thread via on_connect -> refresh_tree(force=True) -> Node.publish() -> publish_value(force=True). Interleaved, the loop thread could send the old payload, the application thread could then send and memoize the new one, and the loop thread could finally overwrite the memo with the older payload it had sent first. The property then believed the broker held a value it did not, and a later set_value() of that value was skipped, so the wrong retained value persisted until the next genuine change or reconnect. compute-payload / publish / memoize is now one atomic unit per property. The lock is REENTRANT because set_value() takes it and calls publish_value(), which on the retraction path calls clear_value(); a plain Lock self-deadlocks on the commonest call in the SDK. It is per-property, so it never serializes a tree walk, and no path holds two, so there is no ordering hazard. It is deliberately held across the transport's publish(): releasing earlier reopens the window it exists to close. That is safe for the paho transport the SDK ships, and the code records why, because it is not obvious: paho invokes on_connect holding only _in_callback_mutex and takes _out_message_mutex only after the callback returns (sequential, not nested), and the one place the publish path touches _in_callback_mutex uses a non-blocking acquire(False) that threaded mode skips. A bring-your-own transport could still build that cycle by holding its own lock across the on-connect handler it wires to refresh_tree() while also requiring it in publish(); it must not. get_last_published_value() now reads the memo tuple once into a local rather than testing the attribute and subscripting it. The GIL makes that window practically unreachable, but a free-threaded build removes the accident, and CI covers 3.13. CI jobs that run code gain timeout-minutes: 5. A lock regression deadlocks whichever thread reaches it, and unbounded, GitHub would let that run to its six-hour default instead of reporting a failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BulkUpdateContext.__enter__ and __exit__ mutated a shared _bulk_mode / _bulk_context pair on GroupedPropertyDict with no lock held, while every other accessor on the class (including the observer dispatch and the group scans) takes its RLock. Two threads entering bulk contexts on the same dict corrupted each other: entering displaced the other's context, and whichever exited first cleared bulk mode for both. No events were lost, because __exit__ fires the context object's own list, but they were misattributed and fragmented. Some of one thread's changes landed in the other's batch, and everything after the early exit fired individually instead of batching. That fragmentation is the real cost for a Homie publisher: each structural event escaping the batch triggers its own $description republish and a $state transition, so one logical change produces extra republishes and visible state flapping. The active context is now thread-local rather than serialized on the existing lock, because a bulk context can be held across I/O and one thread should not block for the duration of another's batch. Two threads batching independently was always the reasonable reading of this API; now it is the behavior. A nested bulk_update() on the same thread now restores the enclosing context on exit instead of clearing it, so the outer batch resumes rather than leaking its remainder as individual events. Closes #55. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: also closes #55 (filed while this PR was open)Same class of defect, one layer down, so it rides along in 0.20.1 rather than waiting.
Two threads entering bulk contexts on the same dict corrupted each other: entering displaced the other's context, and whichever exited first cleared bulk mode for both. Per #55's own analysis, and it is worth keeping the distinction: no events are lost ( The fragmentation is what actually hurts a Homie publisher: each structural event escaping the batch triggers its own Fixed thread-local, which is the option #55 recommends and for the reason it gives: a bulk context can be held across I/O, and serializing on the existing lock would make one thread block for the duration of another's batch. Two threads batching independently was always the reasonable reading of this API. Nesting improves as a side effect. A context now restores the enclosing one on exit instead of clearing it, so a nested Verification
|
Closes the race flagged as a known limitation in #51, rather than carrying it. Also bumps to 0.20.1.
The defect
0.20.0's publish-on-change memo could record an older payload than the one that actually reached the wire last. The gate then suppressed the very publish that would have corrected the broker.
Two threads reach compute-payload/publish/memoize:
set_value()on_connect→refresh_tree(force=True)→Node.publish()→publish_value(force=True)Interleaved: the loop thread sends the old payload; the application thread then sends and memoizes the new one; the loop thread finally overwrites the memo with the older payload it sent first. The property now believes the broker holds a value it does not, and a later
set_value()of that value is skipped. The wrong retained value persists until the next genuine change or reconnect.The window is narrow (it needs a reconnect refresh concurrent with a value update), and
homie.Propertyhas never had a lock, so_valueand_ever_publishedwere already exposed to it in kind. What 0.20.0 changed is that the consequence became durable rather than transient. That is what moves it from a latent wart to a fix.The fix
A per-property
RLock, held across compute/publish/memoize inset_value(),publish_value(),clear_value()andinvalidate_publish_cache().Reentrant, and not optionally so.
set_value()takes it and callspublish_value(), which on the retraction path callsclear_value(). A plainLockself-deadlocks on the commonest call in the SDK — verified by mutation, and the reason is recorded in the code so nobody "simplifies" it back.Held across the transport's
publish(), deliberately. Releasing earlier reopens the window it exists to close. That is safe for the paho transport the SDK ships, and the code records why, because it is not obvious. The dangerous shape would be an A-B/B-A cycle where the network thread holds a transport lock while invokingon_connect(→refresh_tree→publish_value, which wants this lock) thatpublish()also needs. In paho 2.x it does not arise:_handle_connackinvokeson_connectholding only_in_callback_mutexand acquires_out_message_mutexonly after the callback returns (the two blocks are sequential, not nested), and the one place the publish path touches_in_callback_mutex(_packet_queue) uses a non-blockingacquire(False)that threaded mode skips entirely.A bring-your-own transport could still construct that cycle by holding its own lock across the on-connect handler it wires to
refresh_tree()while also requiring that lock inpublish(). The code says so.Per-property, so it never serializes a tree walk, and no path holds two, so there is no lock-ordering hazard.
Also
get_last_published_value()reads the memo tuple once into a local instead of testing the attribute and then subscripting it. Under the GIL that window is practically unreachable; a free-threaded build removes the accident, and CI covers 3.13.timeout-minutes: 5. The suite runs in ~2s. A lock regression deadlocks whichever thread reaches it — making it non-reentrant deadlocks the main thread at the firstset_value, which is most of the suite — and unbounded, GitHub would let that run to its six-hour default instead of reporting a failure. Publish/release jobs are left unbounded: a slow PyPI upload is not the same event.Verification
ruff format --checkclean, markdownlint clean.TestHomiePropertyPublishThreadSafety, both mutation-verified:RLock()→nullcontext()) failstest_a_concurrent_forced_republish_cannot_leave_a_stale_memo, reproducing the race exactly: memo1.0while the wire last carried2.0.RLock→Lockfailstest_retraction_through_the_lock_does_not_deadlock.Note most of the
homie.pydiff is reindentation of two method bodies under awithblock;git diff -wshows the real change is ~57 lines.An adversarial review raised 8 findings and 7 were refuted (several TOCTOU claims were disproven by measurement). The one that survived — worker threads in the new test being non-daemon, so a future deadlock regression would hang CI after reporting the failure — is fixed with
daemon=True, and chasing it is what surfaced the missing CI timeouts above.🤖 Generated with Claude Code