fix(ws): clear shared websocket on close and harden disconnect cleanup - #142
fix(ws): clear shared websocket on close and harden disconnect cleanup#142jeeftor wants to merge 2 commits into
Conversation
Fixes three bugs in the websocket layer that caused the weatherflow_cloud integration to lose wind sensors after a few days and require a full HA restart to reload (home-assistant/core#174833, #141, #134): - close() referenced the bound method `is_connected` instead of calling `is_connected()`, so the not-connected early-return guard never fired and cleanup always ran into a dead socket. - close() only cleared the instance `self.websocket`, never the class-level `_shared_websocket`. After the server's idle timeout the dead socket stayed referenced, so connect() reused it on reload until the process restarted. Now the class reference is cleared on both the early-return and normal close paths (only when it points at the socket being closed). - stop_all_listeners awaited coroutines sequentially, so the first ConnectionClosedOK aborted the loop and leaked the remaining coroutines ("RuntimeWarning: coroutine send_message_and_wait was never awaited"). It now skips when not connected and gathers with return_exceptions so no coroutine is left unawaited. Closes #141
There was a problem hiding this comment.
Devin Review found 3 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| # already closed the socket) doesn't leave the remaining coroutines | ||
| # unawaited, which previously produced | ||
| # "RuntimeWarning: coroutine 'send_message_and_wait' was never awaited". | ||
| results = await asyncio.gather(*stop_coros, return_exceptions=True) |
There was a problem hiding this comment.
🟡 Concurrent stop acknowledgements are lost
With multiple stop requests, gather installs competing acknowledgement callbacks concurrently. Bursty replies reach only the last callback, delaying shutdown until the others time out.
Prompt for agents
Rework WeatherFlowWebsocketAPI.stop_all_listeners in weatherflow4py/ws.py so stop requests do not concurrently overwrite the single ACKNOWLEDGEMENT callback used by send_message_and_wait. Preserve the requirement that every created coroutine is awaited or closed when a send fails. A sequential, lazily created request flow is one option; a correlation-aware acknowledgement dispatcher is another. Add a test that delivers several ACK frames back-to-back for multiple devices and verifies every wait completes without timing out.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if not self.is_connected(): | ||
| # The socket is already gone (e.g. server idle timeout). Clear the | ||
| # class-level reference so a subsequent connect() opens a fresh | ||
| # socket instead of reusing this dead one. | ||
| if ( | ||
| websocket is not None | ||
| and WeatherFlowWebsocketAPI._shared_websocket is websocket | ||
| ): | ||
| WeatherFlowWebsocketAPI._shared_websocket = None | ||
| self.websocket = None | ||
| self.is_listening = False | ||
| return |
There was a problem hiding this comment.
🟡 Closed sockets leave listeners running
When the socket closes during a callback, close returns without cancelling listen_task. Shutdown can finish while that callback continues using unloaded resources.
Prompt for agents
Make WeatherFlowWebsocketAPI.close clean up listen_task even when is_connected() is false. The early-return path currently clears websocket state but can leave listen() running if the transport closed while an awaited callback is still executing. Cancel and await any unfinished listener with the supplied timeout before returning, while retaining the dead shared-socket cleanup. Add a regression test with a callback blocked on an event and a websocket whose state changes to CLOSED.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if ( | ||
| websocket is not None | ||
| and WeatherFlowWebsocketAPI._shared_websocket is websocket | ||
| ): | ||
| WeatherFlowWebsocketAPI._shared_websocket = None |
There was a problem hiding this comment.
🔴 One client disconnects every client
When clients share a connection, one client’s close removes it without tracking remaining owners. Their active listeners then lose weather updates.
Prompt for agents
Define and enforce shared-connection ownership for WeatherFlowWebsocketAPI. connect() deliberately lets multiple instances use _shared_websocket, so close() cannot unconditionally close and clear that connection while another instance still owns it. Track connected owners or avoid sharing connections; ensure closing one instance removes only its subscriptions/listener and closes the transport only for the last owner. Add a test where two instances connect to the same socket, one closes, and the other continues receiving data.
Was this helpful? React with 👍 or 👎 to provide feedback.
When the WeatherFlow server closes the websocket (idle timeout, keepalive ping timeout), the listen loop previously just exited and nothing reconnected — wind sensors went stale permanently while REST-polling sensors kept working (home-assistant/core#174833, #141 Bug 4). Add a listen supervisor that wraps listen() in a reconnect loop: - Tracks active listen_start/listen_rapid_start subscriptions so they can be replayed after reconnect (listen_stop removes the matching entry). - On unexpected disconnect, reconnects with exponential backoff (1s → 60s cap) and replays all active subscriptions on the new connection. - close() sets _shutting_down before teardown so the supervisor exits instead of reconnecting. - Yields to the event loop each iteration so close() can run even when listen() returns instantly (already-closed socket). Closes #141 (Bug 4)
Summary
Fixes all four bugs in the websocket layer that cause the
weatherflow_cloudHome Assistant integration to lose wind sensors after a few days and require a full HA restart to reload.Tracking issue: #141. Also relates to #134 and home-assistant/core#174833.
Bug 1 — broken
is_connectedguard inclose()close()referenced the bound methodself.is_connected(always truthy) instead of callingself.is_connected(). The not-connected early-return guard therefore never fired, so cleanup always proceeded into a dead socket and threwConnectionClosedOK. Now callsself.is_connected().Bug 2 —
_shared_websocketclass-variable leakclose()only cleared the instance attributeself.websocket, never the class-levelWeatherFlowWebsocketAPI._shared_websocket. After the server's idle timeout the dead socket stayed referenced, soconnect()reused it on reload — which is why the entry couldn't come back up without a full HA restart (only process death cleared the class variable). Now the class reference is cleared on both the early-return and normal close paths, but only when it points at the socket being closed (so a different instance's shared socket is left intact).Bug 3 —
stop_all_listenersleaked unawaited coroutinesstop_all_listenersawaited coroutines sequentially, so the firstConnectionClosedOKaborted the loop and the remaining coroutines were never awaited — producingRuntimeWarning: coroutine 'WeatherFlowWebsocketAPI.send_message_and_wait' was never awaited. It now:return_exceptions=Trueso no coroutine is left unawaited, loggingConnectionClosedat debug and unexpected errors at warning.Bug 4 — No reconnection when the server closes the socket (the stale-sensors symptom)
listen()just exited when the connection dropped and nothing reconnected. Wind data only arrives via websocket, so once the server killed the socket (idle timeout / keepalive ping timeout), wind sensors went stale permanently. Added:send_message/send_message_and_waittracklisten_start/listen_rapid_startmessages (and remove on the correspondingstop), so they can be replayed after reconnect.connect()now starts a supervisor task that wrapslisten()in a reconnect loop. On unexpected disconnect, it reconnects with exponential backoff (1s → 60s cap) and replays all active subscriptions on the new connection.close()sets_shutting_down = Truebefore teardown so the supervisor exits instead of reconnecting.asyncio.sleep(0)) soclose()can run even whenlisten()returns instantly (already-closed socket).Test plan
stop_all_listenersskips when not connected / no websocketstop_all_listenersno longer leaks coroutines when sends raiseConnectionClosedOKclose()clears_shared_websocketon normal closeclose()clears_shared_websocketon the already-closed (idle timeout) pathclose()does not clear a shared socket owned by another instanceclose()callsis_connected()(regression guard for the bound-method bug)close()clears the dead socket,connect()opens a fresh one (reload scenario)listen_startadds,listen_stopremoves matching entry, other devices unaffectedlisten()exitsclose()prevents reconnectionclose()clears active subscriptionsclose()is called during reconnect backoffruff check,ruff format,ty check,codespell,pyupgradeall passGenerated with Devin