Fix entity state stuck out-of-sync via out-of-order event delivery#14
Conversation
Local entity state could get stuck disagreeing with Home Assistant (e.g. HAL reports a light "on" while it is really "off"). The state dump showed a mix of two events (state/last_changed from an "on" event, last_updated from a later "off" event), i.e. a newer state overwritten by an older one. Root cause was three things together: - hassws/client.go dispatched every incoming frame in a new goroutine onto a shared unbuffered channel. Go gives no ordering among goroutines racing on a channel send, so rapid on/off events (plus the Hue strip's post-on attribute-settle events) could reach the handler reordered. - Entity.SetState is an unconditional overwrite and there was no staleness check anywhere, so processing was strictly last-writer-wins. - The state only self-healed on the next reconnect via syncStates. Fix (two layers): - Deliver events in order: buffer the per-listener response channel (eventChannelBufferSize) and send sequentially from the single read loop instead of spawning a goroutine per message. The buffer keeps the read loop from blocking so synchronous CallService responses still flow. - Add a staleness guard in StateChangeEvent: drop an update whose LastUpdated is older than the currently held state (equal still applies). Also stop a nil NewState from overwriting the stored/persisted state with nil. Adds tests for in-order delivery, stale-update rejection, and nil-state handling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Line 369 in aabb588
When an automation triggered by a subscription event calls a synchronous service method (for example Light.TurnOn calls Connection.CallService at entity_light.go:64), the handler waits for a result that is delivered by this same websocket read loop. If more than eventChannelBufferSize subscription frames are already queued ahead of that result, this synchronous send blocks on the full subscription channel, so the reader can no longer read and deliver the service result and the action times out instead of completing. Preserving per-subscription order needs a queue/drainer that does not let one busy subscription stop the shared response reader.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Addresses PR review (Codex): buffering the per-listener channel and sending sequentially preserved order but did not fully decouple the read loop. A subscription handler that makes a synchronous service call (e.g. Light.TurnOn -> CallService) waits for a result delivered by the same read loop; if more than eventChannelBufferSize frames were queued ahead of that result, the read loop would block on the full event channel and the call would time out. Introduce dispatchInOrder: a drainer pulls frames off the read-loop channel immediately into an unbounded, order-preserving queue, and a single feeder invokes the handler in receipt order. The reader can never be stalled by a slow or reentrant handler, while per-subscription order is preserved. Both SubscribeEvents and SubscribeEventsRaw now use it; the channel buffer is now just a small handoff/startup smoother. Adds TestReaderNotBlockedByBlockedHandler, which blocks a handler while a backlog larger than the buffer arrives and asserts a concurrent service call still completes (fails against the previous buffer-only approach). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Good catch — the buffered channel + sequential send preserved ordering but didn't fully decouple the reader, so a handler blocked on a synchronous Fixed in 5b3bb28 with a drainer ( Added |
The dispatchInOrder drainer (a goroutine feeding an unbounded mutex/cond queue) was more machinery than the failure it guarded against warrants. If the event channel fills while a handler is blocked on a synchronous CallService, the reader backpressures and the call can time out - but that is rare (needs a burst larger than the buffer within the 3s call timeout), and it self-heals: the call times out, the handler unblocks, the backlog drains. No crash, no lost events, no state corruption. Drop the drainer and instead size the per-listener channel buffer generously (256 -> 8192; a chan []byte only preallocates slice headers, so this is cheap). SubscribeEvents/SubscribeEventsRaw consume the channel directly in a single goroutine again, which still preserves order (single consumer, FIFO channel). The sequential send in the read loop and the staleness guard in StateChangeEvent are unchanged, so the original out-of-order-state fix stands. Remove TestReaderNotBlockedByBlockedHandler, which asserted a "reader never stalls" guarantee we are intentionally no longer providing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up: I reconsidered the drainer (63605fe) and removed it in favour of a simpler large bounded channel. Rationale: the worst case if the event channel fills while a handler is mid- Since a Net: -118 lines vs the drainer version. I dropped |
CallServiceresponses still flowLastUpdatedis older than the currently held state