Skip to content

Commit 8e8606a

Browse files
fix: bound each addon HA-link probe by the readiness deadline (#2028)
* fix: bound each addon HA-link probe by the readiness deadline `_probe_addon_ha_link` passed no timeout to `Client` or `call_tool`, so an attempt inherited FastMCP's Streamable HTTP default of `httpx.Timeout(30.0, read=300.0)` (client/transports/http.py). The poll loop only consulted the deadline between attempts, so a listener that accepted the connection but stalled on initialize or the tool response could hold `wait_for_addon_ha_link_ready(timeout=180)` for ~300s per attempt — delaying the fixture failure and its HAOS diagnostics well past the advertised budget, and making the docstring's ceiling untrue. Pass the time left on the deadline into each attempt and apply it at all three layers that can stall independently (connect/initialize via `init_timeout`, the tool response via `call_tool(timeout=...)`, and the surrounding coroutine via `asyncio.wait_for`). Cap the inter-attempt sleep the same way so the budget is exact rather than exact-plus-one-poll. Reported by Codex on #2025. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: run the e2e suites when the change classifier does not succeed The classifier is written to fail closed — any error inside the script leaves `run=true` — but that only covers the script choosing a value. It does not cover the job itself not succeeding. `if: needs.changes.outputs.run == 'true'` carries an implicit `success()` on `needs`, so a classifier job that fails (on #2028 it failed with no steps and no runner assigned) skips the suite, and a skipped required check reports Success to branch protection. A runner hiccup in a five-minute filter job could therefore wave code through untested — the exact hole the classifier comment says the design avoids. Gate the four suites on `!cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run == 'true')` instead: a classifier that succeeded and said docs-only still skips (unchanged), a classifier that did not succeed now runs the suite, and a real cancellation still cancels. pr.yml's `e2e-validation-gate` had the same hole from the other side: it reads `needs.changes.outputs.run != 'true'` to detect a docs-only PR, and a failed classifier leaves that output empty, so the gate passed without judging any lane. It now requires the classifier to have succeeded before honoring the skip and otherwise falls through to the lane results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: retry stalled link probes and classify their teardown errors Review follow-ups on the per-attempt budget, all in the same failure path: Bounding an attempt means cancelling a live fastmcp session, which cancels the SDK's anyio task group; its teardown groups that CancelledError with any child failure. A group carrying a BaseException leaf is not an Exception, so it escaped the poll loop as a raw anyio traceback — losing the remaining retry budget and the caller's diagnostics pointer, which is exactly the failure mode this helper exists to prevent. Classify groups the way custom_components/ha_mcp_tools/llm_api.py already does: catch BaseExceptionGroup, but retry only when EVERY leaf is transient so a real bug inside the group still propagates. Also add the anyio stream errors (ClosedResourceError, BrokenResourceError, EndOfStream) that a mid-read teardown raises — plain Exceptions, not OSError. Handing each attempt all the remaining time made the first stall monopolize the window, so a stall was retried zero times while the docstring promised "transient errors are retried". Cap each attempt at _ADDON_HA_LINK_PROBE_S and retry within the overall budget. Set fastmcp's own deadlines strictly tighter than the outer backstop. Each starts its clock later than the one around it, so with equal values the backstop always won the race and the phase-naming errors ("Failed to initialize server session", the session read timeout) could never fire — every stall degraded to a bare TimeoutError() whose repr says nothing. Name the target when the backstop does fire, and log the first transient at INFO, since the e2e harness runs at INFO and the cause was previously DEBUG-only: a failed link reported no reason at all. Correct two overstated comments: the total can overshoot by fastmcp's shielded disconnect timeout, and only the read component of the connect phase is budget-scoped. Skip gates now require an explicit `run == 'false'` rather than treating any non-`true` value as docs-only, so a future edit that lets the classifier exit 0 without writing its output cannot silently reopen the hole this PR closes. tests/src/unit/test_e2e_skip_gate_shape.py walks every classifier-gated lane and pins both clauses plus the pr.yml gate step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c3687b2 commit 8e8606a

7 files changed

Lines changed: 490 additions & 26 deletions

File tree

.github/workflows/haos-e2e-embedded-tests.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,14 @@ jobs:
116116
needs: changes
117117
# A skipped job reports Success to the required status check (unlike a
118118
# workflow-level path-skip, which never reports). See the `changes` job.
119-
if: needs.changes.outputs.run == 'true'
119+
# Skipping requires a classifier that SUCCEEDED and said `false` out loud.
120+
# Two ways this used to fail open: an `if:` with no status-check function
121+
# implicitly ANDs with success(), so a failed classifier skipped this suite
122+
# and reported Success (a runner hiccup in a 5-minute filter job waving code
123+
# through untested); and an empty `run` — which is what a non-success
124+
# classifier leaves behind — read the same as "docs-only". Any value other
125+
# than `false` now runs. `!cancelled()` keeps a real cancellation cancelling.
126+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
120127
runs-on: ubuntu-22.04
121128
# 60 (vs the external/inaddon lanes' 45): the embedded lane pays a per-worker
122129
# session-setup cost the others don't — enabling the entry kicks off a runtime

.github/workflows/haos-e2e-inaddon-tests.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,14 @@ jobs:
119119
needs: changes
120120
# A skipped job reports Success to the required status check (unlike a
121121
# workflow-level path-skip, which never reports). See the `changes` job.
122-
if: needs.changes.outputs.run == 'true'
122+
# Skipping requires a classifier that SUCCEEDED and said `false` out loud.
123+
# Two ways this used to fail open: an `if:` with no status-check function
124+
# implicitly ANDs with success(), so a failed classifier skipped this suite
125+
# and reported Success (a runner hiccup in a 5-minute filter job waving code
126+
# through untested); and an empty `run` — which is what a non-success
127+
# classifier leaves behind — read the same as "docs-only". Any value other
128+
# than `false` now runs. `!cancelled()` keeps a real cancellation cancelling.
129+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
123130
runs-on: ubuntu-22.04
124131
timeout-minutes: 45
125132

.github/workflows/haos-e2e-tests.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,14 @@ jobs:
109109
needs: changes
110110
# A skipped job reports Success to the required status check (unlike a
111111
# workflow-level path-skip, which never reports). See the `changes` job.
112-
if: needs.changes.outputs.run == 'true'
112+
# Skipping requires a classifier that SUCCEEDED and said `false` out loud.
113+
# Two ways this used to fail open: an `if:` with no status-check function
114+
# implicitly ANDs with success(), so a failed classifier skipped this suite
115+
# and reported Success (a runner hiccup in a 5-minute filter job waving code
116+
# through untested); and an empty `run` — which is what a non-success
117+
# classifier leaves behind — read the same as "docs-only". Any value other
118+
# than `false` now runs. `!cancelled()` keeps a real cancellation cancelling.
119+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
113120
runs-on: ubuntu-22.04
114121
timeout-minutes: 45
115122

.github/workflows/pr.yml

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,17 @@ jobs:
9090
steps:
9191
- name: Require E2E success unless skipped
9292
run: |
93-
if [ "${{ needs.changes.outputs.run }}" != "true" ]; then
93+
# Only a classifier that SUCCEEDED and said `false` out loud can
94+
# authorize the skip. A non-success classifier leaves `run` empty, and
95+
# passing on that would make a runner hiccup indistinguishable from a
96+
# docs-only PR — the lanes above run in that case, so fall through and
97+
# judge them on their results. Keep this branch equivalent to the lane
98+
# `if:` predicates above; if they diverge, this gate demands success
99+
# from a lane that never ran.
100+
classifier_result="${{ needs.changes.result }}"
101+
if [ "$classifier_result" != "success" ]; then
102+
echo "::warning::change classifier did not succeed (result=$classifier_result); judging the lanes on their own results."
103+
elif [ "${{ needs.changes.outputs.run }}" = "false" ]; then
94104
echo "Docs/website-only PR — E2E Validation skipped; gate passes."
95105
exit 0
96106
fi
@@ -104,7 +114,12 @@ jobs:
104114
# the update-path lane must all pass. Any one failing wedges the
105115
# required gate.
106116
if [ "$container_result" != "success" ] || [ "$embedded_result" != "success" ] || [ "$update_path_result" != "success" ]; then
107-
echo "::error::E2E Validation did not succeed (container=$container_result, embedded=$embedded_result, update-path=$update_path_result)."
117+
# `skipped` here is NOT the docs-only skip (that exited 0 above): the
118+
# lanes never started, e.g. the run was cancelled.
119+
case "$container_result$embedded_result$update_path_result" in
120+
*skipped*) echo "::error::E2E Validation lanes did not start (results: container=$container_result, embedded=$embedded_result, update-path=$update_path_result) — the run may have been cancelled." ;;
121+
*) echo "::error::E2E Validation did not succeed (container=$container_result, embedded=$embedded_result, update-path=$update_path_result)." ;;
122+
esac
108123
exit 1
109124
fi
110125
@@ -337,7 +352,11 @@ jobs:
337352
needs: changes
338353
# A skipped job reports Success to the required status check, so a
339354
# docs/website-only PR doesn't wedge the merge waiting on these lanes.
340-
if: needs.changes.outputs.run == 'true'
355+
# Skipping requires a classifier that succeeded and said `false` out loud;
356+
# anything else (a failed classifier leaves `run` empty) runs this lane.
357+
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
358+
# if they diverge, the gate demands success from a lane that never ran.
359+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
341360
runs-on: ${{ matrix.os }}
342361
# 20 (was 15): headroom for the in-process-server e2e test that runs on
343362
# THIS lane (workflows/embedded/test_embedded_server.py) - its first
@@ -438,7 +457,11 @@ jobs:
438457
needs: changes
439458
# A skipped job reports Success, so a docs/website-only PR doesn't wedge the
440459
# gate waiting on this lane (mirrors e2e-validation).
441-
if: needs.changes.outputs.run == 'true'
460+
# Skipping requires a classifier that succeeded and said `false` out loud;
461+
# anything else (a failed classifier leaves `run` empty) runs this lane.
462+
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
463+
# if they diverge, the gate demands success from a lane that never ran.
464+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
442465
runs-on: ${{ matrix.os }}
443466
timeout-minutes: 25
444467
strategy:
@@ -537,7 +560,11 @@ jobs:
537560
needs: changes
538561
# A skipped job reports Success, so a docs/website-only PR doesn't wedge the
539562
# gate waiting on this lane (mirrors e2e-validation).
540-
if: needs.changes.outputs.run == 'true'
563+
# Skipping requires a classifier that succeeded and said `false` out loud;
564+
# anything else (a failed classifier leaves `run` empty) runs this lane.
565+
# Keep this predicate equivalent to the skip branch in e2e-validation-gate:
566+
# if they diverge, the gate demands success from a lane that never ran.
567+
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') }}
541568
runs-on: ubuntu-latest
542569
# 30m: each of the two scenarios (component@stable and component@working-tree)
543570
# does a first bring-up that installs ha-mcp plus the full fastmcp dependency

tests/src/haos_runtime.py

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,10 +1379,14 @@ def wait_for_addon_mcp_ready(*, timeout: float = 300.0) -> str:
13791379
# when something is genuinely wrong.
13801380
_ADDON_HA_LINK_TIMEOUT_S = 180.0
13811381
_ADDON_HA_LINK_POLL_S = 3.0
1382+
# Ceiling for ONE attempt. Long enough for a healthy connect + initialize +
1383+
# tool round trip on a loaded runner, short enough that a stalled attempt is
1384+
# abandoned and retried inside the overall budget instead of monopolizing it.
1385+
_ADDON_HA_LINK_PROBE_S = 30.0
13821386

13831387

1384-
def _addon_link_transient_errors() -> tuple[type[BaseException], ...]:
1385-
"""Return the errors that mean "not linked yet" rather than "broken".
1388+
def _addon_link_transient_leaves() -> tuple[type[BaseException], ...]:
1389+
"""Return the leaf errors that mean "not linked yet" rather than "broken".
13861390
13871391
Mirrors the suite's canonical MCP-polling set
13881392
(``e2e/utilities/wait_helpers._POLLING_TRANSIENT_ERRORS``) plus httpx
@@ -1393,6 +1397,7 @@ def _addon_link_transient_errors() -> tuple[type[BaseException], ...]:
13931397
``test_haos_addon_ha_link_wait`` pins that it stays a superset of the
13941398
canonical tuple.
13951399
"""
1400+
import anyio
13961401
import httpx
13971402
from fastmcp.exceptions import ClientError, FastMCPError
13981403
from mcp import McpError
@@ -1405,23 +1410,96 @@ def _addon_link_transient_errors() -> tuple[type[BaseException], ...]:
14051410
OSError,
14061411
TimeoutError,
14071412
httpx.HTTPError,
1413+
# Cancelling a stalled attempt tears the streamable-HTTP memory
1414+
# streams down mid-read. These are plain Exceptions, not OSError.
1415+
anyio.ClosedResourceError,
1416+
anyio.BrokenResourceError,
1417+
anyio.EndOfStream,
14081418
)
14091419

14101420

1411-
def _probe_addon_ha_link(addon_mcp_url: str) -> None:
1412-
"""Make one MCP call that needs Home Assistant; raise if it does not work."""
1421+
def _addon_link_transient_errors() -> tuple[type[BaseException], ...]:
1422+
"""Return the ``except`` target for one probe attempt.
1423+
1424+
Includes ``BaseExceptionGroup`` because bounding an attempt means
1425+
cancelling a live anyio task group, whose teardown groups our
1426+
``CancelledError`` with any child failure — and a group carrying a
1427+
``BaseException`` leaf is not an ``Exception``, so it would otherwise
1428+
escape the poll loop as a raw anyio traceback, losing both the retry
1429+
budget and the caller's diagnostics pointer. A caught group must still
1430+
pass :func:`_is_transient_link_error`, or a genuine bug inside the group
1431+
would be relabelled "not linked yet" (same rule as
1432+
``custom_components/ha_mcp_tools/llm_api.py::_transport_errors``).
1433+
"""
1434+
return (*_addon_link_transient_leaves(), BaseExceptionGroup)
1435+
1436+
1437+
def _is_transient_link_error(err: BaseException) -> bool:
1438+
"""Return True when every leaf of ``err`` is a transient link failure.
1439+
1440+
A group counts only when EVERY leaf (nested groups included) is
1441+
transient: a group carrying any other member is a real bug that must
1442+
propagate with its traceback instead of being retried until the deadline.
1443+
"""
1444+
import asyncio
1445+
1446+
if isinstance(err, BaseExceptionGroup):
1447+
return all(_is_transient_link_error(exc) for exc in err.exceptions)
1448+
# CancelledError counts only as a group leaf — it is this module's own
1449+
# wait_for abandoning a stalled attempt. A *bare* CancelledError is not in
1450+
# the except target above, so an outside cancellation still propagates.
1451+
return isinstance(err, (*_addon_link_transient_leaves(), asyncio.CancelledError))
1452+
1453+
1454+
def _probe_addon_ha_link(addon_mcp_url: str, budget: float) -> None:
1455+
"""Make one MCP call that needs Home Assistant; raise if it does not work.
1456+
1457+
``budget`` bounds this single attempt. Without it the attempt inherits
1458+
FastMCP's Streamable HTTP default of ``httpx.Timeout(30.0, read=300.0)``
1459+
(``client/transports/http.py`` — the one place this default is cited), so a
1460+
listener that accepts the connection but stalls on initialize or the tool
1461+
response holds the poll loop for up to 300s, far past the caller's own
1462+
deadline, delaying the fixture failure and its HAOS diagnostics.
1463+
1464+
FastMCP's own deadlines are set strictly tighter than the outer backstop.
1465+
Each starts its clock later than the one around it — ``init_timeout``
1466+
after session setup, the read timeout at request send — so with equal
1467+
values the backstop would always win the race and their phase-naming
1468+
errors ("Failed to initialize server session", the session read timeout)
1469+
could never fire, degrading every stall to a message-less ``TimeoutError``.
1470+
The raw TCP connect keeps httpx's own fixed connect timeout rather than
1471+
``budget``; the outer coroutine bound is what covers a stall there.
1472+
"""
14131473
import asyncio
14141474

14151475
from fastmcp import Client
14161476
from fastmcp.client.transports import StreamableHttpTransport
14171477

1478+
inner = max(budget * 0.8, budget - 2.0)
1479+
14181480
async def _call() -> None:
14191481
# Fresh client per attempt: the server is stateless, and a long-lived
14201482
# session would not survive the addon restarting mid-poll.
1421-
async with Client(StreamableHttpTransport(url=addon_mcp_url)) as client:
1422-
await client.call_tool("ha_get_addon", {})
1483+
async with Client(
1484+
StreamableHttpTransport(url=addon_mcp_url),
1485+
timeout=inner,
1486+
init_timeout=inner,
1487+
) as client:
1488+
await client.call_tool("ha_get_addon", {}, timeout=inner)
1489+
1490+
async def _bounded() -> None:
1491+
try:
1492+
await asyncio.wait_for(_call(), timeout=budget)
1493+
except TimeoutError as exc:
1494+
# wait_for raises a bare TimeoutError(), whose repr carries nothing.
1495+
# Name the target so the loop's final report is actionable.
1496+
raise TimeoutError(
1497+
f"ha_get_addon at {addon_mcp_url} did not answer within "
1498+
f"{budget:.1f}s (fastmcp's own {inner:.1f}s deadlines did not "
1499+
f"fire, so the stall was not in initialize or the read)"
1500+
) from exc
14231501

1424-
asyncio.run(_call())
1502+
asyncio.run(_bounded())
14251503

14261504

14271505
def wait_for_addon_ha_link_ready(
@@ -1441,21 +1519,41 @@ def wait_for_addon_ha_link_ready(
14411519
Core WebSocket and the Supervisor API) and is the call that surfaced the
14421520
race. Returns False on timeout so the caller can fail with context.
14431521
Transient errors are retried; bugs propagate.
1522+
1523+
Each attempt is capped by ``_ADDON_HA_LINK_PROBE_S`` and by the time left
1524+
on the deadline, so a single stalled attempt is abandoned and retried
1525+
rather than consuming the whole window. ``timeout`` is the ceiling up to
1526+
FastMCP's disconnect timeout: tearing down a cancelled attempt runs
1527+
shielded cleanup scopes (``client_disconnect_timeout``, 5s default) that
1528+
cancellation cannot interrupt, so the total can overshoot by a few seconds.
14441529
"""
14451530
deadline = time.monotonic() + timeout
14461531
transient = _addon_link_transient_errors()
14471532
last_err: BaseException | None = None
1448-
while time.monotonic() < deadline:
1533+
attempts = 0
1534+
while True:
1535+
remaining = deadline - time.monotonic()
1536+
if remaining <= 0:
1537+
break
1538+
attempts += 1
14491539
try:
1450-
_probe_addon_ha_link(addon_mcp_url)
1540+
_probe_addon_ha_link(addon_mcp_url, min(remaining, _ADDON_HA_LINK_PROBE_S))
14511541
except transient as e:
1542+
if not _is_transient_link_error(e):
1543+
raise
14521544
last_err = e
1453-
LOG.debug("Addon -> Home Assistant link not ready yet: %r", e)
1545+
# The e2e harness logs at INFO, so a DEBUG-only cause would leave a
1546+
# failed link reported with no explanation at all. First cause at
1547+
# INFO, the repeats at DEBUG so a slow boot stays quiet.
1548+
log = LOG.info if attempts == 1 else LOG.debug
1549+
log("Addon -> Home Assistant link not ready yet: %r", e)
14541550
else:
14551551
elapsed = int(time.monotonic() - (deadline - timeout))
14561552
LOG.info("Addon -> Home Assistant link ready after ~%ds", elapsed)
14571553
return True
1458-
time.sleep(_ADDON_HA_LINK_POLL_S)
1554+
# Never sleep past the deadline, so the loop does not add a whole
1555+
# extra poll interval on top of the budget.
1556+
time.sleep(min(_ADDON_HA_LINK_POLL_S, max(0.0, deadline - time.monotonic())))
14591557
LOG.error(
14601558
"Addon -> Home Assistant link never came up within %.0fs (last_exc=%r)",
14611559
timeout,

0 commit comments

Comments
 (0)