1764 lines
83 KiB
Python
1764 lines
83 KiB
Python
"""Run the ha-mcp FastMCP server in-process inside Home Assistant (issue #1527).
|
|
|
|
The :class:`EmbeddedServerManager` owns the full lifecycle of the in-process
|
|
ha-mcp server:
|
|
|
|
* ensures the ``ha-mcp`` package is importable (runtime pip install via
|
|
Home Assistant's requirements manager, honoring an options-flow pip-spec
|
|
override for pre-release testing, and forcing a real reinstall when that spec
|
|
changes),
|
|
* provisions a long-lived Home Assistant admin token the server uses to reach HA
|
|
core over loopback (REST + WebSocket),
|
|
* runs the server on a dedicated thread with its own asyncio loop — uvicorn
|
|
skips signal capture off the main thread and a heavy tool can never stall HA's
|
|
event loop — and
|
|
* tears the thread down cleanly, and revokes the provisioned credentials when
|
|
the entry is removed.
|
|
|
|
Everything the server needs from ha-mcp is imported **inside the worker thread**,
|
|
after the required non-secret environment variables are staged, so importing this
|
|
module never pulls in ``ha_mcp`` (which may not be installed yet) and never runs
|
|
before the connection is in place. The loopback URL and the admin token are handed
|
|
to ha-mcp **in memory** via ``ha_mcp.config.set_embedded_connection`` — never
|
|
through ``os.environ`` — so the admin token can never be read from the shared HA
|
|
process environment. ``ha_mcp`` module-level imports are therefore forbidden here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import importlib.metadata
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from contextlib import suppress
|
|
from datetime import timedelta
|
|
from functools import partial
|
|
from typing import TYPE_CHECKING, Literal
|
|
|
|
from homeassistant.auth.const import GROUP_ID_ADMIN
|
|
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
|
from homeassistant.const import __version__ as HA_VERSION
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.requirements import (
|
|
RequirementsNotFound,
|
|
async_process_requirements,
|
|
pip_kwargs,
|
|
)
|
|
from homeassistant.util.package import install_package
|
|
from packaging.requirements import InvalidRequirement, Requirement
|
|
from packaging.utils import canonicalize_name
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
from .const import (
|
|
CHANNEL_DEV,
|
|
DATA_ACCESS_TOKEN,
|
|
DATA_LAST_PIP_SPEC,
|
|
DATA_PENDING_INSTALL_VERSION,
|
|
DATA_REFRESH_TOKEN_ID,
|
|
DATA_SECRET_PATH,
|
|
DATA_SERVER_USER_ID,
|
|
DEFAULT_AUTO_UPDATE,
|
|
DEFAULT_BIND_HOST,
|
|
DEFAULT_CHANNEL,
|
|
DEFAULT_LOOPBACK_URL,
|
|
DEFAULT_PIP_SPEC,
|
|
DEFAULT_SERVER_PORT,
|
|
DIST_NAME_DEV,
|
|
DIST_NAME_STABLE,
|
|
DOMAIN,
|
|
MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
|
|
OPT_AUTO_UPDATE,
|
|
OPT_BIND_HOST,
|
|
OPT_CHANNEL,
|
|
OPT_PIP_SPEC,
|
|
OPT_SERVER_PORT,
|
|
OPT_SERVER_URL,
|
|
SERVER_CONFIG_SUBDIR,
|
|
SERVER_TOKEN_CLIENT_NAME,
|
|
SERVER_USER_NAME,
|
|
dist_for_channel,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Access-token longevity for the provisioned long-lived token. HA caps nothing
|
|
# here; ten years is effectively "for the life of the install" and is refreshed
|
|
# from the same refresh token on every start regardless.
|
|
_ACCESS_TOKEN_TTL = timedelta(days=3650)
|
|
|
|
# Readiness probe: fail the bring-up only when there is no observable startup
|
|
# progress (no new modules landing in sys.modules, no phase advance) for this
|
|
# long. The previous flat 90s deadline assumed real hardware imports the
|
|
# server tree in seconds — issue #1904 (HA Green) showed a cold import alone
|
|
# can take minutes there, and killing the still-importing worker at the
|
|
# deadline is what created the orphaned-thread/port-collision cascade: the
|
|
# worker cannot be joined mid-import, lingered as a zombie, and later bound
|
|
# the port out from under the retry. The module count is process-wide (see
|
|
# _progress_signature), so a wedged worker trips the stall budget on a quiet
|
|
# instance and the absolute cap below at the latest.
|
|
_READY_STALL_TIMEOUT_SECONDS = 90.0
|
|
# Absolute ceiling on one bring-up regardless of apparent progress, matching
|
|
# the HAOS e2e lane's own 600s readiness deadline.
|
|
_READY_TOTAL_CAP_SECONDS = 600.0
|
|
_READY_POLL_INTERVAL_SECONDS = 0.5
|
|
|
|
# How long to wait for the worker thread to exit on stop before giving up and
|
|
# leaking it rather than blocking HA shutdown.
|
|
_STOP_JOIN_TIMEOUT_SECONDS = 10.0
|
|
|
|
# Per-download HTTP timeout for a forced reinstall. The first install pulls the
|
|
# whole fastmcp tree, well beyond HA's 60s requirements default.
|
|
_PIP_INSTALL_TIMEOUT_SECONDS = 300
|
|
|
|
# Uninstall just removes files/metadata, so it is quick; cap it so a wedged
|
|
# subprocess can never tie up an executor thread indefinitely.
|
|
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
|
|
|
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
|
# previous bring-up before giving up: asyncio cancellation detaches the
|
|
# awaiter but an executor pip job runs to completion regardless. Sized to
|
|
# the same absolute budget as the readiness cap: pip's own timeout (300s)
|
|
# is per-download, so a healthy cold install pulling the whole dependency
|
|
# tree on slow hardware can legitimately run for minutes — a tighter bound
|
|
# would misreport it as stuck. On expiry the bring-up fails with a clear
|
|
# message and the next reload retries.
|
|
_PENDING_INSTALL_WAIT_SECONDS = 600.0
|
|
|
|
# The in-process connection API was added with the embedded server in 7.10.0.
|
|
# Older distributions can be left behind by an unsupported Core version's
|
|
# constraints and must never enter the worker thread.
|
|
MIN_EMBEDDED_SERVER_VERSION = "7.10.0"
|
|
|
|
|
|
def _derive_loopback_url(hass: HomeAssistant) -> tuple[str, bool | None]:
|
|
"""Resolve the loopback base URL for HA core from the http integration.
|
|
|
|
Returns ``(url, verify_ssl)`` where ``verify_ssl`` is ``False`` when the
|
|
URL is ``https`` (HA's certificate is issued for its hostname, never for
|
|
127.0.0.1, so verification on the loopback hop can only fail) and ``None``
|
|
when no override of the server's default is needed.
|
|
|
|
The hardcoded ``http://127.0.0.1:8123`` default this replaces broke every
|
|
instance with ``http.ssl_certificate`` configured (issue #1890): port 8123
|
|
speaks TLS there, so the server's plaintext REST/WS calls died with
|
|
"Server disconnected without sending a response" / "did not receive a
|
|
valid HTTP response" on every tool call — while the MCP handshake and
|
|
tools/list (no HA round-trip) kept working. A custom ``server_port``
|
|
similarly broke the hardcoded port. Both live in ``hass.config.api``,
|
|
set by the ``http`` integration this component depends on; the constant
|
|
remains the fallback if it is ever absent.
|
|
"""
|
|
api = getattr(hass.config, "api", None)
|
|
if api is None:
|
|
# Leave a trail: if this ever fires on a real instance, the resulting
|
|
# failure looks exactly like issue #1890 (TLS loopback broken, MCP
|
|
# handshake fine) and took a live reproduction to diagnose last time.
|
|
_LOGGER.debug(
|
|
"hass.config.api unavailable; using hardcoded loopback default %s",
|
|
DEFAULT_LOOPBACK_URL,
|
|
)
|
|
return DEFAULT_LOOPBACK_URL, None
|
|
# Strict type checks (not coercion / truthiness): a malformed api object
|
|
# must resolve to the plaintext default on port 8123, never to a surprise
|
|
# port or https flip. bool is excluded because it is an int subclass.
|
|
port_raw = getattr(api, "port", None)
|
|
port = (
|
|
port_raw
|
|
if isinstance(port_raw, int)
|
|
and not isinstance(port_raw, bool)
|
|
and 0 < port_raw <= 65535
|
|
else 8123
|
|
)
|
|
if getattr(api, "use_ssl", False) is True:
|
|
return f"https://127.0.0.1:{port}", False
|
|
return f"http://127.0.0.1:{port}", None
|
|
|
|
|
|
class EmbeddedServerError(Exception):
|
|
"""Raised when the in-process ha-mcp server could not be installed or started.
|
|
|
|
``kind`` classifies the failure so the caller can file the matching repair
|
|
issue: ``"package"`` for a pip install / import failure, ``"start"`` for
|
|
everything else (token provisioning, thread crash, readiness timeout).
|
|
"""
|
|
|
|
def __init__(
|
|
self, message: str, *, kind: Literal["package", "start"] = "start"
|
|
) -> None:
|
|
"""Store the message and the failure ``kind`` (``package`` / ``start``)."""
|
|
super().__init__(message)
|
|
self.kind = kind
|
|
|
|
|
|
class EmbeddedServerManager:
|
|
"""Manage the lifecycle of the in-process ha-mcp server for one config entry."""
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Bind the manager to its Home Assistant instance and config entry."""
|
|
self._hass = hass
|
|
self._entry = entry
|
|
|
|
options = entry.options
|
|
self._port: int = int(options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT))
|
|
self._bind_host: str = str(options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST))
|
|
# An explicit server_url override wins verbatim (the operator manages
|
|
# scheme/verification themselves via the settings UI). A stored value
|
|
# equal to DEFAULT_LOOPBACK_URL is treated as no-override: the options
|
|
# form used to pre-fill that constant as suggested_value, so existing
|
|
# entries carry it without the user ever having chosen it.
|
|
_url_override = str(options.get(OPT_SERVER_URL) or "").rstrip("/")
|
|
self._loopback_verify_ssl: bool | None = None
|
|
if _url_override and _url_override != DEFAULT_LOOPBACK_URL:
|
|
self._server_url: str = _url_override
|
|
else:
|
|
self._server_url, self._loopback_verify_ssl = _derive_loopback_url(hass)
|
|
self._channel: str = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
|
# An explicit pip-spec override (the pre-release test channel) wins over
|
|
# the channel selector. DEFAULT_PIP_SPEC in the field means "no override,
|
|
# use the channel" — the value moves with each release, so it must never
|
|
# be treated as an intentional pin (the options flow also normalizes it
|
|
# away on save; this guard keeps legacy/direct entries correct too).
|
|
raw_pip_spec = str(options.get(OPT_PIP_SPEC) or "").strip()
|
|
self._pip_spec_override: str = (
|
|
raw_pip_spec if raw_pip_spec and raw_pip_spec != DEFAULT_PIP_SPEC else ""
|
|
)
|
|
# Auto-update toggle (default on). Off pins a non-override channel to
|
|
# the currently-installed version; the periodic PyPI check keeps
|
|
# running either way (it feeds the update entity — issue #1760), only
|
|
# the automatic reload is gated on this. Read before
|
|
# _resolve_pip_spec, which consults it.
|
|
self._auto_update: bool = bool(
|
|
options.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)
|
|
)
|
|
# Initial spec without the installed-version read (that would block the
|
|
# event loop). For an auto-update-off channel this is the bare dist here;
|
|
# _async_ensure_package re-resolves it with the executor-read version
|
|
# before installing.
|
|
self._pip_spec: str = self._resolve_pip_spec()
|
|
self._secret_path: str = str(entry.data.get(DATA_SECRET_PATH, ""))
|
|
self._config_dir: str = hass.config.path(SERVER_CONFIG_SUBDIR)
|
|
|
|
# Worker-thread state. ``_loop`` and ``_stop_event`` are created in the
|
|
# thread before its loop runs, so a stop request can always reach them.
|
|
self._thread: threading.Thread | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._stop_event: asyncio.Event | None = None
|
|
self._thread_exc: BaseException | None = None
|
|
# A worker that refused to die within the stop-join timeout (e.g.
|
|
# wedged in a slow cold import). Tracked so the next start can skip
|
|
# the module purge while it might still be importing.
|
|
self._orphaned_thread: threading.Thread | None = None
|
|
# Version reported by the CURRENT worker thread (stashed by _serve).
|
|
# Compared against the installed distribution after start to detect a
|
|
# stale-code worker (see _purge_ha_mcp_modules).
|
|
self._running_version: str | None = None
|
|
# Startup phase marker (plain attribute writes: init markers from the
|
|
# main thread, _note_startup_phase transitions from the worker). Read
|
|
# by the readiness poll for progress detection and error messages.
|
|
self._startup_phase: str = "not started"
|
|
|
|
@property
|
|
def port(self) -> int:
|
|
"""TCP port the server listens on."""
|
|
return self._port
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
"""Return True while the worker thread is alive."""
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
# -- lifecycle ---------------------------------------------------------
|
|
|
|
async def async_start(self) -> None:
|
|
"""Install the package, provision a token, and start the server thread.
|
|
|
|
Raises :class:`EmbeddedServerError` on any failure. The caller is
|
|
responsible for surfacing a repair issue — a failed start must never take
|
|
the rest of Home Assistant down with it.
|
|
"""
|
|
if not self._secret_path:
|
|
raise EmbeddedServerError(
|
|
"Server secret path missing from the config entry; "
|
|
"reload the integration to regenerate it."
|
|
)
|
|
try:
|
|
home_assistant_version = Version(HA_VERSION)
|
|
except InvalidVersion as err:
|
|
raise EmbeddedServerError(
|
|
f"The in-process server could not determine whether Home "
|
|
f"Assistant {HA_VERSION} satisfies its minimum requirement of "
|
|
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION}. Install a standard Home "
|
|
"Assistant release before reloading this integration.",
|
|
kind="package",
|
|
) from err
|
|
if home_assistant_version < Version(MIN_EMBEDDED_HOME_ASSISTANT_VERSION):
|
|
raise EmbeddedServerError(
|
|
f"The in-process server requires Home Assistant "
|
|
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer; this instance "
|
|
f"is running {HA_VERSION}. Update Home Assistant before "
|
|
"reloading this integration.",
|
|
kind="package",
|
|
)
|
|
|
|
# Read the importer registry BEFORE the package step too: replacing
|
|
# the distribution's files on disk under a live importer corrupts it
|
|
# exactly like the sys.modules purge does (review finding), so a
|
|
# mutating install is deferred while any registered worker is alive.
|
|
ready_version = await self._async_ensure_package(
|
|
defer_mutations=_prune_and_check_importing_workers()
|
|
)
|
|
access_token = await self._async_provision_token()
|
|
await self._hass.async_add_executor_job(self._prepare_config_dir)
|
|
|
|
self._maybe_purge_stale_modules(ready_version)
|
|
|
|
self._thread_exc = None
|
|
self._startup_phase = "waiting for the worker thread"
|
|
self._thread = threading.Thread(
|
|
target=self._thread_main,
|
|
args=(access_token,),
|
|
name="ha-mcp-server",
|
|
daemon=True,
|
|
)
|
|
# Registered by THIS (main) thread before start(): every bring-up
|
|
# runs its gate → register → start section synchronously on the one
|
|
# event loop, so another bring-up's purge gate can never interleave
|
|
# with a worker that exists but has not yet registered itself
|
|
# (review finding). The worker itself only ever deregisters.
|
|
with _IMPORTING_WORKERS_LOCK:
|
|
_IMPORTING_WORKERS.add(self._thread)
|
|
try:
|
|
self._thread.start()
|
|
except BaseException:
|
|
with _IMPORTING_WORKERS_LOCK:
|
|
_IMPORTING_WORKERS.discard(self._thread)
|
|
raise
|
|
|
|
await self._async_wait_until_ready()
|
|
|
|
# Belt-and-braces staleness check: the worker stashed the version
|
|
# reported by the package it imported; if that disagrees with the
|
|
# installed distribution the purge did not fully take (e.g. a stray
|
|
# import of ha_mcp outside the worker re-cached old modules) and only an
|
|
# HA core restart applies the update — say so instead of serving old
|
|
# code silently.
|
|
if self._running_version:
|
|
installed = await self._hass.async_add_executor_job(
|
|
_installed_ha_mcp_version, dist_for_channel(self._channel)
|
|
)
|
|
if installed and installed != self._running_version:
|
|
_LOGGER.warning(
|
|
"HA-MCP in-process server is running version %s but "
|
|
"version %s is installed; restart Home Assistant to "
|
|
"finish applying the update.",
|
|
self._running_version,
|
|
installed,
|
|
)
|
|
|
|
async def async_stop(self) -> None:
|
|
"""Signal the worker thread to shut down and join it (bounded).
|
|
|
|
Never blocks Home Assistant shutdown indefinitely: if the thread does not
|
|
exit within the timeout it is logged and left to die with the process.
|
|
Does NOT revoke the provisioned token — that is reserved for
|
|
:meth:`async_revoke_credentials` (entry removal) so a reload keeps
|
|
working.
|
|
"""
|
|
thread = self._thread
|
|
if thread is None:
|
|
return
|
|
|
|
loop = self._loop
|
|
stop_event = self._stop_event
|
|
if loop is not None and stop_event is not None and not loop.is_closed():
|
|
try:
|
|
loop.call_soon_threadsafe(stop_event.set)
|
|
except RuntimeError:
|
|
# The worker's loop closed between the is_closed() check and
|
|
# this call (thread already exiting) - join below handles it.
|
|
pass
|
|
|
|
await self._hass.async_add_executor_job(thread.join, _STOP_JOIN_TIMEOUT_SECONDS)
|
|
if thread.is_alive():
|
|
_LOGGER.warning(
|
|
"HA-MCP in-process server thread did not stop within %.0fs; "
|
|
"leaving it to terminate with the process.",
|
|
_STOP_JOIN_TIMEOUT_SECONDS,
|
|
)
|
|
# Remember the zombie: the next start must not purge modules
|
|
# while this thread may still be importing them. The worker
|
|
# holds its own loop/stop-event as locals, so clearing the
|
|
# published references below cannot crash it.
|
|
self._orphaned_thread = thread
|
|
self._thread = None
|
|
self._loop = None
|
|
self._stop_event = None
|
|
self._thread_exc = None
|
|
self._running_version = None
|
|
|
|
async def async_revoke_credentials(self) -> None:
|
|
"""Revoke the provisioned refresh token and remove the server's user.
|
|
|
|
Called when the config entry is removed. Best-effort and idempotent:
|
|
missing ids / already-deleted objects are treated as success.
|
|
"""
|
|
rt_id = self._entry.data.get(DATA_REFRESH_TOKEN_ID)
|
|
user_id = self._entry.data.get(DATA_SERVER_USER_ID)
|
|
|
|
if rt_id:
|
|
refresh_token = self._hass.auth.async_get_refresh_token(rt_id)
|
|
if refresh_token is not None:
|
|
self._hass.auth.async_remove_refresh_token(refresh_token)
|
|
|
|
if user_id:
|
|
user = await self._hass.auth.async_get_user(user_id)
|
|
if user is not None:
|
|
await self._hass.auth.async_remove_user(user)
|
|
|
|
remaining = {
|
|
k: v
|
|
for k, v in self._entry.data.items()
|
|
if k not in (DATA_SERVER_USER_ID, DATA_REFRESH_TOKEN_ID, DATA_ACCESS_TOKEN)
|
|
}
|
|
if remaining != dict(self._entry.data):
|
|
self._hass.config_entries.async_update_entry(self._entry, data=remaining)
|
|
|
|
# -- package install ---------------------------------------------------
|
|
|
|
def _resolve_pip_spec(self, installed_version: str | None = None) -> str:
|
|
"""Return the effective pip requirement for the configured channel.
|
|
|
|
An explicit override wins (any pip requirement string — a version pin, a
|
|
GitHub tarball URL — the pre-release test channel). Otherwise the channel
|
|
picks the distribution (``dev`` → ``ha-mcp-dev``, ``stable`` → ``ha-mcp``):
|
|
|
|
* auto-update ON (default): the bare, unpinned distribution name, so the
|
|
newest build of the channel resolves at install time.
|
|
* auto-update OFF: the distribution pinned to ``installed_version``
|
|
(``dist==X``), so reloads/restarts keep that exact version; falls back
|
|
to the unpinned name when ``installed_version`` is None (nothing
|
|
installed yet — first setup has no version to pin to, so it installs
|
|
the newest once).
|
|
|
|
``installed_version`` is passed in (never read here) so this stays a pure,
|
|
non-blocking function: the ``importlib.metadata`` read that discovers it
|
|
happens on the executor in :meth:`_async_ensure_package`, off the loop.
|
|
"""
|
|
if self._pip_spec_override:
|
|
return self._pip_spec_override
|
|
dist = dist_for_channel(self._channel)
|
|
if not self._auto_update and installed_version is not None:
|
|
return f"{dist}=={installed_version}"
|
|
return dist
|
|
|
|
def _conflicting_dist_name(self) -> str | None:
|
|
"""Return the other channel's distribution name, or None to skip.
|
|
|
|
``dev`` installs ``ha-mcp-dev`` (conflicts with ``ha-mcp``) and ``stable``
|
|
installs ``ha-mcp`` (conflicts with ``ha-mcp-dev``). Returns None for an
|
|
explicit override, whose distribution name is unknown so nothing is
|
|
removed.
|
|
"""
|
|
if self._pip_spec_override:
|
|
return None
|
|
return DIST_NAME_STABLE if self._channel == CHANNEL_DEV else DIST_NAME_DEV
|
|
|
|
def _maybe_purge_stale_modules(self, ready_version: str | None) -> None:
|
|
"""Purge cached ha_mcp modules unless doing so is unsafe or pointless.
|
|
|
|
The purge makes the next worker import the code that is on disk NOW.
|
|
Without it, a reload after a pip install keeps serving the OLD code
|
|
forever: all workers are threads of the one HA core process, and
|
|
Python resolves ``import ha_mcp`` from sys.modules — installs only
|
|
took effect after a full HA core restart (observed live: options
|
|
saves reinstalled the package, the web UI footer showed the new
|
|
on-disk version, yet the serving worker kept reporting the version
|
|
it was first imported with).
|
|
|
|
SKIPPED while any previous worker may still be importing — ripping
|
|
entries out of sys.modules under a live importer corrupts its import
|
|
in progress. Two guards cover that: the per-manager orphan (a worker
|
|
this manager's stop could not join), and the process-global
|
|
``_IMPORTING_WORKERS`` registry, because every bring-up constructs a
|
|
FRESH manager (async_bring_up_server) and an entry reload during a
|
|
slow cold import otherwise hands the purge to a manager that has
|
|
never heard of the still-importing worker — which then crashed
|
|
mid-import with KeyError: 'ha_mcp.config' (issue #1904, live on
|
|
1.1.1-dev.107). The post-start staleness check surfaces the
|
|
consequence (old code possibly serving) instead.
|
|
|
|
Also skipped when the cached modules already ARE the generation on
|
|
disk: purging on every attempt made each retry pay the full cold
|
|
import again, so slow hardware that missed the readiness window once
|
|
could never recover (#1904). Never skipped under a pip-spec override
|
|
— the one workflow where a reinstall can change the code without
|
|
changing the version string (re-pointed tarball/pin), which a
|
|
version-keyed skip would serve stale; channel installs mint a
|
|
distinct version per build.
|
|
"""
|
|
orphan = self._orphaned_thread
|
|
if orphan is not None and not orphan.is_alive():
|
|
self._orphaned_thread = orphan = None
|
|
importing_busy = _prune_and_check_importing_workers()
|
|
if orphan is not None:
|
|
_LOGGER.warning(
|
|
"Skipping the ha_mcp module purge: a previous worker thread "
|
|
"is still shutting down. The new worker may serve the "
|
|
"previously imported code until Home Assistant restarts."
|
|
)
|
|
elif importing_busy:
|
|
# Distinct from the orphan message: that worker is still STARTING
|
|
# (mid cold-import, the #1904 incident shape), not shutting down —
|
|
# naming the actual state matters when reading logs during one.
|
|
_LOGGER.warning(
|
|
"Skipping the ha_mcp module purge: a previous bring-up's "
|
|
"worker thread is still importing. The new worker may serve "
|
|
"the previously imported code until Home Assistant restarts."
|
|
)
|
|
elif (
|
|
not self._pip_spec_override
|
|
and ready_version is not None
|
|
and _CACHED_IMPORT_VERSION is not None
|
|
and ready_version == _CACHED_IMPORT_VERSION
|
|
):
|
|
_LOGGER.debug(
|
|
"Cached ha_mcp modules already match installed version %s; "
|
|
"skipping the module purge (warm start)",
|
|
ready_version,
|
|
)
|
|
else:
|
|
_purge_ha_mcp_modules()
|
|
|
|
async def _async_run_tracked_install_job(
|
|
self, func: Callable[[], object]
|
|
) -> object:
|
|
"""Run a package-mutating executor job, tracked process-wide.
|
|
|
|
Registration happens BEFORE dispatch and completion is signalled on
|
|
the executor thread in a finally, so a bring-up cancelled mid-job
|
|
(install or uninstall) leaves behind a waitable job instead of an
|
|
invisible one.
|
|
"""
|
|
global _PENDING_INSTALL_DONE
|
|
done = threading.Event()
|
|
with _PENDING_INSTALL_LOCK:
|
|
_PENDING_INSTALL_DONE = done
|
|
|
|
def _run() -> object:
|
|
global _PENDING_INSTALL_DONE
|
|
try:
|
|
return func()
|
|
finally:
|
|
done.set()
|
|
with _PENDING_INSTALL_LOCK:
|
|
# A newer job may already have replaced the slot.
|
|
if _PENDING_INSTALL_DONE is done:
|
|
_PENDING_INSTALL_DONE = None
|
|
|
|
try:
|
|
# Shielded: cancelling the awaiter must NOT cancel the executor
|
|
# job. Unshielded, a cancel landing while the job is still
|
|
# QUEUED removes it from the pool — _run never starts, nothing
|
|
# ever sets the event, and the next bring-up waits the full
|
|
# budget on a job that does not exist (review finding). With the
|
|
# shield, _run always runs and its finally always fires; the
|
|
# awaiter still detaches immediately on cancel.
|
|
return await asyncio.shield(self._hass.async_add_executor_job(_run))
|
|
except asyncio.CancelledError:
|
|
# The job is queued or running and its finally will clear the
|
|
# slot; leaving it registered is the whole point — the next
|
|
# bring-up must wait it out.
|
|
raise
|
|
except BaseException:
|
|
# A DISPATCH failure (e.g. executor already shut down) means
|
|
# _run never ran and nothing will ever set the event — clear our
|
|
# own registration or the next bring-up waits the full budget on
|
|
# a job that does not exist. The identity check makes this a
|
|
# no-op if _run did run and already cleaned up.
|
|
with _PENDING_INSTALL_LOCK:
|
|
if _PENDING_INSTALL_DONE is done:
|
|
_PENDING_INSTALL_DONE = None
|
|
raise
|
|
|
|
async def _async_wait_for_pending_install(self) -> None:
|
|
"""Wait out an install job orphaned by a cancelled previous bring-up.
|
|
|
|
Raises :class:`EmbeddedServerError` if it is still running after the
|
|
bounded wait — mutating the package (or importing from it) underneath
|
|
a live pip job is the same corruption class as purging sys.modules
|
|
under a live importer.
|
|
|
|
The wait occupies one pooled executor thread (bounded): the setter
|
|
runs on an executor thread with no handle to this loop, so a
|
|
loop-side wakeup would need cross-thread plumbing this rare recovery
|
|
path does not justify.
|
|
"""
|
|
with _PENDING_INSTALL_LOCK:
|
|
pending = _PENDING_INSTALL_DONE
|
|
if pending is None or pending.is_set():
|
|
return
|
|
_LOGGER.warning(
|
|
"A previous bring-up's install job is still running on the "
|
|
"executor (the bring-up was cancelled but pip cannot be); "
|
|
"waiting for it to finish before touching the ha-mcp package."
|
|
)
|
|
finished = await self._hass.async_add_executor_job(
|
|
pending.wait, _PENDING_INSTALL_WAIT_SECONDS
|
|
)
|
|
if not finished:
|
|
raise EmbeddedServerError(
|
|
f"A previous install job was still running after "
|
|
f"{_PENDING_INSTALL_WAIT_SECONDS:.0f}s; refusing to modify "
|
|
"the ha-mcp package underneath it. Reload the integration "
|
|
"to retry.",
|
|
kind="package",
|
|
)
|
|
|
|
async def _async_ensure_package(
|
|
self, *, defer_mutations: bool = False
|
|
) -> str | None:
|
|
"""Ensure ``ha-mcp`` is importable, installing the pip spec if needed.
|
|
|
|
Returns the installed version that the worker is about to run, for the
|
|
caller's warm-cache purge decision.
|
|
|
|
``defer_mutations=True`` (a previous bring-up's worker is still
|
|
importing) downgrades any would-be uninstall/force-install to the
|
|
non-mutating fast path: replacing the distribution's files on disk
|
|
under a live importer corrupts it the same way a sys.modules purge
|
|
does. The deferred update applies on the next reload or HA restart.
|
|
|
|
With auto-update on (the default) both channels install their
|
|
distribution UNPINNED, so every entry reload / HA restart must pick up
|
|
the newest build. Such a spec ALWAYS takes the force-install path
|
|
(``upgrade=True``, bypassing the requirements manager's is-installed
|
|
shortcut) — that is what makes the channel auto-update. This runs in a
|
|
background task, so it never blocks HA startup, and uv no-ops quickly
|
|
when the newest build is already installed.
|
|
|
|
Fast path: reserved for a STABLE spec — an explicit pip-spec override (a
|
|
version pin or tarball URL) or a channel with auto-update turned OFF
|
|
(which pins to the installed version, see :meth:`_resolve_pip_spec`).
|
|
When that spec matches the one last installed and the package imports,
|
|
delegate the "already satisfied?" decision to Home Assistant's
|
|
requirements manager; a pinned spec does not move, so there is nothing to
|
|
upgrade to. A CHANGED spec (a new override, a cleared override, a
|
|
toggled auto-update, a channel switch) falls through to the
|
|
force-install path below — and additionally uninstalls the replaced
|
|
distribution first (:meth:`_async_remove_replaced_source`), because
|
|
``upgrade=True`` alone decides by version and a changed SOURCE can keep
|
|
the version string (issue #1914).
|
|
|
|
On a channel switch the other channel's distribution is uninstalled first
|
|
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
|
share the ``ha_mcp`` import package, so leaving both installed would make a
|
|
pinned reinstall a no-op (breaking a dev→stable downgrade) and the reported
|
|
version ambiguous.
|
|
|
|
A one-shot pending-install marker (:data:`DATA_PENDING_INSTALL_VERSION`,
|
|
set by the update entity's Install button — issue #1760) overrides both
|
|
the unpinned-channel and auto-update-off pinning above for this single
|
|
install, regardless of the ``auto_update`` option.
|
|
|
|
Never imports ``ha_mcp`` in this (main) process — that happens only inside
|
|
the worker thread.
|
|
"""
|
|
await self._async_wait_for_pending_install()
|
|
|
|
stored_spec = self._entry.data.get(DATA_LAST_PIP_SPEC)
|
|
installed_version = await self._hass.async_add_executor_job(
|
|
_installed_ha_mcp_version
|
|
)
|
|
|
|
pending_version = self._pending_install_version(defer_mutations)
|
|
target_dist = dist_for_channel(self._channel)
|
|
if not self._pip_spec_override and pending_version:
|
|
# Pin to the requested version. Its own value differs from
|
|
# stored_spec below (that is the whole point of the marker), which
|
|
# already forces the force-install branch further down — no
|
|
# separate fast-path handling needed here.
|
|
#
|
|
# Consumed HERE, before the install attempt: one-shot means one
|
|
# ATTEMPT, not "until it succeeds". If it were cleared only on
|
|
# success, a marker for a failing version would re-pin every later
|
|
# reload — including the periodic auto-update ones — to that same
|
|
# broken version, looping the failure forever while auto-update
|
|
# looks on (review finding).
|
|
self._pip_spec = f"{target_dist}=={pending_version}"
|
|
self._clear_pending_install_marker()
|
|
elif not self._pip_spec_override and not self._auto_update:
|
|
# Re-pin an auto-update-off channel to its TARGET distribution's
|
|
# installed version, read off-loop (the __init__ value was the bare
|
|
# dist to avoid a blocking read on the event loop). Reading the
|
|
# target dist specifically — not whichever dist happens to be
|
|
# present — keeps a cross-channel switch correct: the previous
|
|
# channel's dist is still installed at this point (removal happens
|
|
# below), so a whichever-present read would pin the new dist to a
|
|
# version that does not exist for it and fail the install. Nothing
|
|
# of the target channel installed yet => None => stays unpinned and
|
|
# installs the newest once.
|
|
pin_version = await self._hass.async_add_executor_job(
|
|
_installed_dist_version, target_dist
|
|
)
|
|
if pin_version is not None and not _is_compatible_embedded_version(
|
|
pin_version
|
|
):
|
|
_LOGGER.warning(
|
|
"Ignoring auto-update pin to legacy %s %s; the in-process "
|
|
"server requires %s or newer",
|
|
target_dist,
|
|
pin_version,
|
|
MIN_EMBEDDED_SERVER_VERSION,
|
|
)
|
|
self._pip_spec = target_dist
|
|
else:
|
|
self._pip_spec = self._resolve_pip_spec(pin_version)
|
|
|
|
# A "stable" spec (an explicit override, or a channel pinned because
|
|
# auto-update is off) is eligible for the fast path; an unpinned
|
|
# auto-updating channel never is.
|
|
spec_is_stable = bool(self._pip_spec_override) or not self._auto_update
|
|
fast_path_ok = (
|
|
spec_is_stable
|
|
and stored_spec == self._pip_spec
|
|
and installed_version is not None
|
|
and _is_compatible_embedded_version(installed_version)
|
|
)
|
|
deferred = False
|
|
if fast_path_ok:
|
|
await self._async_process_requirements_fast()
|
|
elif defer_mutations:
|
|
deferred = True
|
|
await self._async_defer_package_mutations(installed_version)
|
|
else:
|
|
await self._async_remove_conflicting_dist()
|
|
await self._async_remove_legacy_target(target_dist, installed_version)
|
|
await self._async_remove_replaced_source(stored_spec, installed_version)
|
|
await self._async_force_install()
|
|
|
|
version: str | None
|
|
if not self._pip_spec_override and self._channel == CHANNEL_DEV:
|
|
version = await self._hass.async_add_executor_job(
|
|
_installed_ha_mcp_version, target_dist
|
|
)
|
|
else:
|
|
version = await self._hass.async_add_executor_job(_installed_ha_mcp_version)
|
|
if version is None:
|
|
raise EmbeddedServerError(
|
|
f"Installed the server requirement ({self._pip_spec!r}) but the "
|
|
"'ha-mcp' package is still not importable.",
|
|
kind="package",
|
|
)
|
|
if not _is_compatible_embedded_version(version):
|
|
raise EmbeddedServerError(
|
|
f"The installer left installed ha-mcp {version}, but this "
|
|
f"in-process component requires {MIN_EMBEDDED_SERVER_VERSION} "
|
|
"or newer. Review resolver details logged under "
|
|
"homeassistant.util.package, correct the package conflict, and "
|
|
"reload this integration.",
|
|
kind="package",
|
|
)
|
|
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
|
# A DEFERRED spec change must not be recorded as installed: with the
|
|
# stored spec advanced, the next reload would see "unchanged", take the
|
|
# fast path (for a stable spec) and skip the replaced-source uninstall,
|
|
# so the deferred change would silently never apply.
|
|
if not deferred and stored_spec != self._pip_spec:
|
|
self._store_installed_spec()
|
|
return version
|
|
|
|
async def _async_remove_legacy_target(
|
|
self, target_dist: str, installed_version: str | None
|
|
) -> None:
|
|
"""Remove an incompatible target distribution before reinstalling it."""
|
|
if installed_version is None or _is_compatible_embedded_version(
|
|
installed_version
|
|
):
|
|
return
|
|
target_installed_version = await self._hass.async_add_executor_job(
|
|
_installed_dist_version, target_dist
|
|
)
|
|
if target_installed_version is None or _is_compatible_embedded_version(
|
|
target_installed_version
|
|
):
|
|
return
|
|
_LOGGER.warning(
|
|
"Removing legacy %s %s before installing %r; the in-process "
|
|
"server requires %s or newer",
|
|
target_dist,
|
|
target_installed_version,
|
|
self._pip_spec,
|
|
MIN_EMBEDDED_SERVER_VERSION,
|
|
)
|
|
await self._async_remove_distribution(target_dist)
|
|
|
|
def _pending_install_version(self, defer_mutations: bool) -> str:
|
|
"""Return the update entity's pending-install version, or ``""``.
|
|
|
|
Always empty while mutations are deferred, leaving the marker in
|
|
``entry.data`` untouched: the deferred branch runs no install, and
|
|
consuming the marker without an attempt would lose the user's Install
|
|
click entirely (with auto-update off, the next reload re-pins to the
|
|
OLD installed version). One-shot means one real ATTEMPT — a deferred
|
|
bring-up never attempts, so the marker survives to the next
|
|
undeferred reload (review finding on #1923).
|
|
"""
|
|
if defer_mutations:
|
|
return ""
|
|
return str(self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or "").strip()
|
|
|
|
async def _async_defer_package_mutations(
|
|
self, installed_version: str | None
|
|
) -> None:
|
|
"""Handle the defer-mutations branch of :meth:`_async_ensure_package`.
|
|
|
|
A previous bring-up's worker is still importing, so the package files
|
|
must not be replaced under it. With an importable build on disk
|
|
(``installed_version`` non-None — importability only, compatibility is
|
|
checked by the caller afterwards) nothing is touched at all — not even
|
|
the requirements manager, which installs any unsatisfied spec and
|
|
would mutate exactly like the deferred force install. When nothing
|
|
imports there are no distribution files to replace under the live
|
|
importer, and without an install this bring-up cannot produce a
|
|
server at all, so the requirements manager still runs.
|
|
"""
|
|
_LOGGER.warning(
|
|
"Deferring the ha-mcp install/upgrade: a previous bring-up's "
|
|
"worker thread is still importing, and replacing the package "
|
|
"files under it could corrupt that import. The currently "
|
|
"installed build will be used; reload the integration (or "
|
|
"restart Home Assistant) to apply the update."
|
|
)
|
|
if installed_version is None:
|
|
await self._async_process_requirements_fast()
|
|
|
|
def _replaced_dist_name(self) -> str | None:
|
|
"""Return the distribution whose presence could no-op the new spec.
|
|
|
|
This is the distribution the effective spec installs *by name* — the
|
|
channel's distribution for a channel spec, or the named distribution
|
|
of an override that parses as a requirement (a pin like
|
|
``ha-mcp==X``, matched against the two known channel dists). It is
|
|
deliberately NOT the channel's dist for every override: a repo
|
|
tarball installs as ``ha-mcp`` regardless of the selected channel, so
|
|
keying on the channel would miss the dev-channel + override case.
|
|
|
|
Returns None for an override that names an unknown distribution or
|
|
does not parse as a requirement at all (a direct URL): the installer
|
|
re-fetches and rebuilds URL requirements under ``upgrade=True``
|
|
regardless of the installed version, so a URL install is already
|
|
real and nothing needs removing.
|
|
"""
|
|
if not self._pip_spec_override:
|
|
return dist_for_channel(self._channel)
|
|
try:
|
|
name = canonicalize_name(Requirement(self._pip_spec_override).name)
|
|
except InvalidRequirement:
|
|
return None
|
|
for dist_name in (DIST_NAME_STABLE, DIST_NAME_DEV):
|
|
if name == canonicalize_name(dist_name):
|
|
return dist_name
|
|
return None
|
|
|
|
async def _async_remove_replaced_source(
|
|
self, stored_spec: str | None, installed_version: str | None
|
|
) -> None:
|
|
"""Uninstall the replaced distribution when the requested source changed.
|
|
|
|
The forced install that follows relies on ``upgrade=True``, and the
|
|
installer decides "already satisfied" by VERSION alone — but a source
|
|
change can keep the version string. A PR branch's committed
|
|
``project.version`` equals the release it branched from (only release
|
|
automation bumps it), so its tarball installs with that same version
|
|
string; clearing the override then resolves the channel spec to the
|
|
exact version already on disk and the install swaps nothing, leaving
|
|
the PR code running while the entry reports a clean channel install
|
|
(issue #1914). The same version-blindness bites a manual spec edit
|
|
that pins the version already installed. The installer cannot see the
|
|
difference, so when the spec that produced the current install
|
|
differs from the one about to be installed, the distribution the new
|
|
spec resolves to by name (:meth:`_replaced_dist_name`) is removed
|
|
first — the install that follows is then unconditionally real.
|
|
|
|
Skipped when nothing is installed, when the last-installed spec is
|
|
unknown (nothing to compare: first install, or entry data predating
|
|
the spec tracking), when the spec is unchanged (the routine
|
|
reload/restart path, where ``upgrade=True`` alone is correct and an
|
|
uninstall would churn — and briefly break — a healthy install on
|
|
every restart), when the new spec is a direct URL (always installs
|
|
for real), when the named distribution is not installed (e.g. a
|
|
cross-channel switch already removed it), when the stored spec is
|
|
an index requirement on the SAME distribution (a repin — e.g.
|
|
toggling auto-update rewrites bare ``ha-mcp`` to ``ha-mcp==X`` —
|
|
draws from the same index either way, so version resolution is
|
|
faithful and uninstalling a healthy install on a preference toggle
|
|
would only add an offline-breakage window), or when the new spec is
|
|
an exact pin on a version provably different from the installed one
|
|
(the install cannot no-op, so the working build stays in place as
|
|
the fallback if it fails).
|
|
|
|
Unlike the other pre-install uninstalls this one is NOT best-effort:
|
|
if the distribution survives a failed uninstall, the forced install
|
|
would no-op as "already satisfied", the new spec would be persisted,
|
|
and the next reload would see it as unchanged — reproducing #1914 and
|
|
then permanently masking it. Raising instead keeps the stored spec on
|
|
the old value, so the next reload retries the whole source change.
|
|
"""
|
|
if installed_version is None or stored_spec is None:
|
|
return
|
|
if stored_spec == self._pip_spec:
|
|
return
|
|
replaced_dist = self._replaced_dist_name()
|
|
if replaced_dist is None:
|
|
return
|
|
if _spec_is_index_requirement_on(stored_spec, replaced_dist):
|
|
# Same distribution, same index — only the pin changed. The old
|
|
# code on disk came from the index too, so "already satisfied by
|
|
# version" is the truth, not the #1914 lie.
|
|
return
|
|
pinned = _exact_pinned_version(self._pip_spec)
|
|
if pinned is not None:
|
|
try:
|
|
version_moves = Version(pinned) != Version(installed_version)
|
|
except InvalidVersion:
|
|
version_moves = False # unprovable — keep the uninstall
|
|
if version_moves:
|
|
# The new pin cannot be satisfied by the installed version, so
|
|
# the forced install is guaranteed to be real without any
|
|
# uninstall — and keeping the working build in place preserves
|
|
# it as the fallback if that install fails (e.g. offline).
|
|
return
|
|
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
|
return
|
|
_LOGGER.info(
|
|
"The requested server source changed (%r -> %r); removing the "
|
|
"installed %r first so the reinstall cannot be skipped as "
|
|
"already satisfied",
|
|
stored_spec,
|
|
self._pip_spec,
|
|
replaced_dist,
|
|
)
|
|
removed = await self._async_remove_distribution(replaced_dist)
|
|
if not removed and await self._hass.async_add_executor_job(
|
|
_dist_installed, replaced_dist
|
|
):
|
|
raise EmbeddedServerError(
|
|
f"Could not remove the installed {replaced_dist!r} (from "
|
|
f"{stored_spec!r}) before installing {self._pip_spec!r}: the "
|
|
"installer would report the new source as already satisfied "
|
|
"and keep the old code running. Uninstall details are logged "
|
|
"above; reload the integration to retry the source change.",
|
|
kind="package",
|
|
)
|
|
|
|
async def _async_process_requirements_fast(self) -> None:
|
|
"""Fast path: let HA's requirements manager satisfy the override spec."""
|
|
try:
|
|
await async_process_requirements(
|
|
self._hass,
|
|
f"{DOMAIN} server",
|
|
[self._pip_spec],
|
|
is_built_in=False,
|
|
)
|
|
except RequirementsNotFound as err:
|
|
raise EmbeddedServerError(
|
|
f"Could not install the server ({self._pip_spec!r}): {err}",
|
|
kind="package",
|
|
) from err
|
|
|
|
async def _async_force_install(self) -> None:
|
|
"""Force a real (re)install of the pip spec, bypassing the is-installed
|
|
cache.
|
|
|
|
Mirrors how ``homeassistant.requirements`` builds its pip invocation
|
|
(HA's own constraints file + ``config/deps`` target where applicable) so
|
|
the resolver honors Home Assistant's constraints, then installs with
|
|
``upgrade=True`` and a generous per-download timeout.
|
|
"""
|
|
kwargs = pip_kwargs(self._hass.config.config_dir)
|
|
kwargs["timeout"] = max(
|
|
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
|
|
)
|
|
installed = await self._async_run_tracked_install_job(
|
|
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
|
|
)
|
|
if not installed:
|
|
raise EmbeddedServerError(
|
|
f"Could not install the server ({self._pip_spec!r}). The "
|
|
f"in-process server requires ha-mcp "
|
|
f"{MIN_EMBEDDED_SERVER_VERSION} or newer and Home Assistant "
|
|
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. Resolver "
|
|
"details are logged under homeassistant.util.package.",
|
|
kind="package",
|
|
)
|
|
|
|
async def _async_remove_conflicting_dist(self) -> None:
|
|
"""Uninstall the other release channel's distribution before installing.
|
|
|
|
``ha-mcp`` (stable) and ``ha-mcp-dev`` (dev) ship the *same* ``ha_mcp``
|
|
import package, so installing one over the other overwrites the shared
|
|
files while leaving both distributions' metadata behind. That stale
|
|
metadata makes a later pinned reinstall a no-op (``ha-mcp==X`` looks
|
|
already-satisfied, so a dev→stable downgrade would leave dev files on
|
|
disk) and makes the reported version ambiguous. Removing the other
|
|
channel's distribution first keeps exactly one installed.
|
|
|
|
Best-effort: a failed uninstall is logged, not raised — the forced
|
|
(re)install that follows still writes the correct channel's files, and the
|
|
next reload retries the cleanup. Skipped for an explicit override, whose
|
|
distribution name is unknown.
|
|
"""
|
|
other = self._conflicting_dist_name()
|
|
if other is None:
|
|
return
|
|
if not await self._hass.async_add_executor_job(_dist_installed, other):
|
|
return
|
|
_LOGGER.info(
|
|
"Removing the other release channel's package %r before installing %r",
|
|
other,
|
|
self._pip_spec,
|
|
)
|
|
await self._async_remove_distribution(other)
|
|
|
|
async def _async_remove_distribution(self, dist_name: str) -> bool:
|
|
"""Remove a distribution from the same target used for installation.
|
|
|
|
Tracked like the install: an uninstall mutates the same package files.
|
|
Returns whether the uninstall subprocess reported success; callers
|
|
decide whether a failure is best-effort (channel-conflict / legacy
|
|
cleanup) or fatal (the replaced-source removal, whose failure would
|
|
silently void the reinstall — see ``_async_remove_replaced_source``).
|
|
"""
|
|
target = pip_kwargs(self._hass.config.config_dir).get("target")
|
|
if target is None:
|
|
result = await self._async_run_tracked_install_job(
|
|
partial(_uninstall_distribution, dist_name)
|
|
)
|
|
else:
|
|
result = await self._async_run_tracked_install_job(
|
|
partial(_uninstall_distribution, dist_name, target=target)
|
|
)
|
|
return bool(result)
|
|
|
|
def _store_installed_spec(self) -> None:
|
|
"""Persist the pip spec just installed so a restart skips the reinstall."""
|
|
new_data = {**self._entry.data, DATA_LAST_PIP_SPEC: self._pip_spec}
|
|
if new_data != dict(self._entry.data):
|
|
self._hass.config_entries.async_update_entry(self._entry, data=new_data)
|
|
|
|
def _clear_pending_install_marker(self) -> None:
|
|
"""Clear the update entity's one-shot pending-install marker.
|
|
|
|
Called at CONSUME time in :meth:`_async_ensure_package`, before the
|
|
install attempt runs: the marker buys exactly one attempt. Clearing
|
|
only on success would let a marker for a failing version re-pin every
|
|
later reload to that broken version (review finding).
|
|
"""
|
|
if DATA_PENDING_INSTALL_VERSION not in self._entry.data:
|
|
return
|
|
new_data = dict(self._entry.data)
|
|
new_data.pop(DATA_PENDING_INSTALL_VERSION, None)
|
|
self._hass.config_entries.async_update_entry(self._entry, data=new_data)
|
|
|
|
# -- token provisioning ------------------------------------------------
|
|
|
|
async def _async_provision_token(self) -> str:
|
|
"""Return an admin access token for the server, provisioning if needed.
|
|
|
|
Reuses the previously-created local admin user and long-lived refresh
|
|
token across restarts (ids persisted in ``entry.data``); a fresh access
|
|
token is minted from the refresh token on every start. Falls back to
|
|
creating a new user / refresh token when the stored ones are gone.
|
|
"""
|
|
user_id = self._entry.data.get(DATA_SERVER_USER_ID)
|
|
rt_id = self._entry.data.get(DATA_REFRESH_TOKEN_ID)
|
|
|
|
user = await self._hass.auth.async_get_user(user_id) if user_id else None
|
|
if user is None:
|
|
user = await self._hass.auth.async_create_user(
|
|
SERVER_USER_NAME,
|
|
group_ids=[GROUP_ID_ADMIN],
|
|
local_only=True,
|
|
)
|
|
rt_id = None
|
|
|
|
refresh_token = (
|
|
self._hass.auth.async_get_refresh_token(rt_id) if rt_id else None
|
|
)
|
|
if refresh_token is not None and refresh_token.user.id != user.id:
|
|
refresh_token = None
|
|
|
|
if refresh_token is None:
|
|
# A long-lived token's client_name must be unique per user, so clear
|
|
# any stale one left behind by a partial previous provision.
|
|
for token in list(user.refresh_tokens.values()):
|
|
if (
|
|
token.client_name == SERVER_TOKEN_CLIENT_NAME
|
|
and token.token_type == TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
|
):
|
|
self._hass.auth.async_remove_refresh_token(token)
|
|
refresh_token = await self._hass.auth.async_create_refresh_token(
|
|
user,
|
|
client_name=SERVER_TOKEN_CLIENT_NAME,
|
|
token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
|
|
access_token_expiration=_ACCESS_TOKEN_TTL,
|
|
)
|
|
|
|
# hass is untyped here (homeassistant mocked in unit tier); pin str.
|
|
access_token = str(self._hass.auth.async_create_access_token(refresh_token))
|
|
|
|
# Persist only the ids needed to REUSE the credentials next start.
|
|
# The access token itself is deliberately NOT stored: it is handed to
|
|
# the worker in memory, nothing ever reads it back from entry.data,
|
|
# and a fresh JWT is minted each start - persisting it would leave an
|
|
# unused admin token in .storage AND rewrite the config entry on
|
|
# every start (each mint differs). Review finding; the revoke path
|
|
# still strips the legacy key from entries written by older builds.
|
|
new_data = {
|
|
**self._entry.data,
|
|
DATA_SERVER_USER_ID: user.id,
|
|
DATA_REFRESH_TOKEN_ID: refresh_token.id,
|
|
}
|
|
new_data.pop(DATA_ACCESS_TOKEN, None)
|
|
if new_data != dict(self._entry.data):
|
|
self._hass.config_entries.async_update_entry(self._entry, data=new_data)
|
|
return access_token
|
|
|
|
def _prepare_config_dir(self) -> None:
|
|
"""Create the server's persistent data directory (blocking)."""
|
|
os.makedirs(self._config_dir, exist_ok=True)
|
|
|
|
# -- worker thread -----------------------------------------------------
|
|
|
|
def _note_startup_phase(self, phase: str) -> None:
|
|
"""Publish the worker's startup phase (a plain attribute write).
|
|
|
|
Read by the readiness poll: a phase advance counts as progress, and the
|
|
failure message names the phase the worker was last seen in.
|
|
"""
|
|
self._startup_phase = phase
|
|
_LOGGER.debug("HA-MCP in-process server startup: %s", phase)
|
|
|
|
def _thread_main(self, access_token: str) -> None:
|
|
"""Thread entry point: stage non-secret env, then run the server.
|
|
|
|
Only the non-secret ``HA_MCP_CONFIG_DIR`` / ``HA_MCP_EMBEDDED`` variables
|
|
are staged here (both read lazily throughout ha_mcp), and they MUST be set
|
|
before the first ``ha_mcp`` import so data-dir resolution and embedded-mode
|
|
detection see them. The loopback URL and the admin token are handed to
|
|
ha_mcp in memory inside :meth:`_serve` — never via ``os.environ``.
|
|
"""
|
|
os.environ["HA_MCP_CONFIG_DIR"] = self._config_dir
|
|
os.environ["HA_MCP_EMBEDDED"] = "1"
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
# Created here and handed to _serve as a LOCAL; publishing to self
|
|
# is one-way, for async_stop's signaling only — see async_stop's
|
|
# zombie-thread comment for why the worker must never re-read it.
|
|
stop_event = asyncio.Event()
|
|
self._loop = loop
|
|
self._stop_event = stop_event
|
|
# Registration in _IMPORTING_WORKERS happened on the MAIN thread,
|
|
# before start() — see async_start. This thread only deregisters:
|
|
# in _serve once the import section completes, and in the finally
|
|
# below on exit as the backstop.
|
|
try:
|
|
loop.run_until_complete(self._serve(access_token, stop_event))
|
|
except SystemExit as err:
|
|
# uvicorn signals a startup failure (e.g. the port is already in
|
|
# use) with SystemExit(STARTUP_FAILURE), which ``except Exception``
|
|
# misses — live issue #1904 saw the real bind error surface only
|
|
# in HA's generic task-exception log while the component reported
|
|
# a bare readiness timeout. Unwrap the original error so the
|
|
# repair issue names the actual cause; a bare SystemExit (no
|
|
# chained exception) is reported by repr so an empty/zero exit
|
|
# code still reads as what it is. The phase names where in
|
|
# _serve the exit happened instead of hardcoding a bind failure.
|
|
cause = err.__context__ or err.__cause__
|
|
detail = str(cause) if cause is not None else repr(err)
|
|
self._thread_exc = EmbeddedServerError(
|
|
f"the server exited during startup ({self._startup_phase}): {detail}"
|
|
)
|
|
_LOGGER.error(
|
|
"HA-MCP in-process server exited during startup (%s): %s",
|
|
self._startup_phase,
|
|
detail,
|
|
)
|
|
except Exception as err:
|
|
self._thread_exc = err
|
|
_LOGGER.exception("HA-MCP in-process server thread crashed")
|
|
finally:
|
|
with _IMPORTING_WORKERS_LOCK:
|
|
_IMPORTING_WORKERS.discard(threading.current_thread())
|
|
# Teardown is best-effort but never SILENT (review finding): a
|
|
# raise here must not mask the primary outcome, yet a recurring
|
|
# cleanup failure (leaking executor threads across reloads) has
|
|
# to be visible in the logs. Each call gets its own guard so one
|
|
# failure cannot skip the other.
|
|
for _label, _coro_factory in (
|
|
("asyncgen", loop.shutdown_asyncgens),
|
|
("executor", loop.shutdown_default_executor),
|
|
):
|
|
try:
|
|
loop.run_until_complete(_coro_factory())
|
|
except Exception:
|
|
_LOGGER.warning(
|
|
"Worker-loop %s shutdown failed during teardown",
|
|
_label,
|
|
exc_info=True,
|
|
)
|
|
loop.close()
|
|
|
|
async def _serve(self, access_token: str, stop_event: asyncio.Event) -> None:
|
|
"""Build the ha-mcp server and run it until a stop is signaled.
|
|
|
|
Mirrors the CLI HTTP runner in ``ha_mcp.__main__`` without importing it
|
|
(that module runs process-global side effects — truststore SSL patching,
|
|
signal handlers, ``asyncio.run`` — that must never happen in-process).
|
|
"""
|
|
# Hand ha-mcp the loopback URL + provisioned admin token in memory, before
|
|
# the server (and its settings singleton) is built. Keeping the token out
|
|
# of os.environ is the whole point of the in-process channel.
|
|
self._note_startup_phase("importing the server package")
|
|
import ha_mcp.config as _hamcp_config
|
|
|
|
# Record which code generation this worker imported. Prefer the
|
|
# configured channel when both distributions have metadata because
|
|
# ha_mcp.__version__ itself checks stable first and stale stable
|
|
# metadata can otherwise make a fresh dev worker look outdated.
|
|
self._running_version = _running_ha_mcp_version(self._channel)
|
|
# The cache in sys.modules now holds this generation — remembered
|
|
# process-wide so the next start can skip the purge when the install
|
|
# has not changed (issue #1904).
|
|
global _CACHED_IMPORT_VERSION
|
|
_CACHED_IMPORT_VERSION = self._running_version
|
|
|
|
# Drop any settings singleton cached by a PREVIOUS start in this same
|
|
# Python process: an entry reload must re-read the override files
|
|
# (feature flags, advanced settings) exactly like an add-on restart
|
|
# does. Fall back to the private seam on releases that predate the
|
|
# public alias.
|
|
_reset = getattr(
|
|
_hamcp_config,
|
|
"reset_global_settings",
|
|
getattr(_hamcp_config, "_reset_global_settings", None),
|
|
)
|
|
if _reset is not None:
|
|
_reset()
|
|
else:
|
|
_LOGGER.warning(
|
|
"ha_mcp.config exposes no settings-reset seam; a reloaded "
|
|
"entry may serve stale override values until HA restarts"
|
|
)
|
|
|
|
if self._loopback_verify_ssl is None:
|
|
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
|
else:
|
|
try:
|
|
_hamcp_config.set_embedded_connection(
|
|
self._server_url,
|
|
access_token,
|
|
verify_ssl=self._loopback_verify_ssl,
|
|
)
|
|
except TypeError:
|
|
# Server predates the verify_ssl parameter (< the release
|
|
# carrying issue #1890's fix). Register url+token the old way;
|
|
# on an SSL-enabled instance the wss loopback will fail
|
|
# certificate verification until the server package updates —
|
|
# no worse than the plaintext failure it replaces.
|
|
_LOGGER.warning(
|
|
"Installed ha-mcp server does not accept verify_ssl for "
|
|
"the embedded connection; loopback TLS verification stays "
|
|
"enabled until the server package updates"
|
|
)
|
|
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
|
|
|
# Imported here, in the worker thread, after the connection is registered.
|
|
from ha_mcp.server import HomeAssistantSmartMCPServer
|
|
from ha_mcp.settings_ui import register_settings_routes
|
|
|
|
self._note_startup_phase("building the server")
|
|
server = HomeAssistantSmartMCPServer()
|
|
|
|
# Startup observability (no secrets): confirm the in-memory connection
|
|
# channel actually reached the settings singleton — a sentinel here means
|
|
# the server cannot talk to HA core and every tool call will fail.
|
|
OAUTH_MODE_TOKEN = _hamcp_config.OAUTH_MODE_TOKEN
|
|
OAUTH_MODE_URL = _hamcp_config.OAUTH_MODE_URL
|
|
get_global_settings = _hamcp_config.get_global_settings
|
|
|
|
resolved = get_global_settings()
|
|
_LOGGER.info(
|
|
"Embedded connection resolved: url=%s, token=%s (requested url=%s)",
|
|
resolved.homeassistant_url,
|
|
"provisioned"
|
|
if resolved.homeassistant_token not in ("", OAUTH_MODE_TOKEN)
|
|
else "SENTINEL-MISSING",
|
|
self._server_url,
|
|
)
|
|
if resolved.homeassistant_url in (
|
|
"",
|
|
OAUTH_MODE_URL,
|
|
) or resolved.homeassistant_token in ("", OAUTH_MODE_TOKEN):
|
|
# Refuse to serve: a sentinel connection means every tool call
|
|
# would fail while the bring-up still looked successful (webhook
|
|
# registered, connect-URL notification shown). Raising propagates
|
|
# via _thread_exc -> the readiness probe -> a repair issue, which
|
|
# is the honest outcome (live-found signal-swallow). URL and
|
|
# token are checked SYMMETRICALLY - today they can only fail
|
|
# jointly, but the guard must catch a future regression in
|
|
# either half of the in-memory channel.
|
|
raise EmbeddedServerError(
|
|
"The in-process settings channel did not apply - the server "
|
|
"has no Home Assistant connection (sentinel URL or token). "
|
|
"Refusing to start."
|
|
)
|
|
|
|
# Parity with the CLI HTTP runner: serve the web settings UI under the
|
|
# same secret path as the MCP endpoint.
|
|
self._note_startup_phase("registering web routes")
|
|
register_settings_routes(server.mcp, server, secret_path=self._secret_path)
|
|
|
|
# Parity with the CLI HTTP runner: answer a browser GET on the MCP path
|
|
# with the friendly landing page (405 + setup guidance) instead of a
|
|
# bare "Method Not Allowed" — both on the direct URL and through the
|
|
# ingress webhook. Guard only the import: the installed server version
|
|
# is user-controlled (channel choice, pip-spec override), so an older
|
|
# ha-mcp without this module must keep serving; the landing is simply
|
|
# absent there, as it is today.
|
|
try:
|
|
from ha_mcp.browser_landing import register_browser_landing
|
|
except ImportError:
|
|
# Older installed ha-mcp: no landing helper to register.
|
|
pass
|
|
else:
|
|
register_browser_landing(server.mcp, self._secret_path)
|
|
|
|
# Own the uvicorn server instead of calling mcp.run_async(): cancelling
|
|
# run_async's task does NOT release the listening socket in-process
|
|
# (live-found: the next bring-up failed with EADDRINUSE and uvicorn's
|
|
# lifespan generator raised "athrow(): asynchronous generator is
|
|
# already running"). The CLI never sees this because its process exits.
|
|
# This mirrors fastmcp 3.4.2's run_http_async internals (http_app +
|
|
# uvicorn.Config defaults + _lifespan_manager), pinned by ha-mcp.
|
|
import uvicorn
|
|
|
|
# fastmcp >= 3.4.3 ships an on-by-default Host/Origin (DNS-rebinding)
|
|
# guard that would 421 this in-process server's direct LAN listener
|
|
# (bind 0.0.0.0:9584 by default), reached on arbitrary hosts. Default it
|
|
# off to match the CLI / add-on entry points. Guard only the import: a
|
|
# bundled ha-mcp old enough to lack the helper also predates the guard,
|
|
# so there is nothing to disable.
|
|
try:
|
|
from ha_mcp.transport_security import (
|
|
ensure_host_origin_guard_default_off,
|
|
)
|
|
except ImportError:
|
|
# Older bundled ha-mcp: no helper, and no guard to disable either.
|
|
pass
|
|
else:
|
|
ensure_host_origin_guard_default_off()
|
|
|
|
# The cold import — the multi-minute window that crashed in #1904 —
|
|
# is complete: every explicit ha_mcp import in _serve precedes this
|
|
# line. Leave the registry so a long-running healthy server never
|
|
# blocks later bring-ups' purges (removal from sys.modules cannot
|
|
# unload already-bound modules; only in-flight imports are
|
|
# corruptible, and any later lazy import is outside the window this
|
|
# registry protects).
|
|
with _IMPORTING_WORKERS_LOCK:
|
|
_IMPORTING_WORKERS.discard(threading.current_thread())
|
|
|
|
app = server.mcp.http_app(path=self._secret_path, stateless_http=True)
|
|
config = uvicorn.Config(
|
|
app,
|
|
host=self._bind_host,
|
|
port=self._port,
|
|
timeout_graceful_shutdown=2,
|
|
lifespan="on",
|
|
ws="websockets-sansio",
|
|
# Leave Home Assistant's logging untouched — do not let uvicorn
|
|
# reconfigure the root logger from this thread.
|
|
log_config=None,
|
|
)
|
|
uv_server = uvicorn.Server(config)
|
|
|
|
self._note_startup_phase("starting the HTTP listener")
|
|
stop_task = asyncio.create_task(stop_event.wait())
|
|
async with server.mcp._lifespan_manager():
|
|
serve_task = asyncio.create_task(uv_server.serve())
|
|
done, _pending = await asyncio.wait(
|
|
{serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
if stop_task in done:
|
|
# Graceful shutdown through uvicorn's own path: waits out
|
|
# in-flight requests (2s cap), runs lifespan shutdown, and
|
|
# deterministically releases the socket for the next bring-up.
|
|
uv_server.should_exit = True
|
|
await serve_task
|
|
else:
|
|
stop_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await stop_task
|
|
# Surface a server that exited on its own (bind failure, etc.).
|
|
serve_task.result()
|
|
|
|
def _progress_signature(self) -> tuple[int, str]:
|
|
"""Snapshot the observable startup progress of the worker thread.
|
|
|
|
``len(sys.modules)`` moves continuously while the worker grinds
|
|
through a cold import (the single longest startup step — minutes on
|
|
slow hardware, issue #1904), and the published phase moves between
|
|
steps. Any change in the pair counts as progress. The module count is
|
|
PROCESS-wide — an approximation: nothing finer-grained is observable
|
|
from outside a thread stuck inside one ``import`` statement, and any
|
|
other HA thread importing concurrently also refreshes the stall
|
|
budget. Erring toward patience is the point; the absolute cap bounds
|
|
the wait regardless.
|
|
"""
|
|
return (len(sys.modules), self._startup_phase)
|
|
|
|
async def _async_wait_until_ready(self) -> None:
|
|
"""Poll a loopback TCP connect until the server accepts, or fail.
|
|
|
|
Patience is progress-based: the wait only gives up when there is no
|
|
observable progress for ``_READY_STALL_TIMEOUT_SECONDS`` (or the
|
|
absolute ``_READY_TOTAL_CAP_SECONDS`` ceiling is hit). A slow cold
|
|
import keeps the wait alive; a wedged worker is caught by the stall
|
|
budget on a quiet instance, by the cap at the latest (the progress
|
|
signal is process-wide). On failure stops the thread and raises
|
|
:class:`EmbeddedServerError` so the caller leaves the webhook
|
|
unregistered and files a repair issue.
|
|
"""
|
|
start = self._hass.loop.time()
|
|
last_progress = start
|
|
last_signature = self._progress_signature()
|
|
while True:
|
|
if self._thread_exc is not None:
|
|
raise EmbeddedServerError(
|
|
f"HA-MCP in-process server failed to start: {self._thread_exc}"
|
|
) from self._thread_exc
|
|
if self._thread is not None and not self._thread.is_alive():
|
|
raise EmbeddedServerError(
|
|
"HA-MCP in-process server thread exited during startup."
|
|
)
|
|
if await self._async_probe_port():
|
|
_LOGGER.info(
|
|
"HA-MCP in-process server is listening on %s:%d",
|
|
self._bind_host,
|
|
self._port,
|
|
)
|
|
return
|
|
now = self._hass.loop.time()
|
|
signature = self._progress_signature()
|
|
if signature != last_signature:
|
|
last_signature = signature
|
|
last_progress = now
|
|
if now - start >= _READY_TOTAL_CAP_SECONDS:
|
|
failure = (
|
|
f"HA-MCP in-process server did not become reachable on "
|
|
f"port {self._port} within {_READY_TOTAL_CAP_SECONDS:.0f}s "
|
|
f"(last startup phase: {self._startup_phase})."
|
|
)
|
|
break
|
|
if now - last_progress >= _READY_STALL_TIMEOUT_SECONDS:
|
|
failure = (
|
|
f"HA-MCP in-process server did not become reachable on "
|
|
f"port {self._port}: no startup progress observed for "
|
|
f"{_READY_STALL_TIMEOUT_SECONDS:.0f}s (last phase: "
|
|
f"{self._startup_phase}; {now - start:.0f}s since start)."
|
|
)
|
|
break
|
|
await asyncio.sleep(_READY_POLL_INTERVAL_SECONDS)
|
|
|
|
# Gave up — tear the thread down so we never leave a half-started
|
|
# server behind an unregistered webhook.
|
|
await self.async_stop()
|
|
raise EmbeddedServerError(failure)
|
|
|
|
async def _async_probe_port(self) -> bool:
|
|
"""Return True if a loopback TCP connection to the server port succeeds.
|
|
|
|
Probes 127.0.0.1 regardless of bind host — a 0.0.0.0 bind still accepts
|
|
on loopback, and the forwarding webhook only ever talks to loopback.
|
|
"""
|
|
try:
|
|
_reader, writer = await asyncio.wait_for(
|
|
asyncio.open_connection("127.0.0.1", self._port),
|
|
timeout=_READY_POLL_INTERVAL_SECONDS,
|
|
)
|
|
except (TimeoutError, OSError):
|
|
return False
|
|
writer.close()
|
|
with suppress(OSError, TimeoutError):
|
|
await asyncio.wait_for(writer.wait_closed(), timeout=1.0)
|
|
return True
|
|
|
|
|
|
# Worker threads that may still be executing their ha_mcp imports. Purging
|
|
# sys.modules while any of them is alive corrupts the in-flight import
|
|
# (KeyError from frozen importlib — issue #1904). Process-global because a
|
|
# manager is recreated on every bring-up, so per-manager orphan tracking
|
|
# cannot see a previous manager's abandoned worker. The spawning (main)
|
|
# thread registers the worker BEFORE start() — see async_start — and the
|
|
# worker deregisters once its import section completes (or on thread exit,
|
|
# whichever comes first). All access
|
|
# goes through the lock: CPython's GIL would make the individual set ops
|
|
# atomic, but the purge gate's prune-then-check is a composite read and the
|
|
# lock keeps its correctness independent of GIL scheduling arguments.
|
|
# Deliberate tradeoff: a worker wedged forever inside its import stays
|
|
# registered and blocks every later purge until an HA core restart — evicting
|
|
# a live importer on a timer would reintroduce the very corruption this
|
|
# registry prevents, and the skip warning plus the post-start staleness check
|
|
# surface the condition.
|
|
_IMPORTING_WORKERS_LOCK = threading.Lock()
|
|
_IMPORTING_WORKERS: set[threading.Thread] = set()
|
|
|
|
|
|
def _prune_and_check_importing_workers() -> bool:
|
|
"""Drop dead workers from the registry; return True if any live one remains."""
|
|
with _IMPORTING_WORKERS_LOCK:
|
|
_IMPORTING_WORKERS.difference_update(
|
|
[t for t in _IMPORTING_WORKERS if not t.is_alive()]
|
|
)
|
|
return bool(_IMPORTING_WORKERS)
|
|
|
|
|
|
# Completion event of the package-mutating install/uninstall job currently on
|
|
# the executor, if any. asyncio cancellation of a bring-up detaches the
|
|
# awaiter, but the executor job keeps running to completion — untracked, an
|
|
# orphaned pip could swap the distribution's files under the NEXT bring-up's
|
|
# install or its worker's cold import (found in review of PR #1911, the
|
|
# #1904 fixes; pre-existing). The dispatching coroutine registers the event BEFORE handing
|
|
# the job to the executor, and the executor fn sets it in a finally that
|
|
# survives cancellation; the next bring-up waits on it before mutating
|
|
# anything. Process-global for the same reason as _IMPORTING_WORKERS: a
|
|
# manager is recreated on every bring-up.
|
|
#
|
|
# Single slot BY DESIGN: at most one tracked job can exist at a time — the
|
|
# server entry is single-instance, an entry reload cancels-and-awaits the
|
|
# previous bring-up before setting up, and every dispatch site sits behind
|
|
# _async_wait_for_pending_install. A second concurrent dispatcher would
|
|
# overwrite the slot and silently lose the older live job — keep any new
|
|
# package-mutating call site behind the wait gate. The slot tracking wraps
|
|
# the DIRECT-pip sites (force install, uninstalls); the fast path goes
|
|
# through HA's requirements manager, which is behind the gate but untracked —
|
|
# it only dispatches pip when the package is missing outright, which cannot
|
|
# co-occur with a live orphaned job worth waiting on.
|
|
_PENDING_INSTALL_LOCK = threading.Lock()
|
|
_PENDING_INSTALL_DONE: threading.Event | None = None
|
|
|
|
|
|
# Version of the ha_mcp generation currently cached in sys.modules — set by
|
|
# the worker right after its first import lands, cleared by the purge. Process-wide
|
|
# (the module cache it describes is process-wide too). Lets a retry with an
|
|
# unchanged install keep the warm cache instead of paying the full cold import
|
|
# again (issue #1904).
|
|
_CACHED_IMPORT_VERSION: str | None = None
|
|
|
|
|
|
def _purge_ha_mcp_modules() -> None:
|
|
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
|
|
|
|
The in-process server runs as a thread of the HA core Python process, and
|
|
Python resolves imports from the process-wide ``sys.modules`` cache — so
|
|
after a pip install the next worker would silently reuse the OLD code
|
|
unless the cache is purged first. Safe here because ``ha_mcp`` is pure
|
|
Python and is only ever imported inside worker threads, and the caller's
|
|
gate guarantees no registered worker is mid-import when this runs (a
|
|
worker past its imports keeps its already-bound modules regardless);
|
|
third-party dependencies are deliberately NOT purged (they are shared
|
|
with the rest of Home Assistant), so a dependency-version change still
|
|
needs an HA core restart.
|
|
"""
|
|
global _CACHED_IMPORT_VERSION
|
|
_CACHED_IMPORT_VERSION = None
|
|
# Snapshot the keys: sys.modules can be mutated by concurrent imports on
|
|
# other threads mid-iteration (HA core is heavily threaded).
|
|
purged = [
|
|
name
|
|
for name in list(sys.modules)
|
|
if name == "ha_mcp" or name.startswith("ha_mcp.")
|
|
]
|
|
if not purged:
|
|
return
|
|
for name in purged:
|
|
sys.modules.pop(name, None)
|
|
importlib.invalidate_caches()
|
|
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
|
|
|
|
|
|
def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
|
|
"""Return the installed ha-mcp distribution version, or None (blocking).
|
|
|
|
Invalidates the import caches first so a just-completed pip install is seen.
|
|
Checks both the stable (``ha-mcp``) and dev (``ha-mcp-dev``) distribution
|
|
names, mirroring ``ha_mcp._version.get_version``. When ``preferred_dist`` is
|
|
provided, checks that channel first so stale metadata from a failed
|
|
best-effort conflicting uninstall cannot mask the package just installed.
|
|
"""
|
|
importlib.invalidate_caches()
|
|
# Metadata alone is not proof: a channel switch's best-effort uninstall
|
|
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
|
|
# tree belongs to whichever dist installed last). Require the import
|
|
# machinery to actually resolve the package before trusting any version.
|
|
if importlib.util.find_spec("ha_mcp") is None:
|
|
return None
|
|
dist_names = (
|
|
(DIST_NAME_DEV, DIST_NAME_STABLE)
|
|
if preferred_dist == DIST_NAME_DEV
|
|
else (DIST_NAME_STABLE, DIST_NAME_DEV)
|
|
)
|
|
for dist_name in dist_names:
|
|
with suppress(importlib.metadata.PackageNotFoundError):
|
|
return importlib.metadata.version(dist_name)
|
|
return None
|
|
|
|
|
|
def _dist_installed(dist_name: str) -> bool:
|
|
"""Return True if the named distribution has installed metadata (blocking).
|
|
|
|
Invalidates the import caches first so a just-completed (un)install is seen.
|
|
"""
|
|
importlib.invalidate_caches()
|
|
try:
|
|
importlib.metadata.version(dist_name)
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _installed_dist_version(dist_name: str) -> str | None:
|
|
"""Return the installed version of a SPECIFIC distribution, or None (blocking).
|
|
|
|
Invalidates the import caches first so a just-completed (un)install is seen.
|
|
Unlike :func:`_installed_ha_mcp_version` (which reports whichever of the two
|
|
channel distributions is present) this pins the given distribution name, so
|
|
the auto-update check compares the newest PyPI build against the version of
|
|
the channel actually installed.
|
|
"""
|
|
importlib.invalidate_caches()
|
|
try:
|
|
return importlib.metadata.version(dist_name)
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return None
|
|
|
|
|
|
def _running_ha_mcp_version(channel: str) -> str | None:
|
|
"""Return the imported worker version, resolving cross-channel ambiguity."""
|
|
imported_version = getattr(sys.modules.get("ha_mcp"), "__version__", None)
|
|
preferred_dist = dist_for_channel(channel)
|
|
preferred_version = _installed_dist_version(preferred_dist)
|
|
if preferred_version is None:
|
|
return imported_version
|
|
|
|
conflicting_dist = (
|
|
DIST_NAME_STABLE if preferred_dist == DIST_NAME_DEV else DIST_NAME_DEV
|
|
)
|
|
conflicting_version = _installed_dist_version(conflicting_dist)
|
|
if imported_version == conflicting_version:
|
|
return preferred_version
|
|
return imported_version
|
|
|
|
|
|
def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> bool:
|
|
"""Uninstall a distribution by name (blocking), best-effort.
|
|
|
|
Mirrors ``homeassistant.util.package.install_package``'s invocation style —
|
|
``<python> -m uv pip ...`` with no shell. An explicit dependency target is
|
|
used when Home Assistant installs into ``config/deps``; otherwise ``--python``
|
|
selects Home Assistant's interpreter environment. A failure is logged but
|
|
not raised; the caller treats the cleanup as best-effort.
|
|
"""
|
|
args = [
|
|
sys.executable,
|
|
"-m",
|
|
"uv",
|
|
"pip",
|
|
"uninstall",
|
|
]
|
|
if target is not None:
|
|
args += ["--target", os.path.abspath(target)]
|
|
else:
|
|
args += ["--python", sys.executable]
|
|
args.append(dist_name)
|
|
try:
|
|
result = subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_PIP_UNINSTALL_TIMEOUT_SECONDS,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as err:
|
|
_LOGGER.warning("Could not uninstall %r: %s", dist_name, err)
|
|
return False
|
|
if result.returncode != 0:
|
|
_LOGGER.warning(
|
|
"Uninstall of %r exited %d: %s",
|
|
dist_name,
|
|
result.returncode,
|
|
(result.stderr or "").strip(),
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _exact_pinned_version(spec: str) -> str | None:
|
|
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
|
|
|
Anything else — URL specs, bare names, ranges, multi-clause specifiers —
|
|
returns None: only an exact pin lets the caller prove, without asking the
|
|
resolver, whether the installed version could satisfy the spec. A
|
|
wildcard pin (``==7.13.*``) is returned as-is; the caller's ``Version``
|
|
parse rejects it, which conservatively keeps the uninstall.
|
|
"""
|
|
try:
|
|
req = Requirement(spec)
|
|
except InvalidRequirement:
|
|
return None
|
|
if req.url:
|
|
return None
|
|
clauses = list(req.specifier)
|
|
if len(clauses) != 1 or clauses[0].operator not in ("==", "==="):
|
|
return None
|
|
return clauses[0].version
|
|
|
|
|
|
def _spec_is_index_requirement_on(spec: str, dist_name: str) -> bool:
|
|
"""Return whether ``spec`` is a plain index requirement on ``dist_name``.
|
|
|
|
True only for a PEP 508 requirement with no direct-URL part whose
|
|
canonical name matches — i.e. a spec that installs ``dist_name`` from the
|
|
package index (bare name or version pin). A direct URL (whether a plain
|
|
URL string, which does not parse as a requirement, or a ``name @ url``
|
|
form) returns False: its origin is not the index, so it is a genuine
|
|
source change for the replaced-source check.
|
|
"""
|
|
try:
|
|
req = Requirement(spec)
|
|
except InvalidRequirement:
|
|
return False
|
|
if req.url:
|
|
return False
|
|
return canonicalize_name(req.name) == canonicalize_name(dist_name)
|
|
|
|
|
|
def _is_compatible_embedded_version(version: str) -> bool:
|
|
"""Return whether a server distribution provides the embedded API."""
|
|
try:
|
|
return Version(version) >= Version(MIN_EMBEDDED_SERVER_VERSION)
|
|
except InvalidVersion:
|
|
return False
|