fix(config_flow): D3 — await the discovery-dedup coroutine (t37)

`ConfigFlow._async_handle_discovery_without_unique_id` is an `async def` in core
(verified: `inspect.iscoroutinefunction` is True) and core awaits it. t32 called
it without `await`, so the coroutine body never ran: no `already_configured`
abort when the handler already had entries, no
`DEFAULT_DISCOVERY_UNIQUE_ID` claim, no `already_in_progress` abort — HA's
discovery dedup was dead, Python warned "coroutine ... was never awaited", and
the host-equality loop that used to sit here was the only accidental dedup.

The call is awaited and the now-unreachable host loop is gone (the real helper
is stricter: any entry for the domain, or any in-flight flow). The harness
mirrors the real contract instead of a sync no-op:

* `entity_stublib`'s hub stub makes the helper a coroutine (a sync stub would
  turn the production `await` into a TypeError);
* `test_standalone_setup`'s stub models the real semantics — a coroutine raising
  `DiscoveryDedupAbortError` (core raises AbortFlow), driven by the shared
  holder's entry list and in-progress list — and its tests now assert all three
  outcomes: awaited-and-proceeds (counter + claimed discovery unique id),
  `already_configured` when entries exist, `already_in_progress` when another
  flow runs. A fourth test collects the un-awaited-coroutine warning, and the
  prefill test in the UX suite asserts the awaited counter.

Red proof: with the pre-fix config_flow.py the repo-owned sweep reports
`config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()`.

Verified: 289 passed, 2 skipped (D3 stage); ruff clean.
This commit is contained in:
James Mackay committed 2026-10-10 21:20:13 +01:00
1 parent bd72ae3170
commit 60fb4f7557
4 files changed
+115 -34

No files matched your search

+7 -3
View File
@@ -207,9 +207,13 @@ def install() -> None:
async_set_unique_id = _stub_set_unique_id
_abort_if_unique_id_configured = _noop_abort
def _async_handle_discovery_without_unique_id(self):
# t32 F2 (audited helper): recording no-op at hub scope
pass
async def _async_handle_discovery_without_unique_id(self):
# t37 D3: the real helper is a COROUTINE and core awaits it — a
# sync stub here would turn the production `await` into a
# TypeError. The abort semantics live in
# tests/test_standalone_setup.py, whose stub owns entry/in-progress
# state; at hub scope there is nothing to abort on.
return None
def async_show_form(
self, *, step_id, data_schema=None, errors=None,
+11 -1
View File
@@ -114,7 +114,14 @@ def _install_stubs() -> None:
def _async_current_entries(self):
return []
def _async_handle_discovery_without_unique_id(self):
async def _async_handle_discovery_without_unique_id(self):
"""Real HA helper is a coroutine (t37 D3) — awaiting is mandatory.
This file never configures entries for the handler, so the real
aborts (already_configured / already_in_progress) cannot trigger;
tests/test_standalone_setup.py exercises those semantics.
"""
self._discovery_dedup_calls = getattr(self, "_discovery_dedup_calls", 0) + 1
return None
def _get_reauth_entry(self, entry=None):
@@ -496,6 +503,9 @@ class TestDiscoveryPrefill:
assert result["type"] == "form"
assert result["step_id"] == "user"
# t37 D3: MAC-less discovery needs the AWAITED coroutine helper — the
# counter only moves when the call is awaited
assert flow._discovery_dedup_calls == 1
defaults = vol_mirror.defaults_by_key(result["data_schema"])
# schema-level, not "the attribute was stored": this is precisely what
# the old assertion missed while the form rendered blank
+86 -18
View File
@@ -8,8 +8,10 @@ migration validity for v2 entries without the field.
"""
import asyncio # noqa: F401 (async steps need the event loop)
import gc
import sys
import types
import warnings
from pathlib import Path # noqa: E402 (F1 test key staging)
from types import SimpleNamespace
from unittest import mock
@@ -42,6 +44,19 @@ _STUB_NAMES = (
)
class DiscoveryDedupAbortError(Exception):
"""Stands in for data_entry_flow.AbortFlow inside the discovery stub.
HA's `ConfigFlow._async_handle_discovery_without_unique_id` RAISES on a
duplicate (the flow manager turns that into an abort result); modelling it
as a raise is what makes an un-awaited call fail loudly here.
"""
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
def _install_stubs() -> None:
"""Import-time stand-ins (idempotent; same shape as the pairing tests).
@@ -101,11 +116,23 @@ def _install_stubs() -> None:
def _async_current_entries(self):
return _zc_holder.entries
# t32 F2: the audited helper for uniqueness-less discovery — a
# recording no-op here; the flow's own host guard does the stacking
# prevention the acceptance pins.
def _async_handle_discovery_without_unique_id(self):
def _async_in_progress(self, include_uninitialized=False, **kwargs):
return _zc_holder.in_progress
# t37 D3: HA's own helper, modelled faithfully — a COROUTINE that
# dedups uniqueness-less discovery by raising. An un-awaited call
# therefore does nothing at all, which is exactly the production bug
# this replaces.
async def _async_handle_discovery_without_unique_id(self):
_zc_holder.discovery_without_unique_id_calls += 1
if getattr(self, "_unique_id", None) is not None:
return
if self._async_current_entries():
raise DiscoveryDedupAbortError("already_configured")
await self.async_set_unique_id(_zc_holder.discovery_unique_id)
self._abort_if_unique_id_configured()
if self._async_in_progress(include_uninitialized=True):
raise DiscoveryDedupAbortError("already_in_progress")
class OptionsFlow:
def async_show_form(self, *, step_id, data_schema=None, errors=None):
@@ -140,7 +167,12 @@ def _install_stubs() -> None:
# Shared holder so tests can swap the current-entry list through the
# module object (the stub closure reads this same object).
_zc_holder = types.SimpleNamespace(entries=[], discovery_without_unique_id_calls=0)
_zc_holder = types.SimpleNamespace(
entries=[],
in_progress=[],
discovery_unique_id="default_discovery_unique_id",
discovery_without_unique_id_calls=0,
)
sys.modules[__name__]._zc_holder = _zc_holder
class AbortIfConfiguredError(Exception):
@@ -856,23 +888,27 @@ class TestMacLessDiscoveryF2:
ip_address=host, host=host, properties=props or {}
)
async def test_mac_less_records_the_helper(self, tmp_path):
def _holder(self):
return sys.modules[__name__]._zc_holder
async def test_mac_less_discovery_awaits_the_helper(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls = 0
self._holder().discovery_without_unique_id_calls = 0
result = await flow.async_step_zeroconf(self._discovery())
assert result["type"] == "form" # prefill still offered once
assert (
sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls
== 1
)
# t37 D3: only an AWAITED coroutine body runs — before the fix this
# counter stayed at 0 and Python warned "coroutine ... was never
# awaited", while HA's dedup never happened
assert self._holder().discovery_without_unique_id_calls == 1
# the helper claims HA's discovery sentinel for the flow
assert flow._unique_id == self._holder().discovery_unique_id
async def test_mac_less_rediscovery_does_not_stack(self, tmp_path):
"""A second MAC-less discovery of an already-configured host
aborts — one-entry outcome."""
async def test_mac_less_discovery_with_entries_aborts(self, tmp_path):
"""Real contract: any entry for the handler → already_configured."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
@@ -882,13 +918,45 @@ class TestMacLessDiscoveryF2:
options={CONF_PROTOCOL_HOST: "192.168.1.50"},
title="Shield",
)
sys.modules[__name__]._zc_holder.entries[:] = [entry]
self._holder().entries[:] = [entry]
result = await flow.async_step_zeroconf(self._discovery())
with pytest.raises(DiscoveryDedupAbortError) as raised:
await flow.async_step_zeroconf(self._discovery())
assert result == {"type": "abort", "reason": "already_configured"}
assert raised.value.reason == "already_configured"
# the entries list is untouched: one-entry outcome
assert len(sys.modules[__name__]._zc_holder.entries) == 1
assert len(self._holder().entries) == 1
async def test_mac_less_discovery_while_another_flow_runs_aborts(self, tmp_path):
"""The other half of the real contract: one flow at a time."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
self._holder().in_progress[:] = [{"flow_id": "other"}]
with pytest.raises(DiscoveryDedupAbortError) as raised:
await flow.async_step_zeroconf(self._discovery())
assert raised.value.reason == "already_in_progress"
async def test_mac_less_discovery_leaves_no_un_awaited_coroutine(
self, tmp_path
):
"""t37 D3: the helper must never be left un-awaited."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
await flow.async_step_zeroconf(self._discovery())
del flow
gc.collect()
never_awaited = [
str(w.message) for w in caught if "never awaited" in str(w.message)
]
assert never_awaited == []
class TestSeedBlockShapeF4: