Compare commits

...

1 Commits

Author SHA1 Message Date
Stefan Agner
4b0788593d Replace fixed-duration sleeps for fire-and-forget tasks
Several tests use ``await asyncio.sleep(...)`` to "wait for the
handler to run" after kicking off a fire-and-forget
``sys_create_task`` job. The fixed duration is real wall-clock
time and the wait can be indeterministic — if the handler chain
happens to need slightly more time on a busy CI runner, the
assertion races the handler.

For these jobs the only handle the test has is the coroutine
qualname, so add a small piece of test infrastructure:

- The ``coresys`` test fixture wraps ``coresys.create_task`` and
  records every spawned task into a per-test
  ``coresys.test_created_tasks`` list, plus sets a per-test
  ``asyncio.Event`` (``coresys.test_new_task_event``) on each
  creation. ``asyncio.all_tasks()`` only includes pending tasks, so
  a task that completes faster than the test code reaches its
  assertion would otherwise be invisible. Holding our own
  reference avoids that race.
- A new ``tests.common.wait_for_task_by_name`` helper looks tasks
  up in that list by coroutine qualname prefix and gathers any
  matches (including already-finished ones — ``asyncio.gather`` on
  a done future returns immediately). gather without
  ``return_exceptions`` propagates the first task exception so a
  real fire-and-forget error fails the test instead of being
  silently swallowed. If nothing matches yet, the helper blocks on
  the new-task event with a 10s timeout so a task created
  indirectly via a 0-delay ``call_later`` (the timer needs the
  loop to advance before its callback runs) wakes us as soon as
  it appears. Raises ``LookupError`` if no matching task arrives
  within the timeout — that's a test bug, the call site expects
  the task to be scheduled.

Use it for:

- ``BackupManager.reload`` spawned by the API on missing-file 404s
  (3 sites in api/test_backups.py).
- The scheduled connectivity check in test_supervisor.py
  (``Supervisor.check_and_update_connectivity``).
- The four DNS-debounce timer callbacks in plugins/test_dns.py
  (``PluginDns._restart_dns_after_locals_change``), spawned by a
  0-delay ``call_later`` from ``notify_locals_changed``.
2026-05-06 10:20:47 +02:00
5 changed files with 102 additions and 24 deletions

View File

@@ -31,7 +31,7 @@ from supervisor.jobs import SupervisorJob
from supervisor.mounts.mount import Mount
from supervisor.supervisor import Supervisor
from tests.common import get_fixture_path
from tests.common import get_fixture_path, wait_for_task_by_name
from tests.const import TEST_ADDON_SLUG
@@ -1444,8 +1444,8 @@ async def test_missing_file_removes_location_from_cache(
resp = await api_client.request(method, url_path, json=body)
assert resp.status == 404
# Wait for reload task to complete and confirm location is removed
await asyncio.sleep(0.01)
# Wait for the reload task spawned by the API to complete
await wait_for_task_by_name(coresys, "BackupManager.reload")
assert coresys.backups.get(slug).all_locations.keys() == {None}
@@ -1500,8 +1500,8 @@ async def test_missing_file_removes_backup_from_cache(
resp = await api_client.request(method, url_path, json=body)
assert resp.status == 404
# Wait for reload task to complete and confirm backup is removed
await asyncio.sleep(0.01)
# Wait for the reload task spawned by the API to complete
await wait_for_task_by_name(coresys, "BackupManager.reload")
assert not coresys.backups.list_backups
@@ -1548,7 +1548,7 @@ async def test_immediate_list_after_missing_file_restore(
assert len(result["data"]["backups"]) == 2
event.set()
await asyncio.sleep(0.1)
await wait_for_task_by_name(coresys, "BackupManager.reload")
resp = await api_client.get("/backups")
assert resp.status == 200
result = await resp.json()

View File

@@ -19,6 +19,53 @@ from supervisor.utils.yaml import read_yaml_file
from .dbus_service_mocks.base import DBusServiceMock
async def wait_for_task_by_name(
coresys, qualname_prefix: str, *, timeout: float = 10.0
) -> None:
"""Await any task whose coroutine qualname starts with prefix.
Looks at the per-test list of tasks captured by the ``coresys``
fixture's ``create_task`` interceptor — that includes tasks that
have already completed, which ``asyncio.all_tasks()`` would miss.
If no matching task is recorded yet, blocks on
``coresys.test_new_task_event`` until one is created (e.g. by a
0-delay ``call_later`` callback that has yet to fire). Raises
``LookupError`` if no matching task is recorded within ``timeout``
seconds — that's a test bug, the call site expects the task to be
scheduled.
"""
deadline = asyncio.get_running_loop().time() + timeout
while True:
matched = [
t
for t in coresys.test_created_tasks
if t.get_coro().__qualname__.startswith(qualname_prefix)
]
if matched:
# gather() is fine on already-done tasks (returns immediately)
# and propagates the first exception so the test fails on a
# real fire-and-forget error instead of silently passing.
await asyncio.gather(*matched)
return
# Clear before waiting so a task created between the check above
# and the wait below still wakes us.
coresys.test_new_task_event.clear()
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise LookupError(
f"No task with qualname prefix {qualname_prefix!r} was created "
f"within {timeout}s"
)
try:
async with asyncio.timeout(remaining):
await coresys.test_new_task_event.wait()
except TimeoutError as err:
raise LookupError(
f"No task with qualname prefix {qualname_prefix!r} was created "
f"within {timeout}s"
) from err
def get_fixture_path(filename: str) -> Path:
"""Get path for fixture."""
return Path(Path(__file__).parent.joinpath("fixtures"), filename)

View File

@@ -574,6 +574,27 @@ async def coresys(
if not request.node.get_closest_marker("no_mock_init_websession"):
coresys_obj.init_websession = AsyncMock()
# Capture every task spawned via coresys.create_task so tests can wait
# on fire-and-forget background jobs (e.g. BackupManager.reload) by
# name even if they have already finished. ``asyncio.all_tasks()`` only
# returns pending tasks, so a fast task can race the test's lookup;
# holding our own reference avoids that. The event lets a test
# blocking in ``wait_for_task_by_name`` wake up as soon as a new task
# is appended.
created_tasks: list[asyncio.Task] = []
new_task_event = asyncio.Event()
_orig_create_task = coresys_obj.create_task
def _capturing_create_task(*args, **kwargs):
task = _orig_create_task(*args, **kwargs)
created_tasks.append(task)
new_task_event.set()
return task
coresys_obj.create_task = _capturing_create_task
coresys_obj.test_created_tasks = created_tasks
coresys_obj.test_new_task_event = new_task_event
# Don't remove files/folders related to apps and stores
with patch("supervisor.store.git.GitRepo.remove"):
yield coresys_obj

View File

@@ -18,6 +18,8 @@ from supervisor.plugins.dns import HostEntry, PluginDns
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from tests.common import wait_for_task_by_name
@pytest.fixture(name="docker_interface")
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
@@ -267,11 +269,13 @@ async def test_notify_locals_changed_end_to_end_with_changes_and_running(
patch.object(dns_plugin.instance, "is_running", return_value=True),
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
):
# Call notify_locals_changed
# Call notify_locals_changed; this schedules a 0-delay timer
# whose callback creates the actual restart task. The helper
# polls a few iterations so the timer-spawned task can show up.
dns_plugin.notify_locals_changed()
# Wait for the async task to complete
await asyncio.sleep(0.1)
await wait_for_task_by_name(
coresys, "PluginDns._restart_dns_after_locals_change"
)
# Verify restart was called and cached locals were updated
mock_restart.assert_called_once()
@@ -297,11 +301,13 @@ async def test_notify_locals_changed_end_to_end_with_changes_but_not_running(
patch.object(dns_plugin.instance, "is_running", return_value=False),
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
):
# Call notify_locals_changed
# Call notify_locals_changed; this schedules a 0-delay timer
# whose callback creates the actual restart task. The helper
# polls a few iterations so the timer-spawned task can show up.
dns_plugin.notify_locals_changed()
# Wait for the async task to complete
await asyncio.sleep(0.1)
await wait_for_task_by_name(
coresys, "PluginDns._restart_dns_after_locals_change"
)
# Verify restart was NOT called but cached locals were still updated
mock_restart.assert_not_called()
@@ -322,11 +328,13 @@ async def test_notify_locals_changed_end_to_end_no_changes(
patch.object(dns_plugin, "restart") as mock_restart,
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
):
# Call notify_locals_changed
# Call notify_locals_changed; this schedules a 0-delay timer
# whose callback creates the actual restart task. The helper
# polls a few iterations so the timer-spawned task can show up.
dns_plugin.notify_locals_changed()
# Wait for the async task to complete
await asyncio.sleep(0.1)
await wait_for_task_by_name(
coresys, "PluginDns._restart_dns_after_locals_change"
)
# Verify restart was NOT called since no changes
mock_restart.assert_not_called()
@@ -376,8 +384,11 @@ async def test_notify_locals_changed_debouncing_cancels_previous_timer(
assert second_handle is not None
assert first_handle != second_handle
# Wait for the async task to complete
await asyncio.sleep(0.1)
# Let the 0-delay timer fire and spawn the restart task; the
# helper polls a few iterations so it shows up.
await wait_for_task_by_name(
coresys, "PluginDns._restart_dns_after_locals_change"
)
# Verify restart was called once for the final timer
mock_restart.assert_called_once()

View File

@@ -21,7 +21,7 @@ from supervisor.host.apparmor import AppArmorControl
from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue
from tests.common import MockResponse
from tests.common import MockResponse, wait_for_task_by_name
@pytest.mark.parametrize(
@@ -199,9 +199,8 @@ async def test_request_connectivity_check_is_fire_and_forget(
result = coresys.supervisor.request_connectivity_check(force=True)
assert result is None
# Yield until the scheduled task has had a chance to complete.
for _ in range(5):
await asyncio.sleep(0)
# Wait for the scheduled background check to finish.
await wait_for_task_by_name(coresys, "Supervisor.check_and_update_connectivity")
assert websession.head.call_count == 1