Skip to content

[Claude/Opus] Detect stuck WebSocket connections via heartbeat and read timeout#12

Merged
dansimau merged 2 commits into
mainfrom
heartbeat-stale-connection-detection
Jul 10, 2026
Merged

[Claude/Opus] Detect stuck WebSocket connections via heartbeat and read timeout#12
dansimau merged 2 commits into
mainfrom
heartbeat-stale-connection-detection

Conversation

@dansimau

Copy link
Copy Markdown
Owner

Problem

Occasionally the connection to Home Assistant gets "stuck" — the WebSocket stays open but stops delivering data, so automations stop firing (e.g. zero events for an hour while the socket appears healthy).

Root cause: listen() blocked in conn.ReadMessage() with no read deadline. When a connection goes half-open (the peer stops sending data but no TCP FIN/RST arrives), that read never returns, so onDisconnected never fires and the existing reconnection loop never engages.

Changes

Implements both mechanisms suggested — they work together, since neither alone is fully reliable:

  1. Heartbeat (hassws/client.go) — A heartbeat() goroutine sends a Home Assistant ping every PingInterval (default 30s); HA replies with pong. This generates traffic during quiet periods so a healthy-but-idle connection isn't mistaken for a dead one. If the ping write itself fails, it closes the connection to trigger reconnect immediately.

  2. Staleness detection (hassws/client.go) — listen() now sets a read deadline (ReadTimeout, default 60s) that resets on every received frame (including pongs). If nothing arrives within the window, ReadMessage errors out and flows into the existing reconnection path. This is the reliable detector, since a half-open socket may keep accepting writes for a while.

  3. Write serialization — Added a writeMutex around all socket writes. The heartbeat introduces a concurrent writer, and gorilla/websocket forbids concurrent writes (this also fixes a latent race between concurrent CallService calls).

  4. Config (config.go, connection.go) — Configurable via hal.yaml (pingInterval, readTimeout), defaulting to 30s/60s. ReadTimeout should exceed PingInterval so pongs keep the deadline alive.

Tests

Extended the mock server to answer pings (with a SetRespondToPings(false) toggle to simulate a stuck connection), plus two new tests:

  • TestHeartbeatKeepsConnectionAlive — no events for 2× the read timeout with pings answered → no reconnect.
  • TestStaleConnectionTriggersReconnect — server stops answering pings → staleness detected → reconnects → recovers.

Verification

  • go build ./..., go vet ./..., gofmt — clean
  • Full test suite passes; no new lint violations

Note: with real Home Assistant this uses HA's application-level ping/pong, which proves the API itself is responsive — a stronger liveness check than a bare TCP/control-frame ping.

🤖 Generated with Claude Code

Previously listen() blocked in ReadMessage() with no read deadline, so a
half-open connection (peer stops sending data but no TCP FIN/RST arrives)
was never detected: onDisconnected never fired and the reconnection loop
never engaged.

This adds two complementary mechanisms:

- Heartbeat: a goroutine sends a Home Assistant ping every PingInterval
  (default 30s); HA replies with pong. This generates traffic during quiet
  periods so a healthy-but-idle connection is not mistaken for a dead one.
- Staleness detection: listen() sets a read deadline (ReadTimeout, default
  60s) that resets on every received frame. If nothing arrives within the
  window, ReadMessage errors out and flows into the existing reconnection
  path.

Both are configurable via hal.yaml (pingInterval, readTimeout). Also adds a
writeMutex around all socket writes, since the heartbeat introduces a
concurrent writer and gorilla/websocket forbids concurrent writes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dansimau dansimau changed the title Detect stuck WebSocket connections via heartbeat and read timeout [Claude/Opus] Detect stuck WebSocket connections via heartbeat and read timeout Jul 10, 2026

@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: d570c5f961

ℹ️ 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
return
}

_, msgBytes, err := conn.ReadMessage()

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 Close stale sockets before reconnecting

When ReadMessage returns because the newly-added read deadline expires, this branch logs and returns without closing the captured conn; the deferred cleanup stops the heartbeat and opens a replacement connection, but the old Home Assistant websocket can remain open server-side until HA times it out. In repeated stale/quiet periods this leaks websocket sessions while reconnecting, so close the captured connection before returning on read errors caused by the deadline.

Useful? React with 👍 / 👎.

Comment thread hassws/client.go Outdated
Comment on lines +86 to +87
if config.ReadTimeout == 0 {
config.ReadTimeout = defaultReadTimeout

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 Keep read timeout above custom ping intervals

If a user configures only PingInterval to a value above 60s and leaves ReadTimeout unset, this default still sets the read timeout to 60s, so a quiet connection hits the read deadline before the first heartbeat ping is sent and will reconnect continuously. Since these fields are exposed independently through Config, either validate ReadTimeout > PingInterval or derive the default read timeout from the configured ping interval.

Useful? React with 👍 / 👎.

Comment thread hassws/client.go Outdated
c.writeMutex.Lock()
defer c.writeMutex.Unlock()

return conn.WriteMessage(websocket.TextMessage, msgBytes)

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 Add a write deadline for heartbeat pings

When the network path is half-open or Home Assistant stops reading, this new heartbeat WriteMessage can block indefinitely while holding writeMutex; the reconnect path then authenticates/subscribes on a fresh socket via send(), but that also needs the same mutex, so recovery and even Close() can hang behind the stuck heartbeat write. Set a write deadline before heartbeat writes so a blackholed connection cannot wedge the client.

Useful? React with 👍 / 👎.

Comment thread config.go

// PingInterval is how often to send a heartbeat ping to Home Assistant to
// keep the connection active. Defaults to 30s if unset.
PingInterval time.Duration `yaml:"pingInterval"`

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 Pass heartbeat config to event streams

This exposes pingInterval/readTimeout as YAML knobs, but runEventsCommand builds its separate websocket client with only Host and Token (cmd/hal/commands/events.go:53-56), so hal events ignores the custom heartbeat settings. In environments where these values are tuned for a proxy or Home Assistant deployment, the main daemon uses the override while the long-running event stream still uses the hard-coded defaults; pass these fields into that client too.

Useful? React with 👍 / 👎.

…eout

- Close the captured socket in listen()'s deferred cleanup so a stale/
  half-open connection is not leaked server-side across reconnects.
- Add a write deadline to all websocket writes via a shared writeMessage
  helper, so a blackholed connection cannot wedge a write while holding
  writeMutex and block reconnection or shutdown.
- Derive the default ReadTimeout from PingInterval (2x) and guard against
  ReadTimeout <= PingInterval, which would otherwise reconnect continuously
  on quiet connections when a large custom PingInterval is set.
- Pass PingInterval/ReadTimeout to the `hal events` websocket client so it
  honours the configured heartbeat settings.
- Add unit tests for the read-timeout derivation and guard logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dansimau

Copy link
Copy Markdown
Owner Author

Addressed the Codex review feedback in 54e2524:

  1. Close stale sockets before reconnectinglisten()'s deferred cleanup now calls conn.Close() on the captured connection, so a stale/half-open socket is no longer left open server-side while we reconnect. It's idempotent with the existing shutdown()/heartbeat close paths.

  2. Keep read timeout above ping interval — the default ReadTimeout is now derived from PingInterval (2×) instead of a fixed 60s, and there's an explicit guard that bumps ReadTimeout (with a warning) if it's ever ≤ PingInterval. This preserves the existing 30s→60s defaults. Added TestNewClientReadTimeoutDefaults covering these cases.

  3. Write deadline for heartbeat pings — all websocket writes now go through a shared writeMessage() helper that sets a SetWriteDeadline (10s) under writeMutex, so a blackholed connection can't wedge a write and block reconnection/Close(). This covers the heartbeat, send(), and the close frame.

  4. Pass heartbeat config to event streamshal events now passes PingInterval/ReadTimeout into its websocket client so it honours the configured settings instead of the hard-coded defaults.

Build, go vet, and the full test suite pass.

@dansimau
dansimau merged commit 0e044b1 into main Jul 10, 2026
1 check passed
@dansimau
dansimau deleted the heartbeat-stale-connection-detection branch July 10, 2026 21:39
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