diff --git a/homeassistant/core.py b/homeassistant/core.py index 9265c57bbf3..d9155ece2d3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -152,7 +152,7 @@ class CoreState(enum.Enum): starting = "STARTING" running = "RUNNING" stopping = "STOPPING" - writing_data = "WRITING_DATA" + final_write = "FINAL_WRITE" def __str__(self) -> str: """Return the event.""" @@ -414,7 +414,7 @@ class HomeAssistant: # regardless of the state of the loop. if self.state == CoreState.not_running: # just ignore return - if self.state == CoreState.stopping or self.state == CoreState.writing_data: + if self.state == CoreState.stopping or self.state == CoreState.final_write: _LOGGER.info("async_stop called twice: ignored") return if self.state == CoreState.starting: @@ -428,7 +428,7 @@ class HomeAssistant: await self.async_block_till_done() # stage 2 - self.state = CoreState.writing_data + self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await self.async_block_till_done() diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0757770d2f7..d57d3ad9920 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,10 +4,7 @@ from datetime import datetime, timedelta import logging from typing import Any, Awaitable, Dict, List, Optional, Set, cast -from homeassistant.const import ( - EVENT_HOMEASSISTANT_FINAL_WRITE, - EVENT_HOMEASSISTANT_START, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( CoreState, HomeAssistant, @@ -187,9 +184,7 @@ class RestoreStateData: async_track_time_interval(self.hass, _async_dump_states, STATE_DUMP_INTERVAL) # Dump states when stopping hass - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_FINAL_WRITE, _async_dump_states - ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_dump_states) @callback def async_restore_entity_added(self, entity_id: str) -> None: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 5885aa01e6f..00df728fb36 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -6,7 +6,7 @@ import os from typing import Any, Callable, Dict, List, Optional, Type, Union from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.loader import bind_hass from homeassistant.util import json as json_util @@ -72,7 +72,7 @@ class Store: self._private = private self._data: Optional[Dict[str, Any]] = None self._unsub_delay_listener: Optional[CALLBACK_TYPE] = None - self._unsub_stop_listener: Optional[CALLBACK_TYPE] = None + self._unsub_final_write_listener: Optional[CALLBACK_TYPE] = None self._write_lock = asyncio.Lock() self._load_task: Optional[asyncio.Future] = None self._encoder = encoder @@ -132,7 +132,12 @@ class Store: self._data = {"version": self.version, "key": self.key, "data": data} self._async_cleanup_delay_listener() - self._async_cleanup_stop_listener() + self._async_cleanup_final_write_listener() + + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return + await self._async_handle_write_data() @callback @@ -141,27 +146,31 @@ class Store: self._data = {"version": self.version, "key": self.key, "data_func": data_func} self._async_cleanup_delay_listener() + self._async_cleanup_final_write_listener() + + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return self._unsub_delay_listener = async_call_later( self.hass, delay, self._async_callback_delayed_write ) - - self._async_ensure_stop_listener() + self._async_ensure_final_write_listener() @callback - def _async_ensure_stop_listener(self): + def _async_ensure_final_write_listener(self): """Ensure that we write if we quit before delay has passed.""" - if self._unsub_stop_listener is None: - self._unsub_stop_listener = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_stop_write + if self._unsub_final_write_listener is None: + self._unsub_final_write_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_final_write ) @callback - def _async_cleanup_stop_listener(self): + def _async_cleanup_final_write_listener(self): """Clean up a stop listener.""" - if self._unsub_stop_listener is not None: - self._unsub_stop_listener() - self._unsub_stop_listener = None + if self._unsub_final_write_listener is not None: + self._unsub_final_write_listener() + self._unsub_final_write_listener = None @callback def _async_cleanup_delay_listener(self): @@ -172,13 +181,17 @@ class Store: async def _async_callback_delayed_write(self, _now): """Handle a delayed write callback.""" + # catch the case where a call is scheduled and then we stop Home Assistant + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return self._unsub_delay_listener = None - self._async_cleanup_stop_listener() + self._async_cleanup_final_write_listener() await self._async_handle_write_data() - async def _async_callback_stop_write(self, _event): - """Handle a write because Home Assistant is stopping.""" - self._unsub_stop_listener = None + async def _async_callback_final_write(self, _event): + """Handle a write because Home Assistant is in final write state.""" + self._unsub_final_write_listener = None self._async_cleanup_delay_listener() await self._async_handle_write_data() diff --git a/tests/common.py b/tests/common.py index 9790a8a7131..f39d458bbe0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1005,7 +1005,7 @@ async def flush_store(store): if store._data is None: return - store._async_cleanup_stop_listener() + store._async_cleanup_final_write_listener() store._async_cleanup_delay_listener() await store._async_handle_write_data() @@ -1018,7 +1018,7 @@ async def get_system_health_info(hass, domain): def mock_integration(hass, module): """Mock an integration.""" integration = loader.Integration( - hass, f"homeassistant.components.{module.DOMAIN}", None, module.mock_manifest(), + hass, f"homeassistant.components.{module.DOMAIN}", None, module.mock_manifest() ) _LOGGER.info("Adding mock integration: %s", module.DOMAIN) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 4d358bde770..6d19022f959 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -28,6 +28,7 @@ from tests.common import ( get_test_home_assistant, mock_coro, mock_registry, + mock_storage, ) from tests.mock.zwave import MockEntityValues, MockNetwork, MockNode, MockValue @@ -827,6 +828,8 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.hass.start() self.registry = mock_registry(self.hass) @@ -862,6 +865,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) @patch.object(zwave, "import_module") @patch.object(zwave, "discovery") @@ -1194,6 +1198,8 @@ class TestZWaveServices(unittest.TestCase): def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.hass.start() # Initialize zwave @@ -1209,6 +1215,7 @@ class TestZWaveServices(unittest.TestCase): self.hass.services.call("zwave", "stop_network", {}) self.hass.block_till_done() self.hass.stop() + self.mock_storage.__exit__(None, None, None) def test_add_node(self): """Test zwave add_node service.""" diff --git a/tests/conftest.py b/tests/conftest.py index 0963d151490..f93d5190350 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,10 @@ from homeassistant.exceptions import ServiceNotFound from homeassistant.setup import async_setup_component from homeassistant.util import location -from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS +from tests.ignore_uncaught_exceptions import ( + IGNORE_UNCAUGHT_EXCEPTIONS, + IGNORE_UNCAUGHT_JSON_EXCEPTIONS, +) pytest.register_assert_rewrite("tests.common") @@ -104,6 +107,13 @@ def hass(loop, hass_storage, request): continue if isinstance(ex, ServiceNotFound): continue + if ( + isinstance(ex, TypeError) + and "is not JSON serializable" in str(ex) + and (request.module.__name__, request.function.__name__) + in IGNORE_UNCAUGHT_JSON_EXCEPTIONS + ): + continue raise ex @@ -211,7 +221,7 @@ def hass_client(hass, aiohttp_client, hass_access_token): async def auth_client(): """Return an authenticated client.""" return await aiohttp_client( - hass.http.app, headers={"Authorization": f"Bearer {hass_access_token}"}, + hass.http.app, headers={"Authorization": f"Bearer {hass_access_token}"} ) return auth_client diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index dcadd4d4369..61648c85ada 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -6,7 +6,11 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE +from homeassistant.const import ( + EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState from homeassistant.helpers import storage from homeassistant.util import dt @@ -79,10 +83,18 @@ async def test_saving_with_delay(hass, store, hass_storage): } -async def test_saving_on_stop(hass, hass_storage): +async def test_saving_on_final_write(hass, hass_storage): """Test delayed saves trigger when we quit Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) - store.async_delay_save(lambda: MOCK_DATA, 1) + store.async_delay_save(lambda: MOCK_DATA, 5) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) @@ -94,6 +106,43 @@ async def test_saving_on_stop(hass, hass_storage): } +async def test_not_delayed_saving_while_stopping(hass, hass_storage): + """Test delayed saves don't write after the stop event has fired.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + hass.state = CoreState.stopping + + store.async_delay_save(lambda: MOCK_DATA, 1) + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + assert store.key not in hass_storage + + +async def test_not_delayed_saving_after_stopping(hass, hass_storage): + """Test delayed saves don't write after stop if issued before stopping Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + store.async_delay_save(lambda: MOCK_DATA, 10) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + assert store.key not in hass_storage + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=15)) + await hass.async_block_till_done() + assert store.key not in hass_storage + + +async def test_not_saving_while_stopping(hass, hass_storage): + """Test saves don't write when stopping Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + hass.state = CoreState.stopping + await store.async_save(MOCK_DATA) + assert store.key not in hass_storage + + async def test_loading_while_delay(hass, store, hass_storage): """Test we load new data even if not written yet.""" await store.async_save({"delay": "no"}) diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index df623a2fc20..126cb37f14f 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -89,3 +89,42 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [ ("tests.components.yr.test_sensor", "test_forecast_setup"), ("tests.components.zwave.test_init", "test_power_schemes"), ] + +IGNORE_UNCAUGHT_JSON_EXCEPTIONS = [ + ("tests.components.spotify.test_config_flow", "test_full_flow"), + ("tests.components.smartthings.test_init", "test_config_entry_loads_platforms"), + ( + "tests.components.smartthings.test_init", + "test_scenes_unauthorized_loads_platforms", + ), + ( + "tests.components.smartthings.test_init", + "test_config_entry_loads_unconnected_cloud", + ), + ("tests.components.samsungtv.test_config_flow", "test_ssdp"), + ("tests.components.samsungtv.test_config_flow", "test_user_websocket"), + ("tests.components.samsungtv.test_config_flow", "test_user_already_configured"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_websocket"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_websocket_ssl"), + ("tests.components.samsungtv.test_config_flow", "test_ssdp_already_configured"), + ("tests.components.samsungtv.test_config_flow", "test_ssdp_noprefix"), + ("tests.components.samsungtv.test_config_flow", "test_user_legacy"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_legacy"), + ( + "tests.components.samsungtv.test_media_player", + "test_select_source_invalid_source", + ), + ( + "tests.components.samsungtv.test_media_player", + "test_play_media_channel_as_string", + ), + ( + "tests.components.samsungtv.test_media_player", + "test_play_media_channel_as_non_positive", + ), + ("tests.components.samsungtv.test_media_player", "test_turn_off_websocket"), + ("tests.components.samsungtv.test_media_player", "test_play_media_invalid_type"), + ("tests.components.harmony.test_config_flow", "test_form_import"), + ("tests.components.harmony.test_config_flow", "test_form_ssdp"), + ("tests.components.harmony.test_config_flow", "test_user_form"), +]