Skip to content
64 changes: 49 additions & 15 deletions supervisor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def __init__(self, coresys: CoreSys) -> None:
self.coresys: CoreSys = coresys
self._state: CoreState = CoreState.INITIALIZE
self.exit_code: int = 0
self._shutdown_event: asyncio.Event = asyncio.Event()
self._startup_complete: asyncio.Event = asyncio.Event()

@property
def state(self) -> CoreState:
Expand Down Expand Up @@ -82,6 +84,9 @@ async def set_state(self, new_state: CoreState) -> None:
self._state = new_state
await self._write_run_state()

if self._state == CoreState.RUNNING:
self._startup_complete.set()

# Don't attempt to notify anyone on CLOSE as we're about to stop the event loop
if self._state != CoreState.CLOSE:
self.sys_bus.fire_event(BusEvent.SUPERVISOR_STATE_CHANGE, self._state)
Expand Down Expand Up @@ -314,6 +319,9 @@ async def stop(self) -> None:
# don't process scheduler anymore
await self.set_state(CoreState.STOPPING)

# Cancel shutdown monitor task before tearing down infrastructure
await self.sys_host.unload()

# Stage 1
try:
async with asyncio.timeout(10):
Expand Down Expand Up @@ -352,28 +360,54 @@ async def stop(self) -> None:
self.sys_loop.stop()

async def shutdown(self, *, remove_homeassistant_container: bool = False) -> None:
"""Shutdown all running containers in correct order."""
"""Shutdown all running containers in correct order.

Reentrant: if a shutdown is already in progress, subsequent calls
await completion of the existing shutdown rather than starting a second one.
"""
if self.state in STARTING_STATES:
_LOGGER.debug(
"Shutdown requested while Supervisor is still starting up, waiting for startup to complete"
)
await self._startup_complete.wait()

# Supervisor is already tearing down, no point running shutdown
if self.state in (CoreState.STOPPING, CoreState.CLOSE):
_LOGGER.warning("Ignoring shutdown request, Supervisor is already stopping")
return

# Another shutdown is in progress, wait for it to complete
if self.state == CoreState.SHUTDOWN:
await self._shutdown_event.wait()
return

# Reset event for this shutdown cycle (supports repeated use, e.g. backup restore)
self._shutdown_event.clear()

# don't process scheduler anymore
if self.state == CoreState.RUNNING:
await self.set_state(CoreState.SHUTDOWN)

# Shutdown Application Add-ons, using Home Assistant API
await self.sys_addons.shutdown(AddonStartup.APPLICATION)
try:
# Shutdown Application Add-ons, using Home Assistant API
await self.sys_addons.shutdown(AddonStartup.APPLICATION)

# Close Home Assistant
with suppress(HassioError):
await self.sys_homeassistant.core.stop(
remove_container=remove_homeassistant_container
)
# Close Home Assistant
with suppress(HassioError):
await self.sys_homeassistant.core.stop(
remove_container=remove_homeassistant_container
)

# Shutdown System Add-ons
await self.sys_addons.shutdown(AddonStartup.SERVICES)
await self.sys_addons.shutdown(AddonStartup.SYSTEM)
await self.sys_addons.shutdown(AddonStartup.INITIALIZE)
# Shutdown System Add-ons
await self.sys_addons.shutdown(AddonStartup.SERVICES)
await self.sys_addons.shutdown(AddonStartup.SYSTEM)
await self.sys_addons.shutdown(AddonStartup.INITIALIZE)

# Shutdown all Plugins
if self.state in (CoreState.STOPPING, CoreState.SHUTDOWN):
await self.sys_plugins.shutdown()
# Shutdown all Plugins
if self.state in (CoreState.STOPPING, CoreState.SHUTDOWN):
await self.sys_plugins.shutdown()
finally:
self._shutdown_event.set()

async def _update_last_boot(self) -> None:
"""Update last boot time."""
Expand Down
3 changes: 3 additions & 0 deletions supervisor/dbus/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
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"

Expand Down
19 changes: 17 additions & 2 deletions supervisor/dbus/logind.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
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

Expand All @@ -29,7 +34,7 @@ async def connect(self, bus: MessageBus):
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
Expand All @@ -41,3 +46,13 @@ async def reboot(self) -> None:
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)
2 changes: 1 addition & 1 deletion supervisor/dbus/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async def load(self) -> None:

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(
Expand Down
68 changes: 67 additions & 1 deletion supervisor/host/manager.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
"""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 ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HassioError, HostLogError, PulseAudioError
from ..exceptions import (
DBusError,
DBusNotConnectedError,
HassioError,
HostLogError,
PulseAudioError,
)
from ..hardware.const import PolicyGroup
from ..hardware.data import Device
from .apparmor import AppArmorControl
Expand Down Expand Up @@ -38,6 +46,7 @@ def __init__(self, coresys: CoreSys):
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."""
Expand Down Expand Up @@ -180,6 +189,63 @@ async def load(self):
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, DBusNotConnectedError) 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

_LOGGER.info(
"Host shutdown/reboot detected, gracefully stopping services"
)
await self.sys_core.shutdown()
break
except (DBusError, DBusNotConnectedError, 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):
Expand Down
35 changes: 35 additions & 0 deletions tests/dbus/test_login.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Test login dbus interface."""

# pylint: disable=import-error
import os

from dbus_fast.aio.message_bus import MessageBus
import pytest

Expand Down Expand Up @@ -45,6 +47,39 @@ 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)

fd = await logind.inhibit("shutdown", "Test", "Testing inhibit", "delay")
assert logind_service.Inhibit.calls == [
("shutdown", "Test", "Testing inhibit", "delay")
]
if fd is not None:
os.close(fd)


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
):
Expand Down
16 changes: 16 additions & 0 deletions tests/dbus_service_mocks/logind.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -31,3 +35,15 @@ def Reboot(self, interactive: "b") -> None:
@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
Loading
Loading