1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-07-09 14:53:45 +01:00
Files
supervisor/supervisor/core.py
Stefan Agner 0ac8b42062 Rework Supervisor connectivity check with coalescing and force flag (#6765)
* Rework Supervisor connectivity check with coalescing and force flag

Previously, a failed connectivity probe could strand Supervisor in a
"no connectivity" state indefinitely. After an Ethernet reconnect, a
probe kicked by NetworkManager's connectivity transition could race
with CoreDNS being restarted (due to DNS locals changing), time out on
DNS, and leave supervisor.connectivity = False. The retry that
_on_dns_container_running was meant to fire landed inside the 5 s
JobThrottle window from the just-failed probe and was silently dropped,
since JobThrottle.THROTTLE drops rather than waits.

The rework replaces the @Job(throttle=THROTTLE) decorator and the
public connectivity setter with a single authoritative state-updating
method:

- check_and_update_connectivity(force=False) is the only path that
  runs the HTTP probe and updates the cached state. Concurrent callers
  coalesce onto a single in-flight probe. A min-interval throttle
  lives inside the method and reuses the cached result within window
  instead of dropping calls.
- request_connectivity_check(force=False) is a fire-and-forget wrapper
  for signal handlers (D-Bus, plugin callbacks) that must return
  quickly without blocking signal dispatch on the HTTP round-trip.
- force=True bypasses the min-interval and, when a probe is in flight,
  sets a trailing-rerun flag so the owning task runs one more probe
  after the current one completes. Used for signals that carry fresh
  state-change information (NM connectivity transition to FULL, DNS
  container RUNNING, startup, post-NTP sync).
- _update_connectivity is the sole writer of the cached flag and
  emits SUPERVISOR_CONNECTIVITY_CHANGE only on actual transitions.

Call sites migrate accordingly. The opportunistic
supervisor.connectivity = False writes in update_apparmor,
updater.fetch_data, os.manager, and addon_pwned error paths are
replaced with request_connectivity_check() calls so the probe remains
authoritative - an endpoint-specific failure no longer lies about the
overall connectivity state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Propagate connectivity-probe cancellation and skip last-check on cancel

Awaiting an asyncio.Task does not propagate cancellation INTO the task,
so the previous owner-doesn't-shield comment was misleading: a cancelled
owner left the spawned probe running orphaned, and the next caller could
start a second probe alongside it. The owner now explicitly cancels and
awaits the probe on CancelledError before re-raising.

The last-check timestamp is also moved out of the finally block so a
cancelled probe does not leave a "fresh result just ran" cache behind
that would short-circuit the next non-forced caller.

A regression test exercises both: that owner cancellation clears the
in-flight reference and leaves the timestamp untouched, and that a
subsequent non-forced check therefore still actually probes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Clarify why post-NTP-sync forces a connectivity probe

The previous comment claimed the last-check timestamp may be unreliable
after a time jump, but _connectivity_last_check uses loop.time() which
is monotonic and unaffected by wall-clock corrections. The real reason
to force a fresh probe is TLS validation: certificates that appeared
expired or not-yet-valid before the system clock was corrected may now
verify, so a probe that just failed with an SSL error can succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add debug logs to Supervisor connectivity probe paths

The original stuck-offline bug was hard to spot in logs because the
silent throttle-drop and the cached state had no audit trail. With
debug-level logging at each decision point, a future investigation can
reconstruct from a single log file:

- who requested a check (force flag distinguishes signal-driven probes
  from precondition / opportunistic-error-path requests)
- why a probe did not actually run (in-flight coalesce, cached within
  min-interval, owner cancellation)
- when a forced rerun was queued and when it ran (the precise failure
  mode that stranded the supervisor in the original incident)
- when the cached state actually flipped (with the previous value in
  the message so transitions are visible)

All new lines are debug-level. The existing _do_connectivity_check
"failed" / "succeeded" lines are kept unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Skip system-checks fan-out in test_events_on_issue_changes

The test asserts that apply_suggestion fires an ISSUE_REMOVED event.
ISSUE_REMOVED is fired by dismiss_issue inside FixupBase.__call__, before
apply_suggestion calls healthcheck. The healthcheck call afterwards is
incidental to this test's intent, but it fans out into check_system()
which runs CheckDNSServer (A and AAAA) - real aiodns query_dns() probes
against the NetworkManager mock's stub nameserver 192.168.30.1 that each
hit the default ~10 s aiodns timeout. The file took ~21 s to run.

The slowness has been latent since #3818 (Aug 2022), which added the
apply_suggestion step at the end of test_events_on_issue_changes two
days after the DNS check landed in its current form (#3811). The default
24 h JobThrottle on CheckDNSServer.run_check tends to mask the cost in
full-suite runs once any earlier test has tripped the throttle, which is
likely why this slipped through.

Mock coresys.resolution.healthcheck for just this one apply_suggestion
call rather than introducing a file-wide DNS mock. The patch is local to
the slow call site and the test's assertion is unaffected. The file
drops from ~21 s to ~2.5 s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:14:13 +02:00

494 lines
18 KiB
Python

"""Main file for Supervisor."""
import asyncio
from collections.abc import Awaitable
from contextlib import suppress
from datetime import timedelta
import logging
from typing import Self
from .const import (
ATTR_STARTUP,
RUN_SUPERVISOR_STATE,
STARTING_STATES,
AppStartup,
BusEvent,
CoreState,
)
from .coresys import CoreSys, CoreSysAttributes
from .dbus.const import StopUnitMode, UnitActiveState
from .exceptions import (
AppFileReadError,
HassioError,
HomeAssistantCrashError,
HomeAssistantError,
SupervisorUpdateError,
WhoamiError,
WhoamiSSLError,
)
from .homeassistant.core import LANDINGPAGE
from .resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from .utils.dt import utcnow
from .utils.sentry import async_capture_exception
from .utils.whoami import retrieve_whoami
_LOGGER: logging.Logger = logging.getLogger(__name__)
class Core(CoreSysAttributes):
"""Main object of Supervisor."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize Supervisor object."""
self.coresys: CoreSys = coresys
self._state: CoreState = CoreState.INITIALIZE
self.exit_code: int = 0
@property
def state(self) -> CoreState:
"""Return state of the core."""
return self._state
@property
def supported(self) -> bool:
"""Return true if the installation is supported."""
return len(self.sys_resolution.unsupported) == 0
@property
def healthy(self) -> bool:
"""Return true if the installation is healthy."""
return len(self.sys_resolution.unhealthy) == 0
async def _write_run_state(self):
"""Write run state for s6 service supervisor."""
try:
await self.sys_run_in_executor(
RUN_SUPERVISOR_STATE.write_text, str(self._state), encoding="utf-8"
)
except OSError as err:
_LOGGER.warning(
"Can't update the Supervisor state to %s: %s", self._state, err
)
async def post_init(self) -> Self:
"""Post init actions that must be done in event loop."""
await self._write_run_state()
return self
async def set_state(self, new_state: CoreState) -> None:
"""Set core into new state."""
if self._state == new_state:
return
self._state = new_state
await self._write_run_state()
# 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)
# These will be received by HA after startup has completed which won't make sense
if self._state not in STARTING_STATES:
self.sys_homeassistant.websocket.supervisor_update_event(
"info", {"state": self._state}
)
async def connect(self) -> None:
"""Connect Supervisor container."""
# Load information from container
await self.sys_supervisor.load()
# Evaluate the system
await self.sys_resolution.evaluate.evaluate_system()
# Check supervisor version/update
if self.sys_config.version == self.sys_supervisor.version:
return
# Somethings going wrong
_LOGGER.error(
"Update '%s' of Supervisor '%s' failed!",
self.sys_config.version,
self.sys_supervisor.version,
)
if self.sys_supervisor.need_update:
self.sys_resolution.create_issue(
IssueType.UPDATE_ROLLBACK, ContextType.SUPERVISOR
)
self.sys_resolution.add_unhealthy_reason(UnhealthyReason.SUPERVISOR)
# Fix wrong version in config / avoid boot loop on OS
self.sys_config.version = self.sys_supervisor.version
await self.sys_config.save_data()
async def setup(self) -> None:
"""Start setting up supervisor orchestration."""
await self.set_state(CoreState.SETUP)
# Initialize websession early. At this point we'll use the Docker DNS proxy
# at 127.0.0.11, which does not have the fallback feature and hence might
# fail in certain environments. But a websession is required to get the
# initial version information after a device wipe or otherwise empty state
# (e.g. CI environment, Supervised).
#
# An OS installation has the plug-in container images pre-installed, so we
# setup can continue even if this early websession fails to connect to the
# internet. We'll reinitialize the websession when the DNS plug-in is up to
# make sure the DNS plug-in along with its fallback capabilities is used
# (see #5857).
await self.coresys.init_websession()
# Check internet on startup
await self.sys_supervisor.check_and_update_connectivity(force=True)
# Order can be important!
setup_loads: list[Awaitable[None]] = [
# rest api views
self.sys_api.load(),
# Load Host Hardware
self.sys_hardware.load(),
# Load DBus
self.sys_dbus.load(),
# Load Host
self.sys_host.load(),
# Load HassOS
self.sys_os.load(),
# Adjust timezone / time settings
self._adjust_system_datetime(),
# Load mounts
self.sys_mounts.load(),
# Load Docker manager
self.sys_docker.load(),
# load last available data
self.sys_updater.load(),
# Load Plugins container
self.sys_plugins.load(),
# Load Home Assistant
self.sys_homeassistant.load(),
# Load CPU/Arch
self.sys_arch.load(),
# Load Stores
self.sys_store.load(),
# Load Apps
self.sys_apps.load(),
# load last available data
self.sys_backups.load(),
# load services
self.sys_services.load(),
# Load discovery
self.sys_discovery.load(),
# Load ingress
self.sys_ingress.load(),
# Load Resoulution
self.sys_resolution.load(),
]
# Execute each load task in secure context
for setup_task in setup_loads:
try:
await setup_task
except AppFileReadError as err:
# Already reported to the user via the resolution system
# (unhealthy reason set by check_oserror). Log without
# stack trace and skip Sentry capture to avoid noise.
_LOGGER.error(
"Error on load Task %s: %s",
setup_task,
err,
)
self.sys_resolution.add_unhealthy_reason(UnhealthyReason.SETUP)
except Exception as err: # pylint: disable=broad-except
_LOGGER.critical(
"Fatal error happening on load Task %s: %s",
setup_task,
err,
exc_info=True,
)
self.sys_resolution.add_unhealthy_reason(UnhealthyReason.SETUP)
await async_capture_exception(err)
async def start(self) -> None:
"""Start Supervisor orchestration."""
await self.set_state(CoreState.STARTUP)
# Set OS Agent diagnostics if needed
if (
self.sys_dbus.agent.is_connected
and self.sys_config.diagnostics is not None
and self.sys_dbus.agent.diagnostics != self.sys_config.diagnostics
and self.supported
):
_LOGGER.debug("Set OS Agent diagnostics to %s", self.sys_config.diagnostics)
await self.sys_dbus.agent.set_diagnostics(self.sys_config.diagnostics)
# Check if system is healthy
if not self.supported:
_LOGGER.warning("System running in a unsupported environment!")
if not self.healthy:
_LOGGER.critical(
"System is running in an unhealthy state and needs manual intervention!"
)
# Mark booted partition as healthy
await self.sys_os.mark_healthy()
# Refresh update information
await self.sys_updater.reload()
# On release channel, try update itself if auto update enabled
if self.sys_supervisor.need_update and self.sys_updater.auto_update:
if not self.healthy:
_LOGGER.warning("Ignoring Supervisor updates!")
else:
with suppress(SupervisorUpdateError):
await self.sys_supervisor.update()
return
try:
# Start app mark as initialize
await self.sys_apps.boot(AppStartup.INITIALIZE)
# HomeAssistant is already running, only Supervisor restarted
if await self.sys_hardware.helper.last_boot() == self.sys_config.last_boot:
_LOGGER.info("Detected Supervisor restart")
return
# reset register services / discovery
await self.sys_services.reset()
# start app mark as system
await self.sys_apps.boot(AppStartup.SYSTEM)
# start app mark as services
await self.sys_apps.boot(AppStartup.SERVICES)
# run HomeAssistant
if (
self.sys_homeassistant.boot
and not await self.sys_homeassistant.core.is_running()
):
_LOGGER.info("Start Home Assistant Core")
try:
await self.sys_homeassistant.core.start()
except HomeAssistantCrashError as err:
_LOGGER.error("Can't start Home Assistant Core - rebuiling")
await async_capture_exception(err)
with suppress(HomeAssistantError):
await self.sys_homeassistant.core.rebuild()
except HomeAssistantError as err:
await async_capture_exception(err)
else:
_LOGGER.info("Skipping start of Home Assistant")
# Core is not running
if self.sys_homeassistant.core.error_state:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.CORE,
suggestions=[SuggestionType.EXECUTE_REPAIR],
)
# start app mark as application
await self.sys_apps.boot(AppStartup.APPLICATION)
# store new last boot
await self._update_last_boot()
finally:
# Add core tasks into scheduler
await self.sys_tasks.load()
# If landingpage / run upgrade in background
if self.sys_homeassistant.version == LANDINGPAGE:
self.sys_create_task(self.sys_homeassistant.core.install())
# Upate Host/Deivce information
self.sys_create_task(self.sys_host.reload())
self.sys_create_task(self.sys_resolution.healthcheck())
await self.set_state(CoreState.RUNNING)
self.sys_homeassistant.websocket.supervisor_update_event(
"supervisor", {ATTR_STARTUP: "complete"}
)
_LOGGER.info("Supervisor is up and running")
async def stop(self) -> None:
"""Stop a running orchestration."""
# store new last boot / prevent time adjustments
if self.state in (CoreState.RUNNING, CoreState.SHUTDOWN):
await self._update_last_boot()
if self.state in (CoreState.STOPPING, CoreState.CLOSE):
return
# don't process scheduler anymore
await self.set_state(CoreState.STOPPING)
# Stage 1
try:
async with asyncio.timeout(10):
await asyncio.wait(
[
self.sys_create_task(coro)
for coro in (
self.sys_api.stop(),
self.sys_scheduler.shutdown(),
self.sys_docker.unload(),
)
]
)
except TimeoutError:
_LOGGER.warning("Stage 1: Force Shutdown!")
# Stage 2
try:
async with asyncio.timeout(10):
await asyncio.wait(
[
self.sys_create_task(coro)
for coro in (
self.sys_websession.close(),
self.sys_homeassistant.api.close(),
self.sys_ingress.unload(),
self.sys_hardware.unload(),
self.sys_dbus.unload(),
)
]
)
except TimeoutError:
_LOGGER.warning("Stage 2: Force Shutdown!")
await self.set_state(CoreState.CLOSE)
_LOGGER.info("Supervisor is down - %d", self.exit_code)
self.sys_loop.stop()
async def shutdown(self, *, remove_homeassistant_container: bool = False) -> None:
"""Shutdown all running containers in correct order."""
# don't process scheduler anymore
if self.state == CoreState.RUNNING:
await self.set_state(CoreState.SHUTDOWN)
# Shutdown Application Apps, using Home Assistant API
await self.sys_apps.shutdown(AppStartup.APPLICATION)
# Close Home Assistant
with suppress(HassioError):
await self.sys_homeassistant.core.stop(
remove_container=remove_homeassistant_container
)
# Shutdown System Apps
await self.sys_apps.shutdown(AppStartup.SERVICES)
await self.sys_apps.shutdown(AppStartup.SYSTEM)
await self.sys_apps.shutdown(AppStartup.INITIALIZE)
# Shutdown all Plugins
if self.state in (CoreState.STOPPING, CoreState.SHUTDOWN):
await self.sys_plugins.shutdown()
async def _update_last_boot(self) -> None:
"""Update last boot time."""
if not (last_boot := await self.sys_hardware.helper.last_boot()):
_LOGGER.error("Could not update last boot information!")
return
self.sys_config.last_boot = last_boot
await self.sys_config.save_data()
async def _adjust_system_datetime(self) -> None:
"""Adjust system time/date on startup."""
# Ensure host system timezone matches supervisor timezone configuration
if (
self.sys_config.timezone
and self.sys_host.info.timezone != self.sys_config.timezone
and self.sys_dbus.timedate.is_connected
):
_LOGGER.info(
"Timezone in Supervisor config '%s' differs from host '%s'",
self.sys_config.timezone,
self.sys_host.info.timezone,
)
await self.sys_host.control.set_timezone(self.sys_config.timezone)
# If no timezone is detect or set
# If we are not connected or time sync
if (
self.sys_config.timezone
or self.sys_host.info.timezone not in ("Etc/UTC", None)
) and self.sys_host.info.dt_synchronized:
return
# Get Timezone data
try:
try:
data = await retrieve_whoami(self.sys_websession, True)
except WhoamiSSLError:
# SSL Date Issue & possible time drift
_LOGGER.info("Whoami service SSL error")
data = await retrieve_whoami(self.sys_websession, False)
except WhoamiError as err:
_LOGGER.warning("Can't adjust Time/Date settings: %s", err)
return
timezone = self.sys_config.timezone or data.timezone
await self.sys_config.set_timezone(timezone)
await self.sys_host.control.set_timezone(timezone)
# Calculate if system time is out of sync
delta = abs(data.dt_utc - utcnow())
if delta <= timedelta(hours=1) or self.sys_host.info.dt_synchronized:
return
_LOGGER.warning("System time/date shift over more than 1 hour detected!")
if self.sys_host.info.use_ntp:
# Stop timesyncd if NTP is enabled, as set_time is blocked while it runs.
# timedated rejects set_time while an NTP unit is active. We listen
# for the unit's ActiveState to become inactive before proceeding.
_LOGGER.info("Stopping systemd-timesyncd to allow manual time adjustment")
timesync_unit = await self.sys_dbus.systemd.get_unit(
"systemd-timesyncd.service"
)
try:
async with asyncio.timeout(10):
await self.sys_dbus.systemd.stop_unit(
"systemd-timesyncd.service", StopUnitMode.REPLACE
)
await timesync_unit.wait_for_active_state(
{UnitActiveState.INACTIVE}
)
except TimeoutError:
_LOGGER.warning(
"Timeout waiting for systemd-timesyncd to stop, "
"attempting time sync anyway"
)
# Create a repair issue so the user knows NTP was disabled
self.sys_resolution.create_issue(
IssueType.NTP_SYNC_FAILED,
ContextType.SYSTEM,
suggestions=[SuggestionType.ENABLE_NTP],
)
await self.sys_host.control.set_datetime(data.dt_utc)
# System time was just corrected. TLS certificates that previously
# appeared expired/not-yet-valid may now verify, so a connectivity
# probe that just failed for that reason can succeed now.
await self.sys_supervisor.check_and_update_connectivity(force=True)
async def repair(self) -> None:
"""Repair system integrity."""
_LOGGER.info("Starting repair of Supervisor Environment")
await self.sys_docker.repair()
# Fix plugins
await self.sys_plugins.repair()
# Restore core functionality
await self.sys_apps.repair()
await self.sys_homeassistant.core.repair()
# Tag version for latest
await self.sys_supervisor.repair()
_LOGGER.info("Finished repair of Supervisor Environment")