diff --git a/supervisor/__main__.py b/supervisor/__main__.py index 686e83f0948..1194d645c43 100644 --- a/supervisor/__main__.py +++ b/supervisor/__main__.py @@ -13,6 +13,8 @@ # pylint: disable=wrong-import-position from supervisor import bootstrap # noqa: E402 +from supervisor.dbus.const import SystemState # noqa: E402 +from supervisor.exceptions import HassioError # noqa: E402 from supervisor.utils.blockbuster import BlockBusterManager # noqa: E402 from supervisor.utils.logging import activate_log_queue_handler # noqa: E402 @@ -68,6 +70,32 @@ def run_os_startup_check_cleanup() -> None: # Create startup task that can be cancelled gracefully startup_task = loop.create_task(coresys.core.start()) + shutdown_tasks: list[asyncio.Task] = [] + + async def host_is_shutting_down() -> bool: + """Return True if systemd is shutting the host down. + + This relies only on the systemd manager state, which is exposed by + every systemd version, so it works regardless of the running HAOS + release (no dependency on OS-side units being present). + """ + if not coresys.dbus.systemd.is_connected: + return False + + try: + return await coresys.dbus.systemd.get_system_state() == SystemState.STOPPING + except HassioError as err: + _LOGGER.warning("Could not read systemd manager state: %s", err) + return False + + async def stop_supervisor() -> None: + """Stop Supervisor, including managed services during host shutdown.""" + try: + if await host_is_shutting_down(): + _LOGGER.info("Host shutdown detected, shutting down managed services") + await coresys.core.shutdown() + finally: + await coresys.core.stop() def shutdown_handler() -> None: """Handle shutdown signals gracefully during startup.""" @@ -75,7 +103,10 @@ def shutdown_handler() -> None: _LOGGER.warning("Supervisor startup interrupted by shutdown signal") startup_task.cancel() - coresys.create_task(coresys.core.stop()) + if shutdown_tasks and not shutdown_tasks[0].done(): + return + + shutdown_tasks[:] = [coresys.create_task(stop_supervisor())] bootstrap.register_signal_handlers(loop, shutdown_handler) diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index e7e53dfe5a0..7ca91974f38 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -427,6 +427,17 @@ class StartUnitMode(StrEnum): ISOLATE = "isolate" +class SystemState(DBusStrEnum): + """State of the systemd manager.""" + + INITIALIZING = "initializing" + STARTING = "starting" + RUNNING = "running" + DEGRADED = "degraded" + MAINTENANCE = "maintenance" + STOPPING = "stopping" + + class UnitActiveState(DBusStrEnum): """Active state of a systemd unit.""" diff --git a/supervisor/dbus/systemd.py b/supervisor/dbus/systemd.py index 66776a41ede..b9fa04634b4 100644 --- a/supervisor/dbus/systemd.py +++ b/supervisor/dbus/systemd.py @@ -31,6 +31,7 @@ DBUS_SIGNAL_PROPERTIES_CHANGED, StartUnitMode, StopUnitMode, + SystemState, UnitActiveState, ) from .interface import DBusInterface, DBusInterfaceProxy, dbus_property @@ -205,6 +206,11 @@ async def list_units( """Return a list of available systemd services.""" return await self.connected_dbus.Manager.call("list_units") + @dbus_connected + async def get_system_state(self) -> SystemState: + """Return the systemd manager state.""" + return SystemState(await self.connected_dbus.Manager.get("system_state")) + @dbus_connected async def start_transient_unit( self, unit: str, mode: StartUnitMode, properties: list[tuple[str, Variant]] diff --git a/supervisor/host/control.py b/supervisor/host/control.py index 0fa91042bb9..1c9a47ac9fe 100644 --- a/supervisor/host/control.py +++ b/supervisor/host/control.py @@ -1,5 +1,6 @@ """Power control for host.""" +import asyncio from datetime import datetime import logging @@ -7,14 +8,20 @@ from ..const import HostFeature from ..coresys import CoreSysAttributes +from ..dbus.const import StartUnitMode, UnitActiveState from ..exceptions import ( DBusInvalidArgsError, + DBusSystemdNoSuchUnit, + HassioError, HostInvalidHostnameError, HostNotSupportedError, ) _LOGGER: logging.Logger = logging.getLogger(__name__) +HAOS_PRE_SHUTDOWN_TARGET = "haos-pre-shutdown.target" +HAOS_PRE_SHUTDOWN_TIMEOUT = 10 + class SystemControl(CoreSysAttributes): """Handle host power controls.""" @@ -38,6 +45,28 @@ def _check_dbus(self, flag: HostFeature) -> None: f"No {flag!s} D-Bus connection available", _LOGGER.error ) + async def _enter_haos_pre_shutdown(self) -> None: + """Enter the HAOS pre-shutdown systemd phase if available.""" + if not self.sys_dbus.systemd.is_connected: + return + + try: + _LOGGER.info("Entering Home Assistant OS pre-shutdown phase") + await self.sys_dbus.systemd.start_unit( + HAOS_PRE_SHUTDOWN_TARGET, StartUnitMode.REPLACE + ) + unit = await self.sys_dbus.systemd.get_unit(HAOS_PRE_SHUTDOWN_TARGET) + async with asyncio.timeout(HAOS_PRE_SHUTDOWN_TIMEOUT): + await unit.wait_for_active_state({UnitActiveState.ACTIVE}) + except DBusSystemdNoSuchUnit: + _LOGGER.debug("Home Assistant OS pre-shutdown target is not available") + except TimeoutError: + _LOGGER.warning("Timed out entering Home Assistant OS pre-shutdown phase") + except HassioError as err: + _LOGGER.warning( + "Could not enter Home Assistant OS pre-shutdown phase: %s", err + ) + async def reboot(self) -> None: """Reboot host system.""" self._check_dbus(HostFeature.REBOOT) @@ -47,7 +76,13 @@ async def reboot(self) -> None: "Initialize host reboot using %s", "logind" if use_logind else "systemd" ) + # Stop Home Assistant Core, add-ons and plugins before requesting the + # reboot. Doing it here (rather than relying only on the SIGTERM + # handler during host shutdown) keeps UI-triggered reboots fully + # graceful on every OS version, including ones whose systemd units + # give Supervisor only a short stop timeout. try: + await self._enter_haos_pre_shutdown() await self.sys_core.shutdown() finally: if use_logind: @@ -64,7 +99,13 @@ async def shutdown(self) -> None: "Initialize host power off %s", "logind" if use_logind else "systemd" ) + # Stop Home Assistant Core, add-ons and plugins before requesting the + # power off. Doing it here (rather than relying only on the SIGTERM + # handler during host shutdown) keeps UI-triggered shutdowns fully + # graceful on every OS version, including ones whose systemd units + # give Supervisor only a short stop timeout. try: + await self._enter_haos_pre_shutdown() await self.sys_core.shutdown() finally: if use_logind: diff --git a/tests/dbus/test_systemd.py b/tests/dbus/test_systemd.py index 6489d0e95c2..801cba33524 100644 --- a/tests/dbus/test_systemd.py +++ b/tests/dbus/test_systemd.py @@ -5,7 +5,12 @@ from dbus_fast.aio.message_bus import MessageBus import pytest -from supervisor.dbus.const import StartUnitMode, StopUnitMode, UnitActiveState +from supervisor.dbus.const import ( + StartUnitMode, + StopUnitMode, + SystemState, + UnitActiveState, +) from supervisor.dbus.systemd import Systemd from supervisor.exceptions import DBusNotConnectedError, DBusSystemdNoSuchUnit @@ -30,6 +35,7 @@ async def test_dbus_systemd_info(dbus_session_bus: MessageBus): assert systemd.boot_timestamp == 1632236713344227 assert systemd.startup_time == 45.304696 + assert await systemd.get_system_state() == SystemState.RUNNING async def test_subscribe_on_connect( diff --git a/tests/dbus_service_mocks/systemd.py b/tests/dbus_service_mocks/systemd.py index 4ebb3f03564..9e77d57deb8 100644 --- a/tests/dbus_service_mocks/systemd.py +++ b/tests/dbus_service_mocks/systemd.py @@ -28,6 +28,7 @@ class Systemd(DBusServiceMock): reboot_watchdog_usec = 600000000 kexec_watchdog_usec = 0 service_watchdogs = True + system_state = "running" virtualization = "" response_get_unit: ( dict[str, list[str | DBusError]] | list[str | DBusError] | str | DBusError @@ -401,7 +402,7 @@ def ControlGroup(self) -> "s": @dbus_property(access=PropertyAccess.READ) def SystemState(self) -> "s": """Get SystemState.""" - return "running" + return self.system_state @dbus_property(access=PropertyAccess.READ) def ExitCode(self) -> "y":