Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion supervisor/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

# pylint: disable=wrong-import-position
from supervisor import bootstrap # noqa: E402
from supervisor.dbus.const import SystemState # noqa: E402
from supervisor.exceptions import HassioError # noqa: E402
from supervisor.utils.blockbuster import BlockBusterManager # noqa: E402
from supervisor.utils.logging import activate_log_queue_handler # noqa: E402

Expand Down Expand Up @@ -68,14 +70,43 @@ def run_os_startup_check_cleanup() -> None:

# Create startup task that can be cancelled gracefully
startup_task = loop.create_task(coresys.core.start())
shutdown_tasks: list[asyncio.Task] = []

async def host_is_shutting_down() -> bool:
"""Return True if systemd is shutting the host down.

This relies only on the systemd manager state, which is exposed by
every systemd version, so it works regardless of the running HAOS
release (no dependency on OS-side units being present).
"""
if not coresys.dbus.systemd.is_connected:
return False

try:
return await coresys.dbus.systemd.get_system_state() == SystemState.STOPPING
except HassioError as err:
_LOGGER.warning("Could not read systemd manager state: %s", err)
return False

async def stop_supervisor() -> None:
"""Stop Supervisor, including managed services during host shutdown."""
try:
if await host_is_shutting_down():
_LOGGER.info("Host shutdown detected, shutting down managed services")
await coresys.core.shutdown()
finally:
await coresys.core.stop()

def shutdown_handler() -> None:
"""Handle shutdown signals gracefully during startup."""
if not startup_task.done():
_LOGGER.warning("Supervisor startup interrupted by shutdown signal")
startup_task.cancel()

coresys.create_task(coresys.core.stop())
if shutdown_tasks and not shutdown_tasks[0].done():
return

shutdown_tasks[:] = [coresys.create_task(stop_supervisor())]

bootstrap.register_signal_handlers(loop, shutdown_handler)

Expand Down
11 changes: 11 additions & 0 deletions supervisor/dbus/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ class StartUnitMode(StrEnum):
ISOLATE = "isolate"


class SystemState(DBusStrEnum):
"""State of the systemd manager."""

INITIALIZING = "initializing"
STARTING = "starting"
RUNNING = "running"
DEGRADED = "degraded"
MAINTENANCE = "maintenance"
STOPPING = "stopping"


class UnitActiveState(DBusStrEnum):
"""Active state of a systemd unit."""

Expand Down
6 changes: 6 additions & 0 deletions supervisor/dbus/systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
DBUS_SIGNAL_PROPERTIES_CHANGED,
StartUnitMode,
StopUnitMode,
SystemState,
UnitActiveState,
)
from .interface import DBusInterface, DBusInterfaceProxy, dbus_property
Expand Down Expand Up @@ -205,6 +206,11 @@ async def list_units(
"""Return a list of available systemd services."""
return await self.connected_dbus.Manager.call("list_units")

@dbus_connected
async def get_system_state(self) -> SystemState:
"""Return the systemd manager state."""
return SystemState(await self.connected_dbus.Manager.get("system_state"))

@dbus_connected
async def start_transient_unit(
self, unit: str, mode: StartUnitMode, properties: list[tuple[str, Variant]]
Expand Down
41 changes: 41 additions & 0 deletions supervisor/host/control.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
"""Power control for host."""

import asyncio
from datetime import datetime
import logging

from awesomeversion import AwesomeVersion

from ..const import HostFeature
from ..coresys import CoreSysAttributes
from ..dbus.const import StartUnitMode, UnitActiveState
from ..exceptions import (
DBusInvalidArgsError,
DBusSystemdNoSuchUnit,
HassioError,
HostInvalidHostnameError,
HostNotSupportedError,
)

_LOGGER: logging.Logger = logging.getLogger(__name__)

HAOS_PRE_SHUTDOWN_TARGET = "haos-pre-shutdown.target"
HAOS_PRE_SHUTDOWN_TIMEOUT = 10


class SystemControl(CoreSysAttributes):
"""Handle host power controls."""
Expand All @@ -38,6 +45,28 @@ def _check_dbus(self, flag: HostFeature) -> None:
f"No {flag!s} D-Bus connection available", _LOGGER.error
)

async def _enter_haos_pre_shutdown(self) -> None:
"""Enter the HAOS pre-shutdown systemd phase if available."""
if not self.sys_dbus.systemd.is_connected:
return

try:
_LOGGER.info("Entering Home Assistant OS pre-shutdown phase")
await self.sys_dbus.systemd.start_unit(
HAOS_PRE_SHUTDOWN_TARGET, StartUnitMode.REPLACE
)
unit = await self.sys_dbus.systemd.get_unit(HAOS_PRE_SHUTDOWN_TARGET)
async with asyncio.timeout(HAOS_PRE_SHUTDOWN_TIMEOUT):
await unit.wait_for_active_state({UnitActiveState.ACTIVE})
except DBusSystemdNoSuchUnit:
_LOGGER.debug("Home Assistant OS pre-shutdown target is not available")
except TimeoutError:
_LOGGER.warning("Timed out entering Home Assistant OS pre-shutdown phase")
except HassioError as err:
_LOGGER.warning(
"Could not enter Home Assistant OS pre-shutdown phase: %s", err
)

async def reboot(self) -> None:
"""Reboot host system."""
self._check_dbus(HostFeature.REBOOT)
Expand All @@ -47,7 +76,13 @@ async def reboot(self) -> None:
"Initialize host reboot using %s", "logind" if use_logind else "systemd"
)

# Stop Home Assistant Core, add-ons and plugins before requesting the
# reboot. Doing it here (rather than relying only on the SIGTERM
# handler during host shutdown) keeps UI-triggered reboots fully
# graceful on every OS version, including ones whose systemd units
# give Supervisor only a short stop timeout.
try:
await self._enter_haos_pre_shutdown()
await self.sys_core.shutdown()
finally:
if use_logind:
Expand All @@ -64,7 +99,13 @@ async def shutdown(self) -> None:
"Initialize host power off %s", "logind" if use_logind else "systemd"
)

# Stop Home Assistant Core, add-ons and plugins before requesting the
# power off. Doing it here (rather than relying only on the SIGTERM
# handler during host shutdown) keeps UI-triggered shutdowns fully
# graceful on every OS version, including ones whose systemd units
# give Supervisor only a short stop timeout.
try:
await self._enter_haos_pre_shutdown()
await self.sys_core.shutdown()
finally:
if use_logind:
Expand Down
8 changes: 7 additions & 1 deletion tests/dbus/test_systemd.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
import pytest

from supervisor.dbus.const import StartUnitMode, StopUnitMode, UnitActiveState
from supervisor.dbus.const import (
StartUnitMode,
StopUnitMode,
SystemState,
UnitActiveState,
)
from supervisor.dbus.systemd import Systemd
from supervisor.exceptions import DBusNotConnectedError, DBusSystemdNoSuchUnit

Expand All @@ -30,6 +35,7 @@ async def test_dbus_systemd_info(dbus_session_bus: MessageBus):

assert systemd.boot_timestamp == 1632236713344227
assert systemd.startup_time == 45.304696
assert await systemd.get_system_state() == SystemState.RUNNING


async def test_subscribe_on_connect(
Expand Down
3 changes: 2 additions & 1 deletion tests/dbus_service_mocks/systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Systemd(DBusServiceMock):
reboot_watchdog_usec = 600000000
kexec_watchdog_usec = 0
service_watchdogs = True
system_state = "running"
virtualization = ""
response_get_unit: (
dict[str, list[str | DBusError]] | list[str | DBusError] | str | DBusError
Expand Down Expand Up @@ -401,7 +402,7 @@ def ControlGroup(self) -> "s":
@dbus_property(access=PropertyAccess.READ)
def SystemState(self) -> "s":
"""Get SystemState."""
return "running"
return self.system_state

@dbus_property(access=PropertyAccess.READ)
def ExitCode(self) -> "y":
Expand Down