From c291d4aa7dcd2ce7dc0b95ce03068140ac9075d4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Aug 2020 14:58:19 +0200 Subject: [PATCH] Intelligent timeout handler for setup/bootstrap (#38329) Co-authored-by: Paulus Schoutsen Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 42 +- homeassistant/components/recorder/__init__.py | 4 +- homeassistant/components/recorder/const.py | 1 + .../components/recorder/migration.py | 16 +- homeassistant/core.py | 34 +- homeassistant/helpers/entity_platform.py | 3 +- homeassistant/requirements.py | 18 +- homeassistant/setup.py | 19 +- homeassistant/util/timeout.py | 508 ++++++++++++++++++ .../alarm_control_panel/test_device_action.py | 4 + .../binary_sensor/test_device_condition.py | 1 + .../binary_sensor/test_device_trigger.py | 1 + tests/components/cover/test_device_action.py | 10 + tests/components/dsmr/test_sensor.py | 1 + tests/components/flux/test_switch.py | 20 + tests/components/lock/test_device_action.py | 3 + tests/helpers/test_entity_platform.py | 4 +- tests/test_core.py | 4 +- tests/test_requirements.py | 21 - tests/test_setup.py | 8 +- tests/util/test_timeout.py | 268 +++++++++ 21 files changed, 901 insertions(+), 89 deletions(-) create mode 100644 homeassistant/util/timeout.py create mode 100644 tests/util/test_timeout.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7d20ca0ce90..4cf95d68f05 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -9,7 +9,6 @@ import sys from time import monotonic from typing import TYPE_CHECKING, Any, Dict, Optional, Set -from async_timeout import timeout import voluptuous as vol import yarl @@ -44,6 +43,11 @@ DATA_LOGGING = "logging" LOG_SLOW_STARTUP_INTERVAL = 60 +STAGE_1_TIMEOUT = 120 +STAGE_2_TIMEOUT = 300 +WRAP_UP_TIMEOUT = 300 +COOLDOWN_TIME = 60 + DEBUGGER_INTEGRATIONS = {"debugpy", "ptvsd"} CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") LOGGING_INTEGRATIONS = { @@ -136,7 +140,7 @@ async def async_setup_hass( hass.async_track_tasks() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) with contextlib.suppress(asyncio.TimeoutError): - async with timeout(10): + async with hass.timeout.async_timeout(10): await hass.async_block_till_done() safe_mode = True @@ -496,24 +500,42 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains # Kick off loading the registries. They don't need to be awaited. - asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), - hass.helpers.area_registry.async_get_registry(), - ) + asyncio.create_task(hass.helpers.device_registry.async_get_registry()) + asyncio.create_task(hass.helpers.entity_registry.async_get_registry()) + asyncio.create_task(hass.helpers.area_registry.async_get_registry()) # Start setup if stage_1_domains: _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - await async_setup_multi_components(hass, stage_1_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_1_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 1 - moving forward") # Enables after dependencies async_set_domains_to_be_loaded(hass, stage_1_domains | stage_2_domains) if stage_2_domains: _LOGGER.info("Setting up stage 2: %s", stage_2_domains) - await async_setup_multi_components(hass, stage_2_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_2_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 2 - moving forward") # Wrap up startup _LOGGER.debug("Waiting for startup to wrap up") - await hass.async_block_till_done() + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5ac4d226082..d0c18256377 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,14 +35,12 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import migration, purge -from .const import DATA_INSTANCE, SQLITE_URL_PREFIX +from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import Base, Events, RecorderRuns, States from .util import session_scope, validate_or_move_away_sqlite_database _LOGGER = logging.getLogger(__name__) -DOMAIN = "recorder" - SERVICE_PURGE = "purge" ATTR_KEEP_DAYS = "keep_days" diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index fb699d13fb3..b2ffc91fdb4 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -2,3 +2,4 @@ DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" +DOMAIN = "recorder" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d8b508ba513..e88852e4a5a 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,22 +1,19 @@ """Schema migration helpers.""" import logging -import os from sqlalchemy import Table, text from sqlalchemy.engine import reflection from sqlalchemy.exc import InternalError, OperationalError, SQLAlchemyError +from .const import DOMAIN from .models import SCHEMA_VERSION, Base, SchemaChanges from .util import session_scope _LOGGER = logging.getLogger(__name__) -PROGRESS_FILE = ".migration_progress" def migrate_schema(instance): """Check if the schema needs to be upgraded.""" - progress_path = instance.hass.config.path(PROGRESS_FILE) - with session_scope(session=instance.get_session()) as session: res = ( session.query(SchemaChanges) @@ -32,20 +29,13 @@ def migrate_schema(instance): ) if current_version == SCHEMA_VERSION: - # Clean up if old migration left file - if os.path.isfile(progress_path): - _LOGGER.warning("Found existing migration file, cleaning up") - os.remove(instance.hass.config.path(PROGRESS_FILE)) return - with open(progress_path, "w"): - pass - _LOGGER.warning( "Database is about to upgrade. Schema version: %s", current_version ) - try: + with instance.hass.timeout.freeze(DOMAIN): for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) @@ -53,8 +43,6 @@ def migrate_schema(instance): session.add(SchemaChanges(schema_version=new_version)) _LOGGER.info("Upgrade to version %s done", new_version) - finally: - os.remove(instance.hass.config.path(PROGRESS_FILE)) def _create_index(engine, table_name, index_name): diff --git a/homeassistant/core.py b/homeassistant/core.py index 1d05336e5c4..da40c17c411 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -35,7 +35,6 @@ from typing import ( ) import uuid -from async_timeout import timeout import attr import voluptuous as vol import yarl @@ -76,6 +75,7 @@ from homeassistant.util import location, network from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.thread import fix_threading_exception_logging +from homeassistant.util.timeout import TimeoutManager from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem # Typing imports that create a circular dependency @@ -184,10 +184,12 @@ class HomeAssistant: self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. self.data: dict = {} - self.state = CoreState.not_running - self.exit_code = 0 + self.state: CoreState = CoreState.not_running + self.exit_code: int = 0 # If not None, use to signal end-of-loop self._stopped: Optional[asyncio.Event] = None + # Timeout handler for Core/Helper namespace + self.timeout: TimeoutManager = TimeoutManager() @property def is_running(self) -> bool: @@ -255,7 +257,7 @@ class HomeAssistant: try: # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() - async with timeout(TIMEOUT_EVENT_START): + async with self.timeout.async_timeout(TIMEOUT_EVENT_START): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( @@ -460,17 +462,35 @@ class HomeAssistant: self.state = CoreState.stopping self.async_track_tasks() self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(120): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 1 to complete, the shutdown will continue" + ) # stage 2 self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(60): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 2 to complete, the shutdown will continue" + ) # stage 3 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(30): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 3 to complete, the shutdown will continue" + ) # Python 3.9+ and backported in runner.py await self.loop.shutdown_default_executor() # type: ignore diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index fb542f660d1..7a581dbd19e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -177,7 +177,8 @@ class EntityPlatform: try: task = async_create_setup_task() - await asyncio.wait_for(asyncio.shield(task), SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, self.domain): + await asyncio.shield(task) # Block till all entities are done if self._tasks: diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 0b4560c8ac3..303f6219cae 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -2,7 +2,6 @@ import asyncio import logging import os -from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast from homeassistant.core import HomeAssistant @@ -14,7 +13,6 @@ DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" CONSTRAINT_FILE = "package_constraints.txt" -PROGRESS_FILE = ".pip_progress" _LOGGER = logging.getLogger(__name__) DISCOVERY_INTEGRATIONS: Dict[str, Iterable[str]] = { "ssdp": ("ssdp",), @@ -124,22 +122,16 @@ async def async_process_requirements( if pkg_util.is_installed(req): continue - ret = await hass.async_add_executor_job(_install, hass, req, kwargs) + def _install(req: str, kwargs: Dict) -> bool: + """Install requirement.""" + return pkg_util.install_package(req, **kwargs) + + ret = await hass.async_add_executor_job(_install, req, kwargs) if not ret: raise RequirementsNotFound(name, [req]) -def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool: - """Install requirement.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - progress_path.touch() - try: - return pkg_util.install_package(req, **kwargs) - finally: - progress_path.unlink() - - def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" is_docker = pkg_util.is_docker_env() diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3395baa3e86..578cd33b097 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -22,11 +22,7 @@ DATA_SETUP = "setup_tasks" DATA_DEPS_REQS = "deps_reqs_processed" SLOW_SETUP_WARNING = 10 - -# Since its possible for databases to be -# upwards of 36GiB (or larger) in the wild -# we wait up to 3 hours for startup -SLOW_SETUP_MAX_WAIT = 10800 +SLOW_SETUP_MAX_WAIT = 300 @core.callback @@ -89,7 +85,8 @@ async def _async_process_dependencies( return True _LOGGER.debug("Dependency %s will wait for %s", integration.domain, list(tasks)) - results = await asyncio.gather(*tasks.values()) + async with hass.timeout.async_freeze(integration.domain): + results = await asyncio.gather(*tasks.values()) failed = [ domain @@ -190,7 +187,8 @@ async def _async_setup_component( hass.data[DATA_SETUP_STARTED].pop(domain) return False - result = await asyncio.wait_for(task, SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, domain): + result = await task except asyncio.TimeoutError: _LOGGER.error( "Setup of %s is taking longer than %s seconds." @@ -319,9 +317,10 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not set up all dependencies.") if not hass.config.skip_pip and integration.requirements: - await requirements.async_get_integration_with_requirements( - hass, integration.domain - ) + async with hass.timeout.async_freeze(integration.domain): + await requirements.async_get_integration_with_requirements( + hass, integration.domain + ) processed.add(integration.domain) diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py new file mode 100644 index 00000000000..908d36a41bb --- /dev/null +++ b/homeassistant/util/timeout.py @@ -0,0 +1,508 @@ +"""Advanced timeout handling. + +Set of helper classes to handle timeouts of tasks with advanced options +like zones and freezing of timeouts. +""" +from __future__ import annotations + +import asyncio +import enum +import logging +from types import TracebackType +from typing import Any, Dict, List, Optional, Type, Union + +from .async_ import run_callback_threadsafe + +ZONE_GLOBAL = "global" + +_LOGGER = logging.getLogger(__name__) + + +class _State(str, enum.Enum): + """States of a task.""" + + INIT = "INIT" + ACTIVE = "ACTIVE" + TIMEOUT = "TIMEOUT" + EXIT = "EXIT" + + +class _GlobalFreezeContext: + """Context manager that freezes the global timeout.""" + + def __init__(self, manager: TimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + + async def __aenter__(self) -> _GlobalFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _GlobalFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.pause() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.pause() + + self._manager.global_freezes.append(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._manager.global_freezes.remove(self) + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.reset() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.reset() + + +class _ZoneFreezeContext: + """Context manager that freezes a zone timeout.""" + + def __init__(self, zone: _ZoneTimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + + async def __aenter__(self) -> _ZoneFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _ZoneFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if self._zone.freezes_done: + self._zone.pause() + self._zone.enter_freeze(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._zone.exit_freeze(self) + if not self._zone.freezes_done: + return + self._zone.reset() + + +class _GlobalTaskContext: + """Context manager that tracks a global task.""" + + def __init__( + self, + manager: TimeoutManager, + task: asyncio.Task[Any], + timeout: float, + cool_down: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + self._task: asyncio.Task[Any] = task + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + self._wait_zone: asyncio.Event = asyncio.Event() + self._state: _State = _State.INIT + self._cool_down: float = cool_down + + async def __aenter__(self) -> _GlobalTaskContext: + self._manager.global_tasks.append(self) + self._start_timer() + self._state = _State.ACTIVE + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._stop_timer() + self._manager.global_tasks.remove(self) + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + self._wait_zone.set() + return None + + @property + def state(self) -> _State: + """Return state of the Global task.""" + return self._state + + def zones_done_signal(self) -> None: + """Signal that all zones are done.""" + self._wait_zone.set() + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Reset timer if zones are running + if not self._manager.zones_done: + asyncio.create_task(self._on_wait()) + else: + self._cancel_task() + + def _cancel_task(self) -> None: + """Cancel own task.""" + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + async def _on_wait(self) -> None: + """Wait until zones are done.""" + await self._wait_zone.wait() + await asyncio.sleep(self._cool_down) # Allow context switch + if not self.state == _State.TIMEOUT: + return + self._cancel_task() + + +class _ZoneTaskContext: + """Context manager that tracks an active task for a zone.""" + + def __init__( + self, zone: _ZoneTimeoutManager, task: asyncio.Task[Any], timeout: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + self._task: asyncio.Task[Any] = task + self._state: _State = _State.INIT + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + + @property + def state(self) -> _State: + """Return state of the Zone task.""" + return self._state + + async def __aenter__(self) -> _ZoneTaskContext: + self._zone.enter_task(self) + self._state = _State.ACTIVE + + # Zone is on freeze + if self._zone.freezes_done: + self._start_timer() + + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._zone.exit_task(self) + self._stop_timer() + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + return None + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Timeout + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + +class _ZoneTimeoutManager: + """Manage the timeouts for a zone.""" + + def __init__(self, manager: TimeoutManager, zone: str) -> None: + """Initialize internal timeout context manager.""" + self._manager: TimeoutManager = manager + self._zone: str = zone + self._tasks: List[_ZoneTaskContext] = [] + self._freezes: List[_ZoneFreezeContext] = [] + + @property + def name(self) -> str: + """Return Zone name.""" + return self._zone + + @property + def active(self) -> bool: + """Return True if zone is active.""" + return len(self._tasks) > 0 or len(self._freezes) > 0 + + @property + def freezes_done(self) -> bool: + """Return True if all freeze are done.""" + return len(self._freezes) == 0 and self._manager.freezes_done + + def enter_task(self, task: _ZoneTaskContext) -> None: + """Start into new Task.""" + self._tasks.append(task) + + def exit_task(self, task: _ZoneTaskContext) -> None: + """Exit a running Task.""" + self._tasks.remove(task) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def enter_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Start into new freeze.""" + self._freezes.append(freeze) + + def exit_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Exit a running Freeze.""" + self._freezes.remove(freeze) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def pause(self) -> None: + """Stop timers while it freeze.""" + if not self.active: + return + + # Forward pause + for task in self._tasks: + task.pause() + + def reset(self) -> None: + """Reset timer after freeze.""" + if not self.active: + return + + # Forward reset + for task in self._tasks: + task.reset() + + +class TimeoutManager: + """Class to manage timeouts over different zones. + + Manages both global and zone based timeouts. + """ + + def __init__(self) -> None: + """Initialize TimeoutManager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zones: Dict[str, _ZoneTimeoutManager] = {} + self._globals: List[_GlobalTaskContext] = [] + self._freezes: List[_GlobalFreezeContext] = [] + + @property + def zones_done(self) -> bool: + """Return True if all zones are finished.""" + return not bool(self._zones) + + @property + def freezes_done(self) -> bool: + """Return True if all freezes are finished.""" + return not self._freezes + + @property + def zones(self) -> Dict[str, _ZoneTimeoutManager]: + """Return all Zones.""" + return self._zones + + @property + def global_tasks(self) -> List[_GlobalTaskContext]: + """Return all global Tasks.""" + return self._globals + + @property + def global_freezes(self) -> List[_GlobalFreezeContext]: + """Return all global Freezes.""" + return self._freezes + + def drop_zone(self, zone_name: str) -> None: + """Drop a zone out of scope.""" + self._zones.pop(zone_name, None) + if self._zones: + return + + # Signal Global task, all zones are done + for task in self._globals: + task.zones_done_signal() + + def async_timeout( + self, timeout: float, zone_name: str = ZONE_GLOBAL, cool_down: float = 0 + ) -> Union[_ZoneTaskContext, _GlobalTaskContext]: + """Timeout based on a zone. + + For using as Async Context Manager. + """ + current_task: Optional[asyncio.Task[Any]] = asyncio.current_task() + assert current_task + + # Global Zone + if zone_name == ZONE_GLOBAL: + task = _GlobalTaskContext(self, current_task, timeout, cool_down) + return task + + # Zone Handling + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + # Create Task + return _ZoneTaskContext(zone, current_task, timeout) + + def async_freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Async Context Manager. + """ + # Global Freeze + if zone_name == ZONE_GLOBAL: + return _GlobalFreezeContext(self) + + # Zone Freeze + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + return _ZoneFreezeContext(zone) + + def freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Context Manager. + """ + return run_callback_threadsafe( + self._loop, self.async_freeze, zone_name + ).result() diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 72754c3c96f..74c98da3189 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -133,6 +133,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": {"extra_fields": []}, @@ -170,6 +171,7 @@ async def test_get_action_capabilities_arm_code(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": { @@ -267,6 +269,8 @@ async def test_action(hass): }, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + assert ( hass.states.get("alarm_control_panel.alarm_no_arm_code").state == STATE_UNKNOWN ) diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index fef12c88f49..9fd50277d23 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -60,6 +60,7 @@ async def test_get_conditions(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_conditions = [ { diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 92fbce27bc1..cea0103acdb 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -60,6 +60,7 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_triggers = [ { diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index b38521d4051..d302353582c 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -46,6 +46,7 @@ async def test_get_actions(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -87,6 +88,7 @@ async def test_get_actions_tilt(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -140,6 +142,7 @@ async def test_get_actions_set_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -169,6 +172,7 @@ async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -217,6 +221,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 3 # open, close, stop @@ -244,6 +249,7 @@ async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -286,6 +292,7 @@ async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -352,6 +359,7 @@ async def test_action(hass): ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover") close_calls = async_mock_service(hass, "cover", "close_cover") @@ -408,6 +416,7 @@ async def test_action_tilt(hass): ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover_tilt") close_calls = async_mock_service(hass, "cover", "close_cover_tilt") @@ -468,6 +477,7 @@ async def test_action_set_position(hass): ] }, ) + await hass.async_block_till_done() cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position") tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position") diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6e6cc8f55c6..ed660eb4f51 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -366,6 +366,7 @@ async def test_reconnect(hass, monkeypatch, mock_connection_factory): protocol.wait_closed = wait_closed await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() assert connection_factory.call_count == 1 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index cc194f4f14a..594b3aff2b2 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -104,6 +104,7 @@ async def test_valid_config_with_info(hass): } }, ) + await hass.async_block_till_done() async def test_valid_config_no_name(hass): @@ -114,6 +115,7 @@ async def test_valid_config_no_name(hass): "switch", {"switch": {"platform": "flux", "lights": ["light.desk", "light.lamp"]}}, ) + await hass.async_block_till_done() async def test_invalid_config_no_lights(hass): @@ -122,6 +124,7 @@ async def test_invalid_config_no_lights(hass): assert await async_setup_component( hass, "switch", {"switch": {"platform": "flux", "name": "flux"}} ) + await hass.async_block_till_done() async def test_flux_when_switch_is_off(hass, legacy_patchable_time): @@ -168,6 +171,7 @@ async def test_flux_when_switch_is_off(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() async_fire_time_changed(hass, test_time) await hass.async_block_till_done() @@ -218,6 +222,7 @@ async def test_flux_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -271,6 +276,7 @@ async def test_flux_before_sunrise_known_location(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -325,6 +331,7 @@ async def test_flux_after_sunrise_before_sunset(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -380,6 +387,7 @@ async def test_flux_after_sunset_before_stop(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -434,6 +442,7 @@ async def test_flux_after_stop_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -490,6 +499,7 @@ async def test_flux_with_custom_start_stop_times(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -547,6 +557,7 @@ async def test_flux_before_sunrise_stop_next_day(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -608,6 +619,7 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -669,6 +681,7 @@ async def test_flux_after_sunset_before_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -729,6 +742,7 @@ async def test_flux_after_sunset_after_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -789,6 +803,7 @@ async def test_flux_after_stop_before_sunrise_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -846,6 +861,7 @@ async def test_flux_with_custom_colortemps(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -902,6 +918,7 @@ async def test_flux_with_custom_brightness(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -974,6 +991,7 @@ async def test_flux_with_multiple_lights(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1033,6 +1051,7 @@ async def test_flux_with_mired(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1085,6 +1104,7 @@ async def test_flux_with_rgb(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 0fc98d9460e..dbf390df57b 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -34,6 +34,7 @@ async def test_get_actions_support_open(hass, device_reg, entity_reg): platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -77,6 +78,7 @@ async def test_get_actions_not_support_open(hass, device_reg, entity_reg): platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -146,6 +148,7 @@ async def test_action(hass): ] }, ) + await hass.async_block_till_done() lock_calls = async_mock_service(hass, "lock", "lock") unlock_calls = async_mock_service(hass, "lock", "unlock") diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 527a89843dd..5912eb42b03 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -188,8 +188,8 @@ async def test_platform_warn_slow_setup(hass): assert mock_call.called # mock_calls[0] is the warning message for component setup - # mock_calls[6] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[6][1][:2] + # mock_calls[4] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[4][1][:2] assert timeout == entity_platform.SLOW_SETUP_WARNING assert logger_method == _LOGGER.warning diff --git a/tests/test_core.py b/tests/test_core.py index 167eda3f6cb..77baa502687 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1140,8 +1140,8 @@ async def test_start_taking_too_long(loop, caplog): caplog.set_level(logging.WARNING) try: - with patch( - "homeassistant.core.timeout", side_effect=asyncio.TimeoutError + with patch.object( + hass, "async_block_till_done", side_effect=asyncio.TimeoutError ), patch("homeassistant.core._async_create_timer") as mock_timer: await hass.async_start() diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 20202f91e89..fcc2d571331 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,15 +1,12 @@ """Test requirements module.""" import os -from pathlib import Path import pytest from homeassistant import loader, setup from homeassistant.requirements import ( CONSTRAINT_FILE, - PROGRESS_FILE, RequirementsNotFound, - _install, async_get_integration_with_requirements, async_process_requirements, ) @@ -190,24 +187,6 @@ async def test_install_on_docker(hass): ) -async def test_progress_lock(hass): - """Test an install attempt on an existing package.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - kwargs = {"hello": "world"} - - def assert_env(req, **passed_kwargs): - """Assert the env.""" - assert progress_path.exists() - assert req == "hello" - assert passed_kwargs == kwargs - return True - - with patch("homeassistant.util.package.install_package", side_effect=assert_env): - _install(hass, "hello", kwargs) - - assert not progress_path.exists() - - async def test_discovery_requirements_ssdp(hass): """Test that we load discovery requirements.""" hass.config.skip_pip = False diff --git a/tests/test_setup.py b/tests/test_setup.py index cb63f8fa865..abd9cecd9ac 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -488,15 +488,12 @@ async def test_component_warn_slow_setup(hass): assert result assert mock_call.called - assert len(mock_call.mock_calls) == 5 + assert len(mock_call.mock_calls) == 3 timeout, logger_method = mock_call.mock_calls[0][1][:2] assert timeout == setup.SLOW_SETUP_WARNING assert logger_method == setup._LOGGER.warning - timeout, function = mock_call.mock_calls[1][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT - assert mock_call().cancel.called @@ -508,8 +505,7 @@ async def test_platform_no_warn_slow(hass): with patch.object(hass.loop, "call_later") as mock_call: result = await setup.async_setup_component(hass, "test_component1", {}) assert result - timeout, function = mock_call.mock_calls[0][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT + assert len(mock_call.mock_calls) == 0 async def test_platform_error_slow_setup(hass, caplog): diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py new file mode 100644 index 00000000000..edd8f4107a4 --- /dev/null +++ b/tests/util/test_timeout.py @@ -0,0 +1,268 @@ +"""Test Home Assistant timeout handler.""" +import asyncio +import time + +import pytest + +from homeassistant.util.timeout import TimeoutManager + + +async def test_simple_global_timeout(): + """Test a simple global timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await asyncio.sleep(0.3) + + +async def test_simple_global_timeout_with_executor_job(hass): + """Test a simple global timeout with executor job.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze(): + """Test a simple global timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_inside_executor_job(hass): + """Test a simple zone timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(1.0): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_simple_global_timeout_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze(): + time.sleep(0.3) + + async with timeout.async_timeout(0.2): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_different_order(hass): + """Test a simple global timeout freeze inside an executor job before timeout was set.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.4) + + async with timeout.async_timeout(0.1): + hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout(0.2, zone_name="recorder"): + await asyncio.sleep(0.3) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_executor_job( + hass, +): + """Test a simple global timeout freeze other zone inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("not_recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + async with timeout.async_timeout(0.2, zone_name="not_recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_second_job_outside_zone_context( + hass, +): + """Test a simple global timeout freeze inside an executor job with second job outside of zone context.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze_with_executor_job(hass): + """Test a simple global timeout freeze with executor job.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await hass.async_add_executor_job(lambda: time.sleep(0.3)) + + +async def test_simple_global_timeout_freeze_reset(): + """Test a simple global timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2) + + +async def test_simple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + + +async def test_multiple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "test"): + await asyncio.sleep(0.3) + + +async def test_different_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "other"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze(): + """Test a simple zone timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_without_timeout(): + """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_reset(): + """Test a simple zone timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2, "test") + + +async def test_mix_zone_timeout_freeze_and_global_freeze(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_global_and_zone_timeout_freeze_(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_freeze(): + """Test a mix zone timeout global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout(): + """Test a mix zone timeout global.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.2, "test"): + await asyncio.sleep(0.4) + except asyncio.TimeoutError: + pass + + +async def test_mix_zone_timeout_trigger_global(): + """Test a mix zone timeout global with trigger it.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_trigger_global_cool_down(): + """Test a mix zone timeout global with trigger it with cool_down.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, cool_down=0.3): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.2)