diff --git a/supervisor/core.py b/supervisor/core.py index 0f77bd3b9..121d21681 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -340,6 +340,7 @@ class Core(CoreSysAttributes): self.sys_websession.close(), self.sys_ingress.unload(), self.sys_hardware.unload(), + self.sys_host.unload(), self.sys_dbus.unload(), ) ] diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index 8e49cc3f7..e31dc5228 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -48,6 +48,9 @@ DBUS_IFACE_UDISKS2_MANAGER = "org.freedesktop.UDisks2.Manager" DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED = ( "org.freedesktop.NetworkManager.Connection.Active.StateChanged" ) +DBUS_SIGNAL_LOGIND_PREPARE_FOR_SHUTDOWN = ( + "org.freedesktop.login1.Manager.PrepareForShutdown" +) DBUS_SIGNAL_PROPERTIES_CHANGED = "org.freedesktop.DBus.Properties.PropertiesChanged" DBUS_SIGNAL_RAUC_INSTALLER_COMPLETED = "de.pengutronix.rauc.Installer.Completed" diff --git a/supervisor/dbus/logind.py b/supervisor/dbus/logind.py index c3a1834f4..b62113f5e 100644 --- a/supervisor/dbus/logind.py +++ b/supervisor/dbus/logind.py @@ -5,7 +5,12 @@ import logging from dbus_fast.aio.message_bus import MessageBus from ..exceptions import DBusError, DBusInterfaceError, DBusServiceUnkownError -from .const import DBUS_NAME_LOGIND, DBUS_OBJECT_LOGIND +from ..utils.dbus import DBusSignalWrapper +from .const import ( + DBUS_NAME_LOGIND, + DBUS_OBJECT_LOGIND, + DBUS_SIGNAL_LOGIND_PREPARE_FOR_SHUTDOWN, +) from .interface import DBusInterface from .utils import dbus_connected @@ -29,7 +34,7 @@ class Logind(DBusInterface): await super().connect(bus) except DBusError: _LOGGER.warning("Can't connect to systemd-logind") - except (DBusServiceUnkownError, DBusInterfaceError): + except DBusServiceUnkownError, DBusInterfaceError: _LOGGER.warning("No systemd-logind support on the host.") @dbus_connected @@ -41,3 +46,13 @@ class Logind(DBusInterface): async def power_off(self) -> None: """Power off host computer.""" await self.connected_dbus.Manager.call("power_off", False) + + @dbus_connected + async def inhibit(self, what: str, who: str, why: str, mode: str) -> int: + """Take an inhibitor lock. Returns a file descriptor.""" + return await self.connected_dbus.Manager.call("inhibit", what, who, why, mode) + + @dbus_connected + def prepare_for_shutdown(self) -> DBusSignalWrapper: + """Return a signal wrapper for PrepareForShutdown signal.""" + return self.connected_dbus.signal(DBUS_SIGNAL_LOGIND_PREPARE_FOR_SHUTDOWN) diff --git a/supervisor/dbus/manager.py b/supervisor/dbus/manager.py index 201165f28..ed417e315 100644 --- a/supervisor/dbus/manager.py +++ b/supervisor/dbus/manager.py @@ -123,7 +123,7 @@ class DBusManager(CoreSysAttributes): try: self._bus = connected_bus = await MessageBus( - bus_type=BusType.SYSTEM + bus_type=BusType.SYSTEM, negotiate_unix_fd=True ).connect() except Exception as err: raise DBusFatalError( diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index a8a94f5b9..35d45d974 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -1,15 +1,17 @@ """Host function like audio, D-Bus or systemd.""" +import asyncio from contextlib import suppress from functools import lru_cache import logging +import os from typing import Self from awesomeversion import AwesomeVersion -from ..const import BusEvent +from ..const import BusEvent, CoreState from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import HassioError, HostLogError, PulseAudioError +from ..exceptions import DBusError, HassioError, HostLogError, PulseAudioError from ..hardware.const import PolicyGroup from ..hardware.data import Device from .apparmor import AppArmorControl @@ -38,6 +40,7 @@ class HostManager(CoreSysAttributes): self._network: NetworkManager = NetworkManager(coresys) self._sound: SoundControl = SoundControl(coresys) self._logs: LogsControl = LogsControl(coresys) + self._shutdown_monitor_task: asyncio.Task | None = None async def post_init(self) -> Self: """Post init actions that must occur in event loop.""" @@ -180,6 +183,71 @@ class HostManager(CoreSysAttributes): except HassioError as err: _LOGGER.warning("Loading host AppArmor on start failed: %s", err) + # Start monitoring for host shutdown signals (ACPI power button, etc.) + if self.sys_dbus.logind.is_connected: + self._shutdown_monitor_task = self.sys_create_task( + self._monitor_host_shutdown() + ) + + async def unload(self) -> None: + """Shutdown host manager and cancel background tasks.""" + if self._shutdown_monitor_task and not self._shutdown_monitor_task.done(): + self._shutdown_monitor_task.cancel() + with suppress(asyncio.CancelledError): + await self._shutdown_monitor_task + self._shutdown_monitor_task = None + + async def _monitor_host_shutdown(self) -> None: + """Monitor for host shutdown via logind PrepareForShutdown signal. + + Takes an inhibitor lock to delay shutdown while we gracefully stop + all running services. When PrepareForShutdown fires, runs the graceful + shutdown sequence and then releases the lock so the host can proceed. + """ + try: + inhibit_fd: int = await self.sys_dbus.logind.inhibit( + "shutdown", + "Home Assistant Supervisor", + "Gracefully stopping running services", + "delay", + ) + except DBusError as err: + _LOGGER.warning( + "Could not take shutdown inhibitor lock from logind: %s", err + ) + return + + _LOGGER.info("Shutdown inhibitor lock acquired from logind") + + try: + async with self.sys_dbus.logind.prepare_for_shutdown() as signal: + while True: + msg = await signal.wait_for_signal() + active = msg[0] + if not active: + continue + + # Only handle if Supervisor didn't initiate the shutdown + if self.sys_core.state != CoreState.RUNNING: + _LOGGER.debug( + "PrepareForShutdown received but already in state %s", + self.sys_core.state, + ) + break + + _LOGGER.info( + "Host shutdown/reboot detected, gracefully stopping services" + ) + await self.sys_core.shutdown() + break + except (DBusError, OSError) as err: + _LOGGER.warning("Error monitoring host shutdown signal: %s", err) + finally: + if isinstance(inhibit_fd, int): + with suppress(OSError): + await self.sys_run_in_executor(os.close, inhibit_fd) + _LOGGER.info("Shutdown inhibitor lock released") + async def _hardware_events(self, device: Device) -> None: """Process hardware requests.""" if self.sys_hardware.policy.is_match_cgroup(PolicyGroup.AUDIO, device): diff --git a/tests/dbus/test_login.py b/tests/dbus/test_login.py index bada0476b..405419222 100644 --- a/tests/dbus/test_login.py +++ b/tests/dbus/test_login.py @@ -45,6 +45,37 @@ async def test_power_off(logind_service: LogindService, dbus_session_bus: Messag assert logind_service.PowerOff.calls == [(False,)] +async def test_inhibit(logind_service: LogindService, dbus_session_bus: MessageBus): + """Test taking an inhibitor lock.""" + logind_service.Inhibit.calls.clear() + logind = Logind() + + with pytest.raises(DBusNotConnectedError): + await logind.inhibit("shutdown", "test", "testing", "delay") + + await logind.connect(dbus_session_bus) + + await logind.inhibit("shutdown", "Test", "Testing inhibit", "delay") + assert logind_service.Inhibit.calls == [ + ("shutdown", "Test", "Testing inhibit", "delay") + ] + + +async def test_prepare_for_shutdown_signal( + logind_service: LogindService, dbus_session_bus: MessageBus +): + """Test PrepareForShutdown signal.""" + logind = Logind() + await logind.connect(dbus_session_bus) + + async with logind.prepare_for_shutdown() as signal: + logind_service.PrepareForShutdown() + await logind_service.ping() + + msg = await signal.wait_for_signal() + assert msg == [True] + + async def test_dbus_logind_connect_error( dbus_session_bus: MessageBus, caplog: pytest.LogCaptureFixture ): diff --git a/tests/dbus_service_mocks/logind.py b/tests/dbus_service_mocks/logind.py index fbd27f362..d7082f281 100644 --- a/tests/dbus_service_mocks/logind.py +++ b/tests/dbus_service_mocks/logind.py @@ -1,6 +1,10 @@ """Mock of logind dbus service.""" +import os +import tempfile + from dbus_fast import DBusError +from dbus_fast.service import signal from .base import DBusServiceMock, dbus_method @@ -31,3 +35,15 @@ class Logind(DBusServiceMock): @dbus_method() def PowerOff(self, interactive: "b") -> None: """PowerOff.""" + + @dbus_method() + def Inhibit(self, what: "s", who: "s", why: "s", mode: "s") -> "h": + """Take an inhibitor lock. Returns a file descriptor.""" + fd, path = tempfile.mkstemp() + os.unlink(path) + return fd + + @signal() + def PrepareForShutdown(self) -> "b": + """Signal prepare for shutdown.""" + return True diff --git a/tests/host/test_manager.py b/tests/host/test_manager.py index b43963838..87d2f8033 100644 --- a/tests/host/test_manager.py +++ b/tests/host/test_manager.py @@ -1,18 +1,29 @@ """Test host manager.""" -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock, patch from awesomeversion import AwesomeVersion import pytest +from supervisor.const import CoreState from supervisor.coresys import CoreSys from supervisor.dbus.const import MulticastProtocolEnabled from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.logind import Logind as LogindService from tests.dbus_service_mocks.rauc import Rauc as RaucService from tests.dbus_service_mocks.systemd import Systemd as SystemdService +@pytest.fixture(name="logind_service") +async def fixture_logind_service( + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +) -> LogindService: + """Return logind service mock.""" + yield all_dbus_services["logind"] + + @pytest.fixture(name="systemd_service") async def fixture_systemd_service( all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], @@ -71,3 +82,49 @@ async def test_reload_os( await coresys.host.reload() assert rauc_service.GetSlotStatus.calls == [()] + + +async def test_host_shutdown_on_prepare_for_shutdown_signal( + coresys: CoreSys, logind_service: LogindService +): + """Test graceful shutdown when PrepareForShutdown signal is received.""" + shutdown_called = asyncio.Event() + + async def mock_shutdown(**kwargs): + shutdown_called.set() + + await coresys.host.load() + await coresys.core.set_state(CoreState.RUNNING) + + # Give the monitor task time to start and register the signal listener + # (needs multiple yields for inhibit D-Bus call + AddMatch call) + await asyncio.sleep(0.1) + + with patch.object(coresys.core, "shutdown", side_effect=mock_shutdown): + # Emit PrepareForShutdown(true) signal as if host is shutting down + logind_service.PrepareForShutdown() + await logind_service.ping() + + async with asyncio.timeout(2): + await shutdown_called.wait() + + +async def test_host_shutdown_signal_ignored_when_not_running( + coresys: CoreSys, logind_service: LogindService +): + """Test PrepareForShutdown is ignored if Supervisor already shutting down.""" + await coresys.host.load() + await coresys.core.set_state(CoreState.SHUTDOWN) + + # Give the monitor task time to start and register the signal listener + await asyncio.sleep(0.1) + + with patch.object( + coresys.core, "shutdown", new_callable=AsyncMock + ) as mock_shutdown: + logind_service.PrepareForShutdown() + await logind_service.ping() + # Give the monitor task time to process the signal + await asyncio.sleep(0.1) + + mock_shutdown.assert_not_called()