Skip to content

fix(ws): clear shared websocket on close and harden disconnect cleanup - #142

Open
jeeftor wants to merge 2 commits into
masterfrom
fix/ws-cleanup-and-shared-websocket-leak
Open

fix(ws): clear shared websocket on close and harden disconnect cleanup#142
jeeftor wants to merge 2 commits into
masterfrom
fix/ws-cleanup-and-shared-websocket-leak

Conversation

@jeeftor

@jeeftor jeeftor commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes all four bugs in the websocket layer that cause the weatherflow_cloud Home 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_connected guard in close()

close() referenced the bound method self.is_connected (always truthy) instead of calling self.is_connected(). The not-connected early-return guard therefore never fired, so cleanup always proceeded into a dead socket and threw ConnectionClosedOK. Now calls self.is_connected().

Bug 2 — _shared_websocket class-variable leak

close() only cleared the instance attribute self.websocket, never the class-level WeatherFlowWebsocketAPI._shared_websocket. After the server's idle timeout the dead socket stayed referenced, so connect() 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_listeners leaked unawaited coroutines

stop_all_listeners awaited coroutines sequentially, so the first ConnectionClosedOK aborted the loop and the remaining coroutines were never awaited — producing RuntimeWarning: coroutine 'WeatherFlowWebsocketAPI.send_message_and_wait' was never awaited. It now:

  • skips entirely when not connected, and
  • gathers with return_exceptions=True so no coroutine is left unawaited, logging ConnectionClosed at 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:

  • Subscription tracking: send_message / send_message_and_wait track listen_start / listen_rapid_start messages (and remove on the corresponding stop), so they can be replayed after reconnect.
  • Listen supervisor: connect() now starts a supervisor task that wraps listen() in a reconnect loop. On unexpected disconnect, it reconnects with exponential backoff (1s → 60s cap) and replays all active subscriptions on the new connection.
  • Shutdown signal: close() sets _shutting_down = True before teardown so the supervisor exits instead of reconnecting.
  • Cooperative scheduling: the supervisor yields to the event loop each iteration (asyncio.sleep(0)) so close() can run even when listen() returns instantly (already-closed socket).

Test plan

  • Existing suite still green (98 tests)
  • 16 new tests covering:
    • stop_all_listeners skips when not connected / no websocket
    • stop_all_listeners no longer leaks coroutines when sends raise ConnectionClosedOK
    • close() clears _shared_websocket on normal close
    • close() clears _shared_websocket on the already-closed (idle timeout) path
    • close() does not clear a shared socket owned by another instance
    • close() calls is_connected() (regression guard for the bound-method bug)
    • end-to-end: after close() clears the dead socket, connect() opens a fresh one (reload scenario)
    • subscription tracking: listen_start adds, listen_stop removes matching entry, other devices unaffected
    • supervisor reconnects after listen() exits
    • supervisor replays subscriptions after reconnect
    • close() prevents reconnection
    • close() clears active subscriptions
    • supervisor exits when close() is called during reconnect backoff
  • ruff check, ruff format, ty check, codespell, pyupgrade all pass

Generated with Devin

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread weatherflow4py/ws.py
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread weatherflow4py/ws.py
Comment on lines +332 to 343
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread weatherflow4py/ws.py
Comment on lines +379 to +383
if (
websocket is not None
and WeatherFlowWebsocketAPI._shared_websocket is websocket
):
WeatherFlowWebsocketAPI._shared_websocket = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Devin Review

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant