Skip to content

Commit 636e38f

Browse files
teteleemontnemery
andauthored
Trigger Home Assistant shutdown automations right before the stop event instead of during it (home-assistant#91165)
Co-authored-by: Erik <[email protected]>
1 parent 44810f9 commit 636e38f

File tree

6 files changed

+164
-49
lines changed

6 files changed

+164
-49
lines changed

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ FROM ${BUILD_FROM}
66

77
# Synchronize with homeassistant/core.py:async_stop
88
ENV \
9-
S6_SERVICES_GRACETIME=220000
9+
S6_SERVICES_GRACETIME=240000
1010

1111
ARG QEMU_CPU
1212

homeassistant/components/homeassistant/triggers/homeassistant.py

+13-20
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Offer Home Assistant core automation rules."""
22
import voluptuous as vol
33

4-
from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP
5-
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
4+
from homeassistant.const import CONF_EVENT, CONF_PLATFORM
5+
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
66
from homeassistant.helpers import config_validation as cv
77
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
88
from homeassistant.helpers.typing import ConfigType
@@ -30,24 +30,17 @@ async def async_attach_trigger(
3030
job = HassJob(action, f"homeassistant trigger {trigger_info}")
3131

3232
if event == EVENT_SHUTDOWN:
33-
34-
@callback
35-
def hass_shutdown(event):
36-
"""Execute when Home Assistant is shutting down."""
37-
hass.async_run_hass_job(
38-
job,
39-
{
40-
"trigger": {
41-
**trigger_data,
42-
"platform": "homeassistant",
43-
"event": event,
44-
"description": "Home Assistant stopping",
45-
}
46-
},
47-
event.context,
48-
)
49-
50-
return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown)
33+
return hass.async_add_shutdown_job(
34+
job,
35+
{
36+
"trigger": {
37+
**trigger_data,
38+
"platform": "homeassistant",
39+
"event": event,
40+
"description": "Home Assistant stopping",
41+
}
42+
},
43+
)
5144

5245
# Automation are enabled while hass is starting up, fire right away
5346
# Check state because a config reload shouldn't trigger it.

homeassistant/core.py

+87-21
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
import concurrent.futures
2020
from contextlib import suppress
21+
from dataclasses import dataclass
2122
import datetime
2223
import enum
2324
import functools
@@ -107,9 +108,10 @@
107108
from .helpers.entity import StateInfo
108109

109110

110-
STAGE_1_SHUTDOWN_TIMEOUT = 100
111-
STAGE_2_SHUTDOWN_TIMEOUT = 60
112-
STAGE_3_SHUTDOWN_TIMEOUT = 30
111+
STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20
112+
STOP_STAGE_SHUTDOWN_TIMEOUT = 100
113+
FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60
114+
CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30
113115

114116
block_async_io.enable()
115117

@@ -299,6 +301,14 @@ def __repr__(self) -> str:
299301
return f"<Job {self.name} {self.job_type} {self.target}>"
300302

301303

304+
@dataclass(frozen=True)
305+
class HassJobWithArgs:
306+
"""Container for a HassJob and arguments."""
307+
308+
job: HassJob[..., Coroutine[Any, Any, Any] | Any]
309+
args: Iterable[Any]
310+
311+
302312
def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType:
303313
"""Determine the job type from the callable."""
304314
# Check for partials to properly determine if coroutine function
@@ -370,6 +380,7 @@ def __init__(self, config_dir: str) -> None:
370380
# Timeout handler for Core/Helper namespace
371381
self.timeout: TimeoutManager = TimeoutManager()
372382
self._stop_future: concurrent.futures.Future[None] | None = None
383+
self._shutdown_jobs: list[HassJobWithArgs] = []
373384

374385
@property
375386
def is_running(self) -> bool:
@@ -766,6 +777,42 @@ async def _await_and_log_pending(
766777
for task in pending:
767778
_LOGGER.debug("Waited %s seconds for task: %s", wait_time, task)
768779

780+
@overload
781+
@callback
782+
def async_add_shutdown_job(
783+
self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any
784+
) -> CALLBACK_TYPE:
785+
...
786+
787+
@overload
788+
@callback
789+
def async_add_shutdown_job(
790+
self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any
791+
) -> CALLBACK_TYPE:
792+
...
793+
794+
@callback
795+
def async_add_shutdown_job(
796+
self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any
797+
) -> CALLBACK_TYPE:
798+
"""Add a HassJob which will be executed on shutdown.
799+
800+
This method must be run in the event loop.
801+
802+
hassjob: HassJob
803+
args: parameters for method to call.
804+
805+
Returns function to remove the job.
806+
"""
807+
job_with_args = HassJobWithArgs(hassjob, args)
808+
self._shutdown_jobs.append(job_with_args)
809+
810+
@callback
811+
def remove_job() -> None:
812+
self._shutdown_jobs.remove(job_with_args)
813+
814+
return remove_job
815+
769816
def stop(self) -> None:
770817
"""Stop Home Assistant and shuts down all threads."""
771818
if self.state == CoreState.not_running: # just ignore
@@ -799,6 +846,26 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
799846
"Stopping Home Assistant before startup has completed may fail"
800847
)
801848

849+
# Stage 1 - Run shutdown jobs
850+
try:
851+
async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT):
852+
tasks: list[asyncio.Future[Any]] = []
853+
for job in self._shutdown_jobs:
854+
task_or_none = self.async_run_hass_job(job.job, *job.args)
855+
if not task_or_none:
856+
continue
857+
tasks.append(task_or_none)
858+
if tasks:
859+
asyncio.gather(*tasks, return_exceptions=True)
860+
except asyncio.TimeoutError:
861+
_LOGGER.warning(
862+
"Timed out waiting for shutdown jobs to complete, the shutdown will"
863+
" continue"
864+
)
865+
self._async_log_running_tasks("run shutdown jobs")
866+
867+
# Stage 2 - Stop integrations
868+
802869
# Keep holding the reference to the tasks but do not allow them
803870
# to block shutdown. Only tasks created after this point will
804871
# be waited for.
@@ -816,33 +883,32 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
816883

817884
self.exit_code = exit_code
818885

819-
# stage 1
820886
self.state = CoreState.stopping
821887
self.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
822888
try:
823-
async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT):
889+
async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT):
824890
await self.async_block_till_done()
825891
except asyncio.TimeoutError:
826892
_LOGGER.warning(
827-
"Timed out waiting for shutdown stage 1 to complete, the shutdown will"
893+
"Timed out waiting for integrations to stop, the shutdown will"
828894
" continue"
829895
)
830-
self._async_log_running_tasks(1)
896+
self._async_log_running_tasks("stop integrations")
831897

832-
# stage 2
898+
# Stage 3 - Final write
833899
self.state = CoreState.final_write
834900
self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
835901
try:
836-
async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT):
902+
async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT):
837903
await self.async_block_till_done()
838904
except asyncio.TimeoutError:
839905
_LOGGER.warning(
840-
"Timed out waiting for shutdown stage 2 to complete, the shutdown will"
906+
"Timed out waiting for final writes to complete, the shutdown will"
841907
" continue"
842908
)
843-
self._async_log_running_tasks(2)
909+
self._async_log_running_tasks("final write")
844910

845-
# stage 3
911+
# Stage 4 - Close
846912
self.state = CoreState.not_running
847913
self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
848914

@@ -856,12 +922,12 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
856922
# were awaiting another task
857923
continue
858924
_LOGGER.warning(
859-
"Task %s was still running after stage 2 shutdown; "
925+
"Task %s was still running after final writes shutdown stage; "
860926
"Integrations should cancel non-critical tasks when receiving "
861927
"the stop event to prevent delaying shutdown",
862928
task,
863929
)
864-
task.cancel("Home Assistant stage 2 shutdown")
930+
task.cancel("Home Assistant final writes shutdown stage")
865931
try:
866932
async with asyncio.timeout(0.1):
867933
await task
@@ -870,11 +936,11 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
870936
except asyncio.TimeoutError:
871937
# Task may be shielded from cancellation.
872938
_LOGGER.exception(
873-
"Task %s could not be canceled during stage 3 shutdown", task
939+
"Task %s could not be canceled during final shutdown stage", task
874940
)
875941
except Exception as exc: # pylint: disable=broad-except
876942
_LOGGER.exception(
877-
"Task %s error during stage 3 shutdown: %s", task, exc
943+
"Task %s error during final shutdown stage: %s", task, exc
878944
)
879945

880946
# Prevent run_callback_threadsafe from scheduling any additional
@@ -885,14 +951,14 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None:
885951
shutdown_run_callback_threadsafe(self.loop)
886952

887953
try:
888-
async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT):
954+
async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT):
889955
await self.async_block_till_done()
890956
except asyncio.TimeoutError:
891957
_LOGGER.warning(
892-
"Timed out waiting for shutdown stage 3 to complete, the shutdown will"
958+
"Timed out waiting for close event to be processed, the shutdown will"
893959
" continue"
894960
)
895-
self._async_log_running_tasks(3)
961+
self._async_log_running_tasks("close")
896962

897963
self.state = CoreState.stopped
898964

@@ -912,10 +978,10 @@ def _cancel_cancellable_timers(self) -> None:
912978
):
913979
handle.cancel()
914980

915-
def _async_log_running_tasks(self, stage: int) -> None:
981+
def _async_log_running_tasks(self, stage: str) -> None:
916982
"""Log all running tasks."""
917983
for task in self._tasks:
918-
_LOGGER.warning("Shutdown stage %s: still running: %s", stage, task)
984+
_LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task)
919985

920986

921987
class Context:

script/hassfest/docker.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@
5959

6060
def _generate_dockerfile() -> str:
6161
timeout = (
62-
core.STAGE_1_SHUTDOWN_TIMEOUT
63-
+ core.STAGE_2_SHUTDOWN_TIMEOUT
64-
+ core.STAGE_3_SHUTDOWN_TIMEOUT
62+
core.STOPPING_STAGE_SHUTDOWN_TIMEOUT
63+
+ core.STOP_STAGE_SHUTDOWN_TIMEOUT
64+
+ core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT
65+
+ core.CLOSE_STAGE_SHUTDOWN_TIMEOUT
6566
+ executor.EXECUTOR_SHUTDOWN_TIMEOUT
6667
+ thread.THREADING_SHUTDOWN_TIMEOUT
6768
+ 10

tests/test_core.py

+54
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
)
3737
import homeassistant.core as ha
3838
from homeassistant.core import (
39+
CoreState,
3940
HassJob,
4041
HomeAssistant,
4142
ServiceCall,
@@ -399,6 +400,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None:
399400
assert len(test_all) == 2
400401

401402

403+
async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None:
404+
"""Simulate a shutdown, test timeouts at each step."""
405+
406+
with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError):
407+
await hass.async_stop()
408+
409+
assert hass.state == CoreState.stopped
410+
411+
412+
async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None:
413+
"""Simulate a shutdown, test that a generic error at the final stage doesn't prevent it."""
414+
415+
task = asyncio.Future()
416+
hass._tasks.add(task)
417+
418+
def fail_the_task(_):
419+
task.set_exception(Exception("test_exception"))
420+
421+
with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call:
422+
await hass.async_stop()
423+
assert patched_call.called
424+
425+
assert "test_exception" in caplog.text
426+
assert hass.state == ha.CoreState.stopped
427+
428+
402429
async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None:
403430
"""Simulate a shutdown, test calling stuff with exit code checks."""
404431
test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP)
@@ -2566,3 +2593,30 @@ def not_callback_func():
25662593
HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type
25672594
== ha.HassJobType.Callback
25682595
)
2596+
2597+
2598+
async def test_shutdown_job(hass: HomeAssistant) -> None:
2599+
"""Test async_add_shutdown_job."""
2600+
evt = asyncio.Event()
2601+
2602+
async def shutdown_func() -> None:
2603+
evt.set()
2604+
2605+
job = HassJob(shutdown_func, "shutdown_job")
2606+
hass.async_add_shutdown_job(job)
2607+
await hass.async_stop()
2608+
assert evt.is_set()
2609+
2610+
2611+
async def test_cancel_shutdown_job(hass: HomeAssistant) -> None:
2612+
"""Test cancelling a job added to async_add_shutdown_job."""
2613+
evt = asyncio.Event()
2614+
2615+
async def shutdown_func() -> None:
2616+
evt.set()
2617+
2618+
job = HassJob(shutdown_func, "shutdown_job")
2619+
cancel = hass.async_add_shutdown_job(job)
2620+
cancel()
2621+
await hass.async_stop()
2622+
assert not evt.is_set()

tests/test_runner.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@
1313
from homeassistant.util import executor, thread
1414

1515
# https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py
16-
SUPERVISOR_HARD_TIMEOUT = 220
16+
SUPERVISOR_HARD_TIMEOUT = 240
1717

1818
TIMEOUT_SAFETY_MARGIN = 10
1919

2020

2121
async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None:
2222
"""Verify the cumulative shutdown timeout is at least 10s less than the supervisor."""
2323
assert (
24-
core.STAGE_1_SHUTDOWN_TIMEOUT
25-
+ core.STAGE_2_SHUTDOWN_TIMEOUT
26-
+ core.STAGE_3_SHUTDOWN_TIMEOUT
24+
core.STOPPING_STAGE_SHUTDOWN_TIMEOUT
25+
+ core.STOP_STAGE_SHUTDOWN_TIMEOUT
26+
+ core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT
27+
+ core.CLOSE_STAGE_SHUTDOWN_TIMEOUT
2728
+ executor.EXECUTOR_SHUTDOWN_TIMEOUT
2829
+ thread.THREADING_SHUTDOWN_TIMEOUT
2930
+ TIMEOUT_SAFETY_MARGIN

0 commit comments

Comments
 (0)