1
0
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:
Stefan Agner
2026-03-13 15:46:12 +01:00
parent f41a8e9d08
commit bb4525bf3f
8 changed files with 197 additions and 6 deletions

View File

@@ -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(),
)
]

View File

@@ -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"

View File

@@ -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)

View File

@@ -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(

View File

@@ -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):

View File

@@ -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
):

View File

@@ -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

View File

@@ -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()