Skip to content

Report a refused or unreachable cast instead of hanging on it - #71

Merged
Mincka merged 3 commits into
mainfrom
bugfix/cast-hang-and-refusal
Sep 7, 2026
Merged

Mincka merged 3 commits into
mainfrom
bugfix/cast-hang-and-refusal

Conversation

@Mincka

@Mincka Mincka commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Description

Supersedes #67 (author unresponsive since the review). The first commit is @chsienki's commit from that PR, cherry-picked as-is for credit. The second commit applies the changes requested in the #67 review.

A cold cast to a Chromecast could hang the spotcast.play service call indefinitely, and a device that refused the Spotify credentials produced only a generic timeout. Four defects, all verified against the vendored pychromecast 14.0.10 source:

  1. Unbounded Chromecast.wait(), both in async_build_from_type (the first and actually blocking one, media_player/utils.py) and twice in launch_app. All three now go through wait_for_connection, bounded by CONNECT_TIMEOUT (20s). Chromecast.wait(timeout=...) raises RequestTimeout (a PyChromecastError) rather than returning, so the helper wraps it and re-raises AppLaunchError, which Home Assistant renders as a proper error.
  2. The launch loop only checked is_launched, so a refusal that had already arrived was polled over until max_attempts ran out. The loop now checks credential_error, and both fields are reset at the top of launch_app.
  3. _add_user_error_handler and _transfer_error_handler raised on pychromecast's socket thread, where _route_message catches and logs the exception (socket_client.py:735). They now record the failure and let the waiting thread raise it. launch_error is written before credential_error because the waiting thread polls the flag every second and would otherwise read the generic fallback.
  4. The handlers dropped the payload. The refusal is now reported with the device's own status, statusString, spotifyError and reason.

Relative to the #67 diff, this drops the socket_client.is_connected guard (it is literally not connecting and flips during every reconnect attempt) and the mock-only "unreachable device" test that passed on a path production never takes.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

No service, entity or WebSocket shape changes. CHANGELOG updated under Unreleased.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented on my code where necessary
  • I have added tests that are covering all code branches
  • New and existing unit tests pass locally with my changes

Verification

  • Full suite: 801 tests, OK (Python 3.14).
  • pylint --fail-under 9 custom_components: 10.00/10.
  • New tests inject the real RequestTimeout, pin the assignment order in both handlers, and pin the utils.py call so it cannot revert to a bare media_player.wait.

🤖 Generated with Claude Code

Copilot AI and others added 3 commits September 7, 2026 09:05
A cold cast to a Chromecast could hang indefinitely: Home Assistant does not
answer a service call until the service returns, so the caller waited with
nothing to time out against. Observed as a service call still unanswered after
150 seconds, while the device sat untouched.

Four things combined to produce that, and each is a problem on its own.

launch_app waited on Chromecast.wait() twice with no timeout. When the socket
client cannot reach the device it retries forever, so an unreachable device
blocked the call rather than failing it. The wait is now bounded and reports
what happened.

The wait loop only ever checked is_launched. A device that had already replied
refusing the credentials was polled over until max_attempts ran out, then
reported as "Timeout when waiting for status response from Spotify app" -- a
wait for an answer that had arrived and said no.

_add_user_error_handler and _transfer_error_handler raised. They run on the
socket client's message-routing thread, where the exception is logged as
"Error doing job: AppLaunchError exception in shielded future" and discarded,
so it never reached the caller. They now record the failure and let the
waiting thread raise it.

Both handlers took *_, **__ and dropped the payload, which is the only account
of why Spotify refused: status, statusString and spotifyError. A refusal is
now reported as "Spotify refused the credentials for this device (status=108,
statusString=ERROR-CANNOT-LOAD, spotifyError=409)" rather than a fixed string,
which is the difference between knowing to re-authorize and guessing.

Verified against a live device that had been failing: the same cast returned
in 9.5 seconds and started playback, where it had previously not answered at
all.
…aller

Follow-up to the cherry-picked fix from #67:

- `Chromecast.wait(timeout=...)` raises `RequestTimeout` rather than
  returning, so the bounded wait is wrapped in `wait_for_connection`
  and re-raised as `AppLaunchError`, which Home Assistant renders.
- The first unbounded wait was in `async_build_from_type`, before
  `launch_app` ever ran; it now uses the same bounded helper.
- Dropped the `socket_client.is_connected` guard, which only reflects
  `not connecting` and fires transiently during reconnects.
- Both error handlers record `launch_error` before setting
  `credential_error`, since the waiting thread polls the flag and
  would otherwise fall back to the generic message.
- CHANGELOG entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ride

Home Assistant pins PyJWT 2.13.0 itself from 2026.8.0, so the uv
override that forced it in the dev environment is no longer needed.
`uv lock --upgrade` moves the dev lock from HA 2026.7.2 to 2026.9.1
(pylint 4.0.8, coverage 7.16.0, RapidFuzz 3.14.6 and friends). A
pip-audit over the locked environment reports no known vulnerabilities.

Also applies the one pylint refactor hint left in `rate_limit.py`
(`max()` instead of an `if` block), which is behaviour-preserving.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Mincka
Mincka merged commit 334a01c into main Sep 7, 2026
7 checks passed
@Mincka
Mincka deleted the bugfix/cast-hang-and-refusal branch September 7, 2026 07:14
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.

2 participants