Skip to content

Commit

Permalink
Core API check during startup can timeout (#4595)
Browse files Browse the repository at this point in the history
* Core API check during startup can timeout

* Use a more specific exception so caller can differentiate
  • Loading branch information
mdegat01 authored Oct 4, 2023
1 parent d70aa5f commit 682b8e0
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 6 deletions.
4 changes: 4 additions & 0 deletions supervisor/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ class HomeAssistantCrashError(HomeAssistantError):
"""Error on crash of a Home Assistant startup."""


class HomeAssistantStartupTimeout(HomeAssistantCrashError):
"""Timeout waiting for Home Assistant successful startup."""


class HomeAssistantAPIError(HomeAssistantError):
"""Home Assistant API exception."""

Expand Down
23 changes: 17 additions & 6 deletions supervisor/homeassistant/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import asyncio
from collections.abc import Awaitable
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import re
import secrets
import shutil
from typing import Final

import attr
from awesomeversion import AwesomeVersion

from ..const import ATTR_HOMEASSISTANT, BusEvent
Expand All @@ -21,6 +23,7 @@
HomeAssistantCrashError,
HomeAssistantError,
HomeAssistantJobError,
HomeAssistantStartupTimeout,
HomeAssistantUpdateError,
JobException,
)
Expand All @@ -40,15 +43,17 @@

_LOGGER: logging.Logger = logging.getLogger(__name__)

SECONDS_BETWEEN_API_CHECKS: Final[int] = 5
STARTUP_API_CHECK_TIMEOUT: Final[timedelta] = timedelta(minutes=5)
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")


@attr.s(frozen=True)
@dataclass
class ConfigResult:
"""Return object from config check."""

valid = attr.ib()
log = attr.ib()
valid: bool
log: str


class HomeAssistantCore(JobGroup):
Expand Down Expand Up @@ -435,8 +440,9 @@ async def _block_till_run(self, version: AwesomeVersion) -> None:
return
_LOGGER.info("Wait until Home Assistant is ready")

while True:
await asyncio.sleep(5)
start = datetime.now()
while not (timeout := datetime.now() >= start + STARTUP_API_CHECK_TIMEOUT):
await asyncio.sleep(SECONDS_BETWEEN_API_CHECKS)

# 1: Check if Container is is_running
if not await self.instance.is_running():
Expand All @@ -450,6 +456,11 @@ async def _block_till_run(self, version: AwesomeVersion) -> None:
return

self._error_state = True
if timeout:
raise HomeAssistantStartupTimeout(
"No API response in 5 minutes, assuming core has had a fatal startup error",
_LOGGER.error,
)
raise HomeAssistantCrashError()

@Job(
Expand Down
36 changes: 36 additions & 0 deletions tests/homeassistant/test_core.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Test Home Assistant core."""

from datetime import datetime, timedelta
from unittest.mock import MagicMock, Mock, PropertyMock, patch

from awesomeversion import AwesomeVersion
from docker.errors import DockerException, ImageNotFound, NotFound
import pytest
from time_machine import travel

from supervisor.const import CpuArch
from supervisor.coresys import CoreSys
Expand All @@ -14,6 +17,7 @@
AudioUpdateError,
CodeNotaryError,
DockerError,
HomeAssistantCrashError,
HomeAssistantError,
HomeAssistantJobError,
)
Expand Down Expand Up @@ -263,3 +267,35 @@ async def test_stats_failures(

with pytest.raises(HomeAssistantError):
await coresys.homeassistant.core.stats()


async def test_api_check_timeout(
coresys: CoreSys, container: MagicMock, caplog: pytest.LogCaptureFixture
):
"""Test attempts to contact the API timeout."""
container.status = "stopped"
coresys.homeassistant.version = AwesomeVersion("2023.9.0")
coresys.homeassistant.api.check_api_state.return_value = False

async def mock_instance_start(*_):
container.status = "running"

with patch.object(
DockerHomeAssistant, "start", new=mock_instance_start
), patch.object(DockerAPI, "container_is_initialized", return_value=True), travel(
datetime(2023, 10, 2, 0, 0, 0), tick=False
) as traveller:

async def mock_sleep(*args):
traveller.shift(timedelta(minutes=1))

with patch(
"supervisor.homeassistant.core.asyncio.sleep", new=mock_sleep
), pytest.raises(HomeAssistantCrashError):
await coresys.homeassistant.core.start()

assert coresys.homeassistant.api.check_api_state.call_count == 5
assert (
"No API response in 5 minutes, assuming core has had a fatal startup error"
in caplog.text
)

0 comments on commit 682b8e0

Please sign in to comment.