Fix race configuring zeroconf (#138425)

This commit is contained in:
J. Nick Koston 2025-02-13 14:17:06 -06:00 committed by GitHub
parent ab2e075b41
commit bbbad90ca2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 106 additions and 30 deletions

View File

@ -150,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
"isal",
# Set log levels
"logger",
# Ensure network config is available
# before hassio or any other integration is
# loaded that might create an aiohttp client session
"network",
# Error logging
"system_log",
"sentry",

View File

@ -20,7 +20,7 @@ from .const import (
PUBLIC_TARGET_IP,
)
from .models import Adapter
from .network import Network, async_get_network
from .network import Network, async_get_loaded_network, async_get_network
_LOGGER = logging.getLogger(__name__)
@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
return network.adapters
@callback
def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
"""Get the network adapter configuration."""
return async_get_loaded_network(hass).adapters
@bind_hass
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
@ -74,7 +80,14 @@ async def async_get_enabled_source_ips(
hass: HomeAssistant,
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
adapters = await async_get_adapters(hass)
return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass))
@callback
def async_get_enabled_source_ips_from_adapters(
adapters: list[Adapter],
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
sources: list[IPv4Address | IPv6Address] = []
for adapter in adapters:
if not adapter["enabled"]:
@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_commands,
)
await async_get_network(hass)
async_register_websocket_commands(hass)
return True

View File

@ -12,8 +12,6 @@ DOMAIN: Final = "network"
STORAGE_KEY: Final = "core.network"
STORAGE_VERSION: Final = 1
DATA_NETWORK: Final = "network"
ATTR_ADAPTERS: Final = "adapters"
ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters"
DEFAULT_CONFIGURED_ADAPTERS: list[str] = []

View File

@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_CONFIGURED_ADAPTERS,
DATA_NETWORK,
DEFAULT_CONFIGURED_ADAPTERS,
DOMAIN,
STORAGE_KEY,
STORAGE_VERSION,
)
@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada
_LOGGER = logging.getLogger(__name__)
DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN)
@singleton(DATA_NETWORK)
@callback
def async_get_loaded_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
return hass.data[DATA_NETWORK]
@singleton(DOMAIN)
async def async_get_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
network = Network(hass)

View File

@ -141,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
return _async_get_instance(hass)
def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
zeroconf = HaZeroconf(**zcargs)
zeroconf = HaZeroconf(**_async_get_zc_args(hass))
aio_zc = HaAsyncZeroconf(zc=zeroconf)
install_multiple_zeroconf_catcher(zeroconf)
@ -175,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool:
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable."""
zc_args: dict = {"ip_version": IPVersion.V4Only}
adapters = await network.async_get_adapters(hass)
def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]:
"""Get zeroconf arguments from config."""
zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only}
adapters = network.async_get_loaded_adapters(hass)
ipv6 = False
if _async_zc_has_functional_dual_stack():
if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters):
@ -195,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else:
zc_args["interfaces"] = [
str(source_ip)
for source_ip in await network.async_get_enabled_source_ips(hass)
for source_ip in network.async_get_enabled_source_ips_from_adapters(
adapters
)
if not source_ip.is_loopback
and not (isinstance(source_ip, IPv6Address) and source_ip.is_global)
and not (
@ -207,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
and zc_args["ip_version"] == IPVersion.V6Only
)
]
return zc_args
aio_zc = _async_get_instance(hass, **zc_args)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable."""
aio_zc = _async_get_instance(hass)
zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)

View File

@ -1090,7 +1090,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route(
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_adapters",
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTER_WITH_DEFAULT_ENABLED,
),
patch(
@ -1178,7 +1178,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux(
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_adapters",
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTERS_WITH_MANUAL_CONFIG,
),
patch(
@ -1212,7 +1212,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd(
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_adapters",
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTERS_WITH_MANUAL_CONFIG,
),
patch(
@ -1263,7 +1263,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux(
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_adapters",
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6,
),
patch(
@ -1292,7 +1292,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd(
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_adapters",
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6,
),
patch(
@ -1310,6 +1310,36 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd(
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_async_detect_interfaces_explicitly_before_setup(
hass: HomeAssistant,
) -> None:
"""Test interfaces are explicitly set with IPv6 before setup is called."""
with (
patch("homeassistant.components.zeroconf.sys.platform", "linux"),
patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc,
patch.object(hass.config_entries.flow, "async_init"),
patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock),
patch(
"homeassistant.components.zeroconf.network.async_get_loaded_adapters",
return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6,
),
patch(
"homeassistant.components.zeroconf.AsyncServiceInfo",
side_effect=get_service_info_mock,
),
):
# Call before async_setup has been called
await zeroconf.async_get_async_instance(hass)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert mock_zc.mock_calls[0] == call(
interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef%3"],
ip_version=IPVersion.All,
)
async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None:
"""Test fallback to Home for mDNS announcement if the name is missing."""
hass.config.location_name = ""

View File

@ -1180,15 +1180,31 @@ async def mqtt_mock_entry(
@pytest.fixture(autouse=True, scope="session")
def mock_network() -> Generator[None]:
"""Mock network."""
with patch(
"homeassistant.components.network.util.ifaddr.get_adapters",
return_value=[
Mock(
nice_name="eth0",
ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)],
index=0,
)
],
with (
patch(
"homeassistant.components.network.util.ifaddr.get_adapters",
return_value=[
Mock(
nice_name="eth0",
ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)],
index=0,
)
],
),
patch(
"homeassistant.components.network.async_get_loaded_adapters",
return_value=[
{
"auto": True,
"default": True,
"enabled": True,
"index": 0,
"ipv4": [{"address": "10.10.10.10", "network_prefix": 24}],
"ipv6": [],
"name": "eth0",
}
],
),
):
yield