Skip to content

[Pi/Opus] Detect stuck websocket connections via heartbeat and read timeout#11

Closed
dansimau wants to merge 3 commits into
mainfrom
heartbeat-reconnect
Closed

[Pi/Opus] Detect stuck websocket connections via heartbeat and read timeout#11
dansimau wants to merge 3 commits into
mainfrom
heartbeat-reconnect

Conversation

@dansimau

Copy link
Copy Markdown
Owner

Problem

Occasionally the connection gets stuck: the WebSocket stays open (so no disconnect callback fires) but no longer receives data. The existing reconnection logic only triggered on a read error or close message, so a silently-dead connection would hang indefinitely — e.g. an hour with zero automations triggered despite an apparently healthy connection.

Fix

Two complementary detection mechanisms in the hassws client:

  1. Heartbeat (ping/pong) — a keepAlive goroutine sends a WebSocket ping frame every PingInterval (default 30s). Home Assistant replies with a pong, which extends the read deadline. If the ping write itself fails, the goroutine exits and a reconnect follows.

  2. Read/idle timeout — every connection has a read deadline (PongTimeout, default 60s), extended whenever any data or a pong arrives. If neither arrives within the window, ReadMessage returns a timeout error, listen exits, and the existing onDisconnected → reconnect path runs.

Together these cover both a healthy-but-idle connection (kept alive by pong) and a truly stuck one (forced to reconnect by the read deadline).

Other changes

  • New config options pingInterval and readTimeout in hal.yaml (both optional; sensible defaults, readTimeout must be > pingInterval).
  • Added a writeMutex since gorilla/websocket forbids concurrent writers and the ping goroutine now writes alongside normal senders.
  • Scoped listen/keepAlive goroutines per-connection so stale goroutines can't interfere after a reconnect.

Tests

  • Added Server.SimulateStuckConnection() test helper (stops replying to pings, sends no data).
  • Added TestStuckConnectionTriggersReconnect verifying the client detects a stuck connection via the read timeout, reconnects, and resumes receiving events.

All existing tests pass.

The websocket connection could get into a state where it was still open
(so no disconnect callback fired) but no longer receiving data. The
existing reconnection logic only triggered on a read error or close
message, so a silently-dead connection would hang indefinitely.

This adds two detection mechanisms in the hassws client:

- Heartbeat: a keepAlive goroutine sends a websocket ping every
  PingInterval (default 30s). A pong resets the read deadline.
- Read/idle timeout: every connection has a read deadline (PongTimeout,
  default 60s) that is extended on any received data or pong. If neither
  arrives in time, ReadMessage errors and the existing reconnect path
  runs.

Both are configurable via hal.yaml (pingInterval, readTimeout). Also
adds a writeMutex to serialize concurrent writes (gorilla/websocket does
not allow concurrent writers) and scopes the listen/keepAlive goroutines
per-connection.

Adds Server.SimulateStuckConnection() test helper and
TestStuckConnectionTriggersReconnect covering the new behaviour.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 844a10a10d

ℹ️ 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".

Comment thread hassws/client.go
Comment thread hassws/server.go Outdated
defer s.lock.Unlock()

if s.websocket != nil {
s.websocket.SetPingHandler(func(string) error { return nil })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Install the ping handler before the read loop

When SimulateStuckConnection is called after the test server is already in listen, this writes the gorilla websocket ping handler while ReadMessage is reading it; go test -race -run TestStuckConnectionTriggersReconnect reports a race at this line. That makes the new test fail under race-enabled CI, so prefer installing a handler once before the read loop and toggling the stuck behavior with an atomic flag.

Useful? React with 👍 / 👎.

Comment thread hassws/client.go
Comment thread hassws/client.go
dansimau added 2 commits July 10, 2026 22:58
The mock server shared a single `s.websocket` and `s.subscribers` across
connections. When a stuck connection was later abandoned, the old
listen() goroutine's deferred cleanup wiped the subscriber list that the
reconnected client had just registered, so events sent after reconnect
went nowhere (the CI failure).

- Scope the server listen loop to its own connection and only clear
  subscribers if it is still the active connection.
- Reset subscribers when a new connection is accepted.
- Simulate a stuck connection via an atomic flag checked in a ping
  handler installed once at connect time, instead of racily calling
  SetPingHandler concurrently with ReadMessage.
- Close abandoned connections client-side on disconnect to avoid leaking
  the socket and to let the remote end observe the closure.
- Ensure PongTimeout is always greater than PingInterval. A misconfigured
  readTimeout <= pingInterval could otherwise expire the read deadline on
  a quiet-but-healthy connection before a ping/pong refreshes it, causing
  a reconnect loop. Bump it to 2x the ping interval and warn.
- On a ping write failure, close the connection so the read loop errors
  out and the reconnect path runs. Previously an asymmetric failure
  (writes failing, reads still arriving) left the client in
  stateConnected with silently failing service calls.
- Add unit tests for NewClient timeout defaulting/validation.

The earlier feedback about closing timed-out sockets before reconnecting
and installing the ping handler before the read loop (avoiding the
SetPingHandler data race) was already addressed in 32310a4.
@dansimau

Copy link
Copy Markdown
Owner Author

Thanks for the review! Addressed all four points:

  1. Validate read timeout against ping intervalNewClient now bumps PongTimeout to 2 * PingInterval (with a warning) if it's <= PingInterval, preventing a reconnect loop on a quiet-but-healthy connection. Added unit tests. (62bf4a0)
  2. Trigger reconnect when ping writes fail — the keepAlive goroutine now closes the connection on a ping write error so the read loop errors out and the reconnect path runs, instead of silently leaving the client in stateConnected. (62bf4a0)
  3. Close timed-out sockets before reconnecting — the client's listen defer now closes the per-connection conn on the error path, avoiding leaked client/server sockets on repeated timeouts. (32310a4)
  4. Install the ping handler before the read loop — the test server now installs the ping handler once at connect time and toggles a stuck state via an atomic flag, removing the SetPingHandler/ReadMessage data race. (32310a4)

@dansimau dansimau changed the title Detect stuck websocket connections via heartbeat and read timeout [Pi/Opus] Detect stuck websocket connections via heartbeat and read timeout Jul 10, 2026
@dansimau dansimau closed this Jul 11, 2026
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