mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-04-02 08:12:47 +01:00
Take shutdown inhibitor lock for graceful service teardown
Handle external shutdown events (ACPI power button, hypervisor shutdown) by listening to logind's PrepareForShutdown signal. A delay inhibitor lock is acquired on startup so Supervisor has time to gracefully stop all managed services before the host proceeds with shutdown. Changes: - Add inhibit() and prepare_for_shutdown() methods to Logind D-Bus interface - Enable Unix FD negotiation on the D-Bus message bus for inhibitor lock FDs - Add background monitor task in HostManager that listens for the signal - Track the monitor task and cancel it cleanly via new unload() method - Wire host unload into Core.stop() stage 2 for clean shutdown - Add PrepareForShutdown signal constant to dbus/const.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user