fix(ws): hold SYNCING through the post-connect status replay#170
Conversation
The controller replays every node's current status (a burst of ST/DON/DOF frames) on every WebSocket connect. The stream notified CONNECTED the instant the socket opened, *before* that burst, so consumers that fire on events (HA `event` entities) treated the replay as live changes — spurious automation triggers on every HA restart / config-entry reload / WS reconnect. Add EventStreamStatus.SYNCING: socket open but initial replay still draining. The reader holds SYNCING and only flips to CONNECTED once the burst goes quiet for _SYNC_QUIET_SECONDS (1.0s; no frame), with a _SYNC_MAX_SECONDS (10s) hard cap so a chatty controller can't stall. Records still update during SYNCING (state stays correct); only the 'live event' signal is withheld. Re-armed every reconnect, so the WS-reconnect case is covered too, not just restart/reload. Empirically validated against IoX-Testing: device/state/attr triggers all spurious-fired on reload+restart before; SYNCING gates them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @shbatm's task in 2m 17s —— View job Code Review
This is a well-designed fix for a real problem. The approach — holding Issues1. Silent-controller edge case: first sample window always promotes immediately when seen = self._frame_count # 0, just reset above
while not self._stop_requested:
await asyncio.sleep(_SYNC_QUIET_SECONDS)
...
quiet = self._frame_count == seen # True if 0 frames ever arrivedFor a controller with no nodes (or one whose replay finishes in < 1 µs before any frame is seen), In practice this is fine — a completed replay is quiet — but it's worth documenting the invariant in the docstring: "a window in which 2. if self._keep_open:
await self._closed_evt.wait()
raise StopAsyncIterationWhen Observations3. No test for the The existing tests cover:
But there is no test for: frames keep arriving without pause until the deadline fires. A test like 4. CONNECTED = "connected"
SYNCING = "stream_syncing" # vs "syncing"
5. # Local copy — mypy narrows ``self._stop_requested`` to its
# loop-entry value across the await; the read loop / stop()
# may flip it during the sleep (same idiom as ``_run``).
stopping: bool = self._stop_requested
if stopping:
returnThe comment says "mypy narrows 6. Potential timing fragility in monkeypatch.setattr(ws_module, "_SYNC_QUIET_SECONDS", 0.2)
...
for _ in range(200):
if nodes["3D 7D 87 1"].properties["ST"].formatted == "On":
break
await asyncio.sleep(0.005) # 200 × 5 ms = 1 s budgetThen the test asserts SummaryThe core implementation is correct and clean. The |
Status changes were fanned to listeners but never logged, so the connect -> SYNCING -> CONNECTED -> reconnect sequence was invisible in debug logs without attaching a custom listener (had to during the live SYNCING verification). Log every real transition at DEBUG on pyisyox.runtime.ws; same-value re-notifies are not repeated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @shbatm's task in 3m 53s —— View job Code Review
This is a clean, well-thought-out fix. The second commit ( Below I cover the still-open items from the last review pass and a few new observations on the logging commit. New commit (
|
- Add test_ws_reader_max_cap_promotes_under_constant_traffic: covers the _SYNC_MAX_SECONDS hard-cap path under continuous traffic (review #3) via a new FakeWebSocket repeat= continuous-frame mode. - Harden test_ws_reader_holds_syncing_through_replay_then_connects with two asyncio.sleep(0) flushes so the SYNCING assertion can't race a same-tick quiet-timer promote on loaded CI (review #6). - Document the quiet-window invariant (idle/initial/fast-replay all treated as quiet) and the cap-vs-quiet timing note in _promote_when_quiet (review #1). - Reword the _stop_requested local-copy comment to match _run's established phrasing (review #5). - Clarify FakeWebSocket keep_open close/StopAsyncIteration flow (review #2). Review #4 (SYNCING 'stream_' prefix) is intentional — consistent with INITIALIZING; no change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @shbatm's task in 2m 41s —— View job Code Review
All six items from the previous two review passes are addressed. This is clean and ready; one minor nit below. Prior-review checklist
New test:
|
…nd 3 nit) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Problem
The controller replays every node's current status (a burst of
ST/DON/DOFframes) over the WebSocket on every connect.WebSocketEventStreamnotifiedEventStreamStatus.CONNECTEDthe instant the socket opened — before that burst arrives in the read loop. Consumers that fire on events (most notably Home Assistantevententities inhacs-udi-iox) therefore treated the replayed status as live changes, producing spurious automation triggers on every HA restart, config-entry reload, and WS reconnect.Empirically reproduced against the IoX-Testing controller: device-triggers, plain state-triggers, and attribute state-triggers on a udi_iox
evententity all fired with zero user action on both a config-entry reload and a full HA restart (on+offeach).Fix
Add
EventStreamStatus.SYNCING— socket is open but the initial status replay is still draining. The reader holdsSYNCINGand only flips toCONNECTEDonce the burst goes quiet for_SYNC_QUIET_SECONDS(1.0 s with no frame), with a_SYNC_MAX_SECONDS(10 s) hard cap so a perpetually chatty controller can't stall the stream. A small sampled watcher task (_promote_when_quiet) does the debounce so the hot read loop stays a plainasync for; it's cancelled in_connect_and_read'sfinally, so a socket that drops before settling never reportsCONNECTED.SYNCING(the dispatcher keeps feeding, so node/property state stays correct) — only the "stream is live" signal is withheld.connected/== CONNECTEDconsumers are unchanged structurally; they simply seeconnectedgo true ~1 s later (after the replay), which is the intended behavior.Consumer follow-up (separate, in hacs-udi-iox)
event.py::_on_controlwill gate emission on the synced flag (the existingsubscribe_ws_status→connected: boolplumbing already maps== CONNECTED). Not in this PR — pyisyox capability lands first; consumer policy follows.Tests
test_ws.py:FakeWebSocketgained an additivekeep_open(socket stays open with no frames so the quiet timer can be exercised);_SYNC_*are module-level for monkeypatching.INITIALIZING → SYNCING → CONNECTED; the reconnect-path tests now reach a live connection viakeep_openinstead of an instant-close.test_ws_reader_holds_syncing_through_replay_then_connects: a replayed frame dispatches into the record while the stream is stillSYNCINGandconnected is False, andCONNECTEDis only emitted after the quiet window.🤖 Generated with Claude Code