Skip to content

Commit 52cc17f

Browse files
authored
Delay initial version fetch until there is connectivity (home-assistant#5603)
* Delay inital version fetch until there is connectivity * Add test * Only mock get not whole websession object * drive delayed fetch off of supervisor connectivity not host * Fix test to not rely on sleep guessing to track tasks * Use fixture to remove job throttle temporarily
1 parent fa6949f commit 52cc17f

13 files changed

+121
-36
lines changed

supervisor/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ class BusEvent(StrEnum):
484484
DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change"
485485
HARDWARE_NEW_DEVICE = "hardware_new_device"
486486
HARDWARE_REMOVE_DEVICE = "hardware_remove_device"
487+
SUPERVISOR_CONNECTIVITY_CHANGE = "supervisor_connectivity_change"
487488
SUPERVISOR_JOB_END = "supervisor_job_end"
488489
SUPERVISOR_JOB_START = "supervisor_job_start"
489490
SUPERVISOR_STATE_CHANGE = "supervisor_state_change"

supervisor/host/network.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def connectivity(self, state: bool | None) -> None:
6767
self.sys_homeassistant.websocket.supervisor_update_event(
6868
"network", {ATTR_HOST_INTERNET: state}
6969
)
70+
if state and not self.sys_supervisor.connectivity:
71+
self.sys_create_task(self.sys_supervisor.check_connectivity())
7072

7173
@property
7274
def interfaces(self) -> list[Interface]:
@@ -148,7 +150,7 @@ async def _check_connectivity_changed(
148150
return
149151

150152
connectivity_check: bool | None = changed.get(DBUS_ATTR_CONNECTION_ENABLED)
151-
connectivity: bool | None = changed.get(DBUS_ATTR_CONNECTIVITY)
153+
connectivity: int | None = changed.get(DBUS_ATTR_CONNECTIVITY)
152154

153155
# This potentially updated the DNS configuration. Make sure the DNS plug-in
154156
# picks up the latest settings.

supervisor/jobs/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ def _notify_on_job_change(
213213

214214
if attribute.name == "done":
215215
if value is False:
216-
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_START, job.uuid)
216+
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_START, job)
217217
if value is True:
218-
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_END, job.uuid)
218+
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_END, job)
219219

220220
def new_job(
221221
self,

supervisor/supervisor.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
from aiohttp.client_exceptions import ClientError
1414
from awesomeversion import AwesomeVersion, AwesomeVersionException
1515

16-
from .const import ATTR_SUPERVISOR_INTERNET, SUPERVISOR_VERSION, URL_HASSIO_APPARMOR
16+
from .const import (
17+
ATTR_SUPERVISOR_INTERNET,
18+
SUPERVISOR_VERSION,
19+
URL_HASSIO_APPARMOR,
20+
BusEvent,
21+
)
1722
from .coresys import CoreSys, CoreSysAttributes
1823
from .docker.stats import DockerStats
1924
from .docker.supervisor import DockerSupervisor
@@ -74,6 +79,7 @@ def connectivity(self, state: bool) -> None:
7479
if self._connectivity == state:
7580
return
7681
self._connectivity = state
82+
self.sys_bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, state)
7783
self.sys_homeassistant.websocket.supervisor_update_event(
7884
"network", {ATTR_SUPERVISOR_INTERNET: state}
7985
)

supervisor/updater.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import aiohttp
99
from awesomeversion import AwesomeVersion
1010

11+
from .bus import EventListener
1112
from .const import (
1213
ATTR_AUDIO,
1314
ATTR_AUTO_UPDATE,
@@ -23,6 +24,7 @@
2324
ATTR_SUPERVISOR,
2425
FILE_HASSIO_UPDATER,
2526
URL_HASSIO_VERSION,
27+
BusEvent,
2628
UpdateChannel,
2729
)
2830
from .coresys import CoreSysAttributes
@@ -47,11 +49,18 @@ def __init__(self, coresys):
4749
"""Initialize updater."""
4850
super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG)
4951
self.coresys = coresys
52+
self._connectivity_listener: EventListener | None = None
5053

5154
async def load(self) -> None:
5255
"""Update internal data."""
53-
with suppress(UpdaterError):
54-
await self.fetch_data()
56+
# If there's no connectivity, delay initial version fetch
57+
if not self.sys_supervisor.connectivity:
58+
self._connectivity_listener = self.sys_bus.register_event(
59+
BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity
60+
)
61+
return
62+
63+
await self.reload()
5564

5665
async def reload(self) -> None:
5766
"""Update internal data."""
@@ -180,6 +189,11 @@ def auto_update(self, value: bool) -> None:
180189
"""Set Supervisor auto updates enabled."""
181190
self._data[ATTR_AUTO_UPDATE] = value
182191

192+
async def _check_connectivity(self, connectivity: bool):
193+
"""Fetch data once connectivity is true."""
194+
if connectivity:
195+
await self.reload()
196+
183197
@Job(
184198
name="updater_fetch_data",
185199
conditions=[JobCondition.INTERNET_SYSTEM],
@@ -214,6 +228,11 @@ async def fetch_data(self):
214228
_LOGGER.warning,
215229
) from err
216230

231+
# Fetch was successful. If there's a connectivity listener, time to remove it
232+
if self._connectivity_listener:
233+
self.sys_bus.remove_listener(self._connectivity_listener)
234+
self._connectivity_listener = None
235+
217236
# Validate
218237
try:
219238
await self.sys_security.verify_own_content(calc_checksum(data))

tests/conftest.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import asyncio
44
from collections.abc import AsyncGenerator, Generator
5-
from functools import partial
6-
from inspect import unwrap
5+
from datetime import datetime
76
import os
87
from pathlib import Path
98
import subprocess
@@ -381,11 +380,6 @@ async def coresys(
381380
ha_version=AwesomeVersion("2021.2.4")
382381
)
383382

384-
# Remove rate limiting decorator from fetch_data
385-
coresys_obj.updater.fetch_data = partial(
386-
unwrap(coresys_obj.updater.fetch_data), coresys_obj.updater
387-
)
388-
389383
# Don't remove files/folders related to addons and stores
390384
with patch("supervisor.store.git.GitRepo._remove"):
391385
yield coresys_obj
@@ -765,3 +759,10 @@ def mock_is_mount() -> MagicMock:
765759
"""Mock is_mount in mounts."""
766760
with patch("supervisor.mounts.mount.Path.is_mount", return_value=True) as is_mount:
767761
yield is_mount
762+
763+
764+
@pytest.fixture
765+
def no_job_throttle():
766+
"""Remove job throttle for tests."""
767+
with patch("supervisor.jobs.decorator.Job.last_call", return_value=datetime.min):
768+
yield

tests/dbus_service_mocks/network_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class NetworkManager(DBusServiceMock):
2222
interface = "org.freedesktop.NetworkManager"
2323
object_path = "/org/freedesktop/NetworkManager"
2424
version = "1.22.10"
25+
connectivity_check_enabled = True
2526
connectivity = 4
2627
devices = [
2728
"/org/freedesktop/NetworkManager/Devices/1",
@@ -155,7 +156,7 @@ def ConnectivityCheckAvailable(self) -> "b":
155156
@dbus_property()
156157
def ConnectivityCheckEnabled(self) -> "b":
157158
"""Get ConnectivityCheckEnabled."""
158-
return True
159+
return self.connectivity_check_enabled
159160

160161
@ConnectivityCheckEnabled.setter
161162
def ConnectivityCheckEnabled(self, value: "b"):

tests/jobs/test_job_decorator.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,13 +1144,13 @@ async def job_task(self) -> None:
11441144
started = False
11451145
ended = False
11461146

1147-
async def start_listener(job_id: str):
1147+
async def start_listener(evt_job: SupervisorJob):
11481148
nonlocal started
1149-
started = started or job_id == job.uuid
1149+
started = started or evt_job.uuid == job.uuid
11501150

1151-
async def end_listener(job_id: str):
1151+
async def end_listener(evt_job: SupervisorJob):
11521152
nonlocal ended
1153-
ended = ended or job_id == job.uuid
1153+
ended = ended or evt_job.uuid == job.uuid
11541154

11551155
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
11561156
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
@@ -1196,13 +1196,13 @@ async def job_task(self) -> None:
11961196
started = False
11971197
ended = False
11981198

1199-
async def start_listener(job_id: str):
1199+
async def start_listener(evt_job: SupervisorJob):
12001200
nonlocal started
1201-
started = started or job_id == job.uuid
1201+
started = started or evt_job.uuid == job.uuid
12021202

1203-
async def end_listener(job_id: str):
1203+
async def end_listener(evt_job: SupervisorJob):
12041204
nonlocal ended
1205-
ended = ended or job_id == job.uuid
1205+
ended = ended or evt_job.uuid == job.uuid
12061206

12071207
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
12081208
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)

tests/misc/test_tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ async def test_watchdog_homeassistant_api_reanimation_limit(
171171
rebuild.assert_not_called()
172172

173173

174+
@pytest.mark.usefixtures("no_job_throttle")
174175
async def test_reload_updater_triggers_supervisor_update(
175176
tasks: Tasks, coresys: CoreSys
176177
):

tests/os/test_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# pylint: disable=protected-access
1717

1818

19-
@pytest.mark.asyncio
19+
@pytest.mark.usefixtures("no_job_throttle")
2020
async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None:
2121
"""Test download URL generated."""
2222
coresys.os._board = "intel-nuc"

tests/plugins/test_plugin_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from unittest.mock import PropertyMock, patch
44

55
from awesomeversion import AwesomeVersion
6+
import pytest
67

78
from supervisor.coresys import CoreSys
89
from supervisor.docker.interface import DockerInterface
@@ -35,6 +36,7 @@ async def test_repair(coresys: CoreSys):
3536
assert install.call_count == len(coresys.plugins.all_plugins)
3637

3738

39+
@pytest.mark.usefixtures("no_job_throttle")
3840
async def test_load(coresys: CoreSys):
3941
"""Test plugin manager load."""
4042
coresys.hardware.disk.get_disk_free_space = lambda x: 5000

tests/test_supervisor.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,23 @@ async def fixture_webession(coresys: CoreSys) -> AsyncMock:
3636
yield mock_websession
3737

3838

39-
@pytest.fixture(name="supervisor_unthrottled")
40-
async def fixture_supervisor_unthrottled(coresys: CoreSys) -> Supervisor:
41-
"""Get supervisor object with connectivity check throttle removed."""
42-
with patch("supervisor.jobs.decorator.Job.last_call", return_value=datetime.min):
43-
yield coresys.supervisor
44-
45-
4639
@pytest.mark.parametrize(
4740
"side_effect,connectivity", [(ClientError(), False), (None, True)]
4841
)
42+
@pytest.mark.usefixtures("no_job_throttle")
4943
async def test_connectivity_check(
50-
supervisor_unthrottled: Supervisor,
44+
coresys: CoreSys,
5145
websession: AsyncMock,
5246
side_effect: Exception | None,
5347
connectivity: bool,
5448
):
5549
"""Test connectivity check."""
56-
assert supervisor_unthrottled.connectivity is True
50+
assert coresys.supervisor.connectivity is True
5751

5852
websession.head.side_effect = side_effect
59-
await supervisor_unthrottled.check_connectivity()
53+
await coresys.supervisor.check_connectivity()
6054

61-
assert supervisor_unthrottled.connectivity is connectivity
55+
assert coresys.supervisor.connectivity is connectivity
6256

6357

6458
@pytest.mark.parametrize(

tests/test_updater.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
"""Test updater files."""
22

3-
from unittest.mock import patch
3+
import asyncio
4+
from unittest.mock import AsyncMock, MagicMock, patch
45

56
from awesomeversion import AwesomeVersion
67
import pytest
78

9+
from supervisor.const import BusEvent
810
from supervisor.coresys import CoreSys
11+
from supervisor.dbus.const import ConnectivityState
12+
from supervisor.jobs import SupervisorJob
13+
14+
from tests.common import load_binary_fixture
15+
from tests.dbus_service_mocks.network_manager import (
16+
NetworkManager as NetworkManagerService,
17+
)
918

1019
URL_TEST = "https://version.home-assistant.io/stable.json"
1120

1221

13-
@pytest.mark.asyncio
22+
@pytest.mark.usefixtures("no_job_throttle")
1423
async def test_fetch_versions(coresys: CoreSys) -> None:
1524
"""Test download and sync version."""
1625

@@ -53,6 +62,7 @@ async def test_fetch_versions(coresys: CoreSys) -> None:
5362
)
5463

5564

65+
@pytest.mark.usefixtures("no_job_throttle")
5666
@pytest.mark.parametrize(
5767
"version, expected",
5868
[
@@ -71,3 +81,51 @@ async def test_os_update_path(coresys: CoreSys, version: str, expected: str):
7181
await coresys.updater.fetch_data()
7282

7383
assert coresys.updater.version_hassos == AwesomeVersion(expected)
84+
85+
86+
@pytest.mark.usefixtures("no_job_throttle")
87+
async def test_delayed_fetch_for_connectivity(
88+
coresys: CoreSys, network_manager_service: NetworkManagerService
89+
):
90+
"""Test initial version fetch waits for connectivity on load."""
91+
coresys.websession.get = MagicMock()
92+
coresys.websession.get.return_value.__aenter__.return_value.status = 200
93+
coresys.websession.get.return_value.__aenter__.return_value.read.return_value = (
94+
load_binary_fixture("version_stable.json")
95+
)
96+
coresys.websession.head = AsyncMock()
97+
coresys.security.verify_own_content = AsyncMock()
98+
99+
# Network connectivity change causes a series of async tasks to eventually do a version fetch
100+
# Rather then use some kind of sleep loop, set up listener for start of fetch data job
101+
event = asyncio.Event()
102+
103+
async def find_fetch_data_job_start(job: SupervisorJob):
104+
if job.name == "updater_fetch_data":
105+
event.set()
106+
107+
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, find_fetch_data_job_start)
108+
109+
# Start with no connectivity and confirm there is no version fetch on load
110+
coresys.supervisor.connectivity = False
111+
network_manager_service.connectivity = ConnectivityState.CONNECTIVITY_NONE.value
112+
await coresys.host.network.load()
113+
await coresys.host.network.check_connectivity()
114+
115+
await coresys.updater.load()
116+
coresys.websession.get.assert_not_called()
117+
118+
# Now signal host has connectivity and wait for fetch data to complete to assert
119+
network_manager_service.emit_properties_changed(
120+
{"Connectivity": ConnectivityState.CONNECTIVITY_FULL}
121+
)
122+
await network_manager_service.ping()
123+
async with asyncio.timeout(5):
124+
await event.wait()
125+
await asyncio.sleep(0)
126+
127+
coresys.websession.get.assert_called_once()
128+
assert (
129+
coresys.websession.get.call_args[0][0]
130+
== "https://version.home-assistant.io/stable.json"
131+
)

0 commit comments

Comments
 (0)