Skip to content

Fix Home Assistant connection errors: dropped calls and an unreachable HA at startup - #140

Merged
NinjaLikesCheez merged 4 commits into
mainfrom
fix/ha-connection-errors
Oct 19, 2026
Merged

NinjaLikesCheez merged 4 commits into
mainfrom
fix/ha-connection-errors

Conversation

@NinjaLikesCheez

Copy link
Copy Markdown
Contributor

What was wrong

Two problems in src/adapters/home-assistant/backend.ts, both checked against home-assistant-js-websocket 9.7.0 (connection.js, socket.js).

1. A call cut off by a dropped connection was reported as a bug. When the socket closes, the library rejects every call still in flight with a whole failed result: { type: "result", success: false, error: { code: 3 /* ERR_CONNECTION_LOST */, message: "Connection lost" } } (_handleClose → messages.error(...)). translateError only knew bare numbers and bare { code: string }, so it passed this object through unchanged. The core Home then logged home.failed ("unexpected Home Assistant failure") at error level, and Sentry got "Object captured as exception". Example: someone runs /ha set just as the Nabu Casa tunnel blips.

2. If Home Assistant was unreachable at startup, nobody heard about it. With setupRetry: -1, createSocket retries every second forever, logs nothing, and rejects only when the token is refused. So settled never resolved, home.check() (with its one-time Sentry report) and the home.devices_missing warning never ran, and nothing was logged. The doc comment ("Settles once the first attempt has either connected or given up") was wrong.

What changed

  • translateError (and describeFailure, and the refused-token check) now unwrap a failed result first. A numeric code inside (the library's own connection codes) means HomeUnavailableError. A string code inside (Home Assistant's own) means HomeRequestError, which keeps the code for the logs. Anything else still goes through unchanged. Messages never include the token, the address or HA's text.
  • Connecting: each attempt is now one try (setupRetry: 0), limited to 15 s overall. The library only times out opening the socket, so this limit also covers a login that never gets an answer. A connection that turns up after the limit is closed. If HA can't be reached (ERR_CANNOT_CONNECT, ERR_CONNECTION_LOST, or the time limit runs out), Pixel logs home.connect_failed at warn once, then retries in the background: 1 s, doubling, up to 60 s, with each later attempt logged only at debug. When it gets through it logs home.ready. A refused token or a bad address still turns it off, as before. Once connected, the library handles reconnects after a drop, as before. close() cancels any pending retry.
  • settled now settles within about 15 s whatever happens. The new connected promise settles the first time it connects.
  • src/index.ts: if the startup check says connecting, Pixel reports once to Sentry ("Couldn't connect to Home Assistant at startup. Still trying."). When connected settles it runs home.check() again, then the missing-devices check. That way the admin-token warning and home.devices_missing still happen once HA comes back.
  • Nothing about calls changes: they still fail closed, nothing is queued and actions are never retried.
  • Docs: added a line to the threat-model row in docs/home-assistant.md and to the runbook's "Home Assistant is unreachable" entry. Added a Fixed entry to the changelog.
  • src/testing/fake-home-assistant.ts: new hangService(key), so a test can leave a call in flight and then drop the connection.

How it was tested

New cases in backend.test.ts, all against the fake or a local ws server, never a real HA:

  • translateError with the connection-lost result, a failed result carrying an HA string code (the code is kept, the message text is not shown), and odd or successful results (passed through).
  • A service call left in flight when the connection drops now rejects with HomeUnavailableError.
  • HA down at startup: settled resolves with connecting, there is exactly one home.connect_failed warning, calls fail at once, retries log home.connect_retry, and it connects (connected, home.ready) once the server starts.
  • Backoff: the waits go 10, 20, 40, 40, 40 with { first: 10, max: 40 }.
  • A server that accepts the socket but never answers the login: the attempt gives up after attemptTimeoutMs (why: ConnectTimeout).
  • close() stops the retries. A bad address goes off (error level) and does not retry.

The old test "keeps trying… settled stays pending" was replaced, because that pending settled was the bug. just check passes: 1996 tests, coverage above every threshold.

Not touched: rule-14 files. core/home.ts is unchanged.

Reviewer note: an attempt that times out while the socket is still open, still waiting for a login answer, keeps that socket until the server or a proxy closes it. The library doesn't give access to the raw socket. A real Home Assistant doesn't behave this way.

🤖 Generated with Claude Code

…e HA at startup

A call in flight when the socket drops was rejected by the library with a
whole failed result ({type: "result", success: false, error: {code: 3}}),
which translateError passed through, so it was logged as an unexpected
failure and sent to Sentry as "Object captured as exception". It is now
unwrapped and treated as unreachable (string codes inside stay refusals).

With setupRetry -1 the library retried every second forever, silently, so
an unreachable Home Assistant at startup never settled: no log, no Sentry
report, and the login and devices checks never ran. Each attempt is now a
single, time-limited try; a failure logs home.connect_failed once, startup
reports it once, and Pixel keeps retrying with backoff (1 s doubling up to
60 s), running the checks once it connects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NinjaLikesCheez NinjaLikesCheez added the bug Something isn't working label Oct 18, 2026
Comment on lines +285 to 292
if (this.#closed) return;
if (isUnreachable(error)) {
this.#retryLater(error);
return;
}
this.#logger.error(
{ event: "home.connect_failed", why: describeFailure(error) },
"couldn't connect to Home Assistant",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The connected promise never settles if connection retries fail with a terminal error, causing the startup sequence awaiting backend.connected to hang indefinitely.
Severity: MEDIUM

Suggested Fix

Ensure connected settles or rejects when the backend transitions to off, or handle non-retryable errors during connection retries by rejecting the pending connected promise.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/adapters/home-assistant/backend.ts#L285-L292

Potential issue: The `connected` promise is only settled upon a successful connection.
If the initial connection attempt fails due to an unreachable Home Assistant instance
and a subsequent retry encounters a non-retryable error (such as an invalid token), the
backend transitions to `off` via `#turnOff()`, but `connected` never resolves or
rejects. Because the startup sequence in `index.ts` awaits `backend.connected`,
execution hangs, preventing the secondary `home.check()` and missing device checks from
ever running.

Did we get this right? 👍 / 👎 to inform future reviews.

@NinjaLikesCheez
NinjaLikesCheez merged commit 50b6b97 into main Oct 19, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant