1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-05-08 17:08:36 +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
+58 -1
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()