From bbbad90ca29b9e9a356fe166fcf586ede8cf8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Feb 2025 14:17:06 -0600 Subject: [PATCH] Fix race configuring zeroconf (#138425) --- homeassistant/bootstrap.py | 4 ++ homeassistant/components/network/__init__.py | 19 ++++++++- homeassistant/components/network/const.py | 2 - homeassistant/components/network/network.py | 13 +++++- homeassistant/components/zeroconf/__init__.py | 24 ++++++----- tests/components/zeroconf/test_init.py | 40 ++++++++++++++++--- tests/conftest.py | 34 +++++++++++----- 7 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7fd73af0053..7c5cb7dce4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -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", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 10046f75127..200cce86997 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -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 diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 120ae9dfd7c..d8c8858be72 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -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] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index 4158307bb1a..db25bedcaea 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -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) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b748006336c..e80b6b8cfdb 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -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) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 3586f54a59a..56262600511 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -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 = "" diff --git a/tests/conftest.py b/tests/conftest.py index de627925941..7905439028c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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