Fix produce-box select cancelling config entry setup
Validate / hassfest (push) Skipped
Validate / pytest (push) Successful in 11s
Validate / HACS (push) Skipped

async_added_to_hass listed the switchable boxes inline, one fetch per box
popup, so on a slower connection the platform setup ran past
SLOW_SETUP_MAX_WAIT and the whole config entry was cancelled into
setup_error — every entity unavailable despite valid credentials. Move the
listing to a background task so setup never blocks on it; the options fill in
once it returns.

Also bound every portal request to a 30s timeout so one hung request can no
longer drag a refresh, or a first setup, past Home Assistant's own limits and
fail it outright.

Regression covered in tests/test_setup_hygiene.py.
This commit is contained in:
flan
2026-08-04 16:04:27 +00:00
parent 8051cc7465
commit 0a5441779a
9 changed files with 152 additions and 19 deletions
+1
View File
@@ -20,6 +20,7 @@ def _load():
stub = types.ModuleType("aiohttp")
stub.ClientSession = object
stub.ClientError = Exception
stub.ClientTimeout = lambda *args, **kwargs: None
sys.modules["aiohttp"] = stub
sys.path.insert(0, str(COMPONENT))
pkg = types.ModuleType("fh_pkg")
+1
View File
@@ -26,6 +26,7 @@ def _load_api():
stub = types.ModuleType("aiohttp")
stub.ClientSession = object
stub.ClientError = Exception
stub.ClientTimeout = lambda *args, **kwargs: None
sys.modules["aiohttp"] = stub
if "yarl" not in sys.modules:
try:
+73
View File
@@ -0,0 +1,73 @@
"""No entity may block its own setup on a portal round-trip.
Written after a real bug: the produce-box select listed the boxes on offer from
inside `async_added_to_hass`, one fetch per popup. On a slower connection those
fetches ran past Home Assistant's SLOW_SETUP_MAX_WAIT, the platform setup was
cancelled, and the whole config entry landed in `setup_error` — every entity
unavailable despite valid credentials.
Every portal call an entity makes goes through `self.coordinator.actions` or
`self.coordinator.client`, so awaiting either inside `async_added_to_hass` is
the shape of that bug. These parse the sources with `ast` rather than importing
them, so the suite still runs without Home Assistant installed.
"""
from __future__ import annotations
import ast
from pathlib import Path
COMPONENT = Path(__file__).parent.parent / "custom_components" / "freshharvest"
# Attribute names that only exist to reach the freshharvest.com portal.
NETWORK_ATTRS = {"actions", "client"}
def _attr_chain(node: ast.AST) -> set[str]:
"""Every attribute/name along a call's dotted func, e.g. a.b.c() -> {a,b,c}."""
names: set[str] = set()
while isinstance(node, ast.Attribute):
names.add(node.attr)
node = node.value
if isinstance(node, ast.Name):
names.add(node.id)
return names
def _added_to_hass(filename: str) -> ast.AsyncFunctionDef | None:
tree = ast.parse((COMPONENT / filename).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if (
isinstance(node, ast.AsyncFunctionDef)
and node.name == "async_added_to_hass"
):
return node
return None
PLATFORMS = ["sensor.py", "binary_sensor.py", "switch.py", "button.py",
"select.py", "todo.py"]
def test_no_platform_awaits_the_portal_during_setup():
"""`async_added_to_hass` must not await a coordinator.actions/client call."""
offenders = []
for filename in PLATFORMS:
func = _added_to_hass(filename)
if func is None:
continue
for node in ast.walk(func):
if isinstance(node, ast.Await) and isinstance(node.value, ast.Call):
if _attr_chain(node.value.func) & NETWORK_ATTRS:
offenders.append(f"{filename}:{node.lineno}")
assert not offenders, (
"portal round-trip awaited during entity setup (blocks setup, can "
f"exceed SLOW_SETUP_MAX_WAIT): {offenders}"
)
def test_select_still_loads_its_options_off_the_setup_path():
"""The fix must defer the listing, not delete it: the select still fetches
its options, just in a background task rather than inline in setup."""
source = (COMPONENT / "select.py").read_text(encoding="utf-8")
assert "async_create_background_task" in source
assert "async_list_baskets" in source