Skip to content

Commit bd10f2b

Browse files
fix: tolerate setuptools editable-finder KeyError on Python 3.14 bring-up (#1989)
On Python 3.14 with `homeassistant` installed as a setuptools editable package (official HA container image / HA OS), CPython's `PathFinder.invalidate_caches()` raises `KeyError` at its `del sys.path_importer_cache[name]` line — the synthetic `__editable__.<dist>.finder.__path_hook__` placeholder is not an absolute path, so CPython takes the `del` branch on a key an earlier iteration already removed (a CPython bug; should be a `pop`). That aborts `importlib.invalidate_caches()` and crashes `_installed_ha_mcp_version()` on the `async_start()` bring-up path, so the in-process server never comes up and every tool fails (#1891, #1985). Deterministic on Python 3.14; invisible on 3.13 / non-editable installs. The four `importlib.invalidate_caches()` call sites route through `_safe_invalidate_caches()`: on that `KeyError` it prunes the stale dead/relative `sys.path_importer_cache` entries with `pop` (the offending placeholder among them), then re-runs `importlib.invalidate_caches()` so CPython completes its full sweep — path-entry finders, namespace-path epoch, and metadata. The retry is guarded (a second KeyError from a concurrent re-add is tolerated, best-effort, never re-crashes bring-up); recovery is logged at WARNING for diagnosis. The broad `except KeyError` is deliberate — the same CPython bug also fires via the `finder is None` branch with an absolute-path key, so narrowing by key would re-raise that variant. Covered by unit tests (`TestSafeInvalidateCaches`) and an in-container e2e probe that injects the exact KeyError against the real component in a live HA container. Rides under the pending 1.2.3 component version. Closes #1985. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7eb5e5b commit bd10f2b

4 files changed

Lines changed: 247 additions & 15 deletions

File tree

custom_components/ha_mcp_tools/embedded_server.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,6 +1568,64 @@ def _prune_and_check_importing_workers() -> bool:
15681568
_CACHED_IMPORT_VERSION: str | None = None
15691569

15701570

1571+
def _safe_invalidate_caches() -> None:
1572+
"""Run ``importlib.invalidate_caches()``, completing it if a finder breaks.
1573+
1574+
On Python 3.14 with ``homeassistant`` installed as a setuptools *editable*
1575+
package (the official HA container image), ``PathFinder.invalidate_caches()``
1576+
raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the
1577+
synthetic ``__editable__.<dist>.finder.__path_hook__`` placeholder is not an
1578+
absolute path, so CPython takes the ``del`` branch on a key an earlier
1579+
iteration already removed — a CPython 3.14 bug (the ``del`` should be a
1580+
``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before
1581+
this guard, crashed in-process server bring-up on every boot (issues #1891,
1582+
#1985).
1583+
1584+
On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead /
1585+
relative ``sys.path_importer_cache`` entries with ``pop`` instead of the
1586+
buggy ``del`` (the stale placeholder that trips the sweep is among them) —
1587+
then re-run ``importlib.invalidate_caches()``. With the offending entries
1588+
gone the retry completes CPython's full sweep itself: every live path-entry
1589+
finder invalidated, the namespace-path epoch advanced, and the metadata
1590+
finder refreshed. Delegating the second pass keeps us off private internals
1591+
(no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the
1592+
running Python's ``invalidate_caches`` does. Recovery only ever runs on the
1593+
broken 3.14 path (the top-level call succeeds everywhere else); every
1594+
non-``KeyError`` still propagates.
1595+
1596+
The retry is itself guarded: a concurrent import on another HA-core thread
1597+
could re-add a stale placeholder in the window between the prune and the
1598+
retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather
1599+
than re-raised — a partial cache refresh must never re-crash bring-up, which
1600+
is the whole point of this helper. The recovery is logged at WARNING (it
1601+
recurs on every version check on an affected install), so it is visible for
1602+
diagnosis rather than a silent workaround.
1603+
"""
1604+
try:
1605+
importlib.invalidate_caches()
1606+
return
1607+
except KeyError as err:
1608+
_LOGGER.warning(
1609+
"importlib.invalidate_caches() raised KeyError from a broken "
1610+
"(setuptools editable / Python 3.14) finder; pruning stale "
1611+
"sys.path_importer_cache entries and retrying: %s",
1612+
err,
1613+
)
1614+
for name in list(sys.path_importer_cache):
1615+
if sys.path_importer_cache.get(name) is None or not os.path.isabs(name):
1616+
sys.path_importer_cache.pop(name, None)
1617+
try:
1618+
importlib.invalidate_caches()
1619+
except KeyError as err:
1620+
_LOGGER.warning(
1621+
"importlib.invalidate_caches() still raised KeyError after pruning "
1622+
"stale sys.path_importer_cache entries; continuing with a best-effort "
1623+
"cache state (a concurrent import may have re-added the placeholder): "
1624+
"%s",
1625+
err,
1626+
)
1627+
1628+
15711629
def _purge_ha_mcp_modules() -> None:
15721630
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
15731631
@@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None:
15951653
return
15961654
for name in purged:
15971655
sys.modules.pop(name, None)
1598-
importlib.invalidate_caches()
1656+
_safe_invalidate_caches()
15991657
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
16001658

16011659

@@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
16081666
provided, checks that channel first so stale metadata from a failed
16091667
best-effort conflicting uninstall cannot mask the package just installed.
16101668
"""
1611-
importlib.invalidate_caches()
1669+
_safe_invalidate_caches()
16121670
# Metadata alone is not proof: a channel switch's best-effort uninstall
16131671
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
16141672
# tree belongs to whichever dist installed last). Require the import
@@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool:
16311689
16321690
Invalidates the import caches first so a just-completed (un)install is seen.
16331691
"""
1634-
importlib.invalidate_caches()
1692+
_safe_invalidate_caches()
16351693
try:
16361694
importlib.metadata.version(dist_name)
16371695
except importlib.metadata.PackageNotFoundError:
@@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None:
16481706
the auto-update check compares the newest PyPI build against the version of
16491707
the channel actually installed.
16501708
"""
1651-
importlib.invalidate_caches()
1709+
_safe_invalidate_caches()
16521710
try:
16531711
return importlib.metadata.version(dist_name)
16541712
except importlib.metadata.PackageNotFoundError:

tests/src/e2e/basic/test_backend_dispatch_smoke.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@
8585
# Entries below are CI-observed item counts, bumped only for intentional
8686
# marker-gated additions rather than runtime skips.
8787
"container": 72, # was 71; +1 Puppet-management test (haos_only + inaddon_only)
88-
"haos": 45, # was 39; +1 Puppet-management test and +5 screenshot sidecar tests
89-
"haos_inaddon": 72, # was 67; +5 screenshot sidecar tests (container_only)
88+
"haos": 46, # was 45; +1 py3.14 invalidate_caches recovery e2e (container_only)
89+
"haos_inaddon": 73, # was 72; +1 py3.14 invalidate_caches recovery e2e (container_only)
9090
# Embedded backend (#1527, E2E_BACKEND=embedded). Skips exactly the container
9191
# lane's marker-skips PLUS two embedded-specific additions:
9292
# - haos_only + inaddon_only tests skip on embedded just like on container
@@ -100,7 +100,7 @@
100100
# 1) + not_on_embedded 2 = 100. Initially set to 115 as a buffer for
101101
# parametrize item-inflation; round 6 (run 28709196071) observed the exact
102102
# item count and the entry below is pinned to it.
103-
"embedded": 128, # was 127; +1 Puppet-management test (haos_only + inaddon_only)
103+
"embedded": 129, # was 128; +1 py3.14 invalidate_caches recovery e2e (not_on_embedded)
104104
# HAOS embedded backend (#1527, HAOS_TEST_MODE=embedded). A HAOS lane, so it
105105
# skips the SAME set as the external HAOS lane (container_only + inaddon_only)
106106
# PLUS two haos_embedded-specific additions:
@@ -118,7 +118,7 @@
118118
# lanes show (haos def 30 → ~35 observed; haos_inaddon def 50 → ~58) gives
119119
# ~84; initially set to 90 with a small buffer, and round 8 observed
120120
# exactly 90 — the entry below is pinned to the observed count.
121-
"haos_embedded": 102, # was 96; +1 Puppet-management test and +5 screenshot sidecar tests
121+
"haos_embedded": 103, # was 102; +1 py3.14 invalidate_caches recovery e2e (container_only)
122122
}
123123

124124

tests/src/e2e/workflows/embedded/test_embedded_server.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,40 @@
8282
_INITIAL_STATE = _REPO_ROOT / "tests" / "initial_test_state"
8383
_INTEGRATION_SRC = _REPO_ROOT / "custom_components" / "ha_mcp_tools"
8484

85+
# Run INSIDE the live HA container (a separate ``python3`` process, so HA itself
86+
# is never touched) to prove _safe_invalidate_caches() recovers from the
87+
# Python-3.14 setuptools editable-finder KeyError (#1891/#1985) on the REAL
88+
# component code + container interpreter. The container here is Python 3.13, so
89+
# the crash can't occur naturally — we inject a meta-path finder that raises the
90+
# exact KeyError while the stale placeholder is cached (what CPython 3.14's
91+
# PathFinder `del` trips on). With the fix, the helper prunes the placeholder and
92+
# the retry succeeds; revert the fix and the KeyError propagates → exit != 0.
93+
_INVALIDATE_RECOVERY_PROBE = """
94+
import sys
95+
sys.path.insert(0, "/config")
96+
from custom_components.ha_mcp_tools.embedded_server import _safe_invalidate_caches
97+
98+
PLACEHOLDER = "__editable__.homeassistant-2026.7.2.finder.__path_hook__"
99+
100+
101+
class _EditableFinderKeyErrorSim:
102+
def find_spec(self, fullname, path=None, target=None):
103+
return None
104+
105+
def invalidate_caches(self):
106+
if PLACEHOLDER in sys.path_importer_cache:
107+
raise KeyError(PLACEHOLDER)
108+
109+
110+
sys.path_importer_cache[PLACEHOLDER] = None
111+
sys.meta_path.insert(0, _EditableFinderKeyErrorSim())
112+
113+
_safe_invalidate_caches()
114+
115+
assert PLACEHOLDER not in sys.path_importer_cache, "stale placeholder not pruned"
116+
print("RECOVERY_OK")
117+
"""
118+
85119

86120
def _docker_available() -> bool:
87121
try:
@@ -258,10 +292,12 @@ def _initialize(base_url: str) -> tuple[bool, str | None]:
258292
def embedded_ha():
259293
"""Boot a dedicated HA container running the in-process MCP server entry.
260294
261-
Yields ``(base_url, session_id, config_path)`` once the in-process MCP
262-
server has installed itself, started, and registered its ingress webhook.
295+
Yields ``(base_url, session_id, config_path, container)`` once the in-process
296+
MCP server has installed itself, started, and registered its ingress webhook.
263297
``config_path`` is the bind-mounted /config dir — the LLM-API test reads
264298
``home-assistant.log`` from it to prove the registration ran inside HA.
299+
``container`` is the testcontainers handle — the invalidate-caches recovery
300+
test ``exec_run``s an in-container probe through it.
265301
"""
266302
if not _docker_available():
267303
pytest.skip("Docker is not available for the embedded-server e2e")
@@ -320,15 +356,15 @@ def embedded_ha():
320356
"in-process MCP server did not become reachable via its webhook within "
321357
f"{_READY_TIMEOUT_S}s. Container logs:\n{logs}"
322358
)
323-
yield base_url, session_id, config_path
359+
yield base_url, session_id, config_path, container
324360
finally:
325361
with contextlib.suppress(Exception):
326362
container.stop()
327363

328364

329365
class TestEmbeddedServerEndToEnd:
330366
def test_initialize_and_list_tools(self, embedded_ha):
331-
base_url, session_id, _config = embedded_ha
367+
base_url, session_id, _config, _container = embedded_ha
332368
resp = _mcp_post(
333369
base_url,
334370
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
@@ -345,7 +381,7 @@ def test_initialize_and_list_tools(self, embedded_ha):
345381
assert "ha_get_state" in names
346382

347383
def test_read_only_tool_call(self, embedded_ha):
348-
base_url, session_id, _config = embedded_ha
384+
base_url, session_id, _config, _container = embedded_ha
349385
resp = _mcp_post(
350386
base_url,
351387
{
@@ -365,6 +401,40 @@ def test_read_only_tool_call(self, embedded_ha):
365401
# The tool ran against the real HA instance and returned content.
366402
assert parsed["result"].get("content"), parsed
367403

404+
def test_invalidate_caches_recovers_from_editable_finder_keyerror(
405+
self, embedded_ha
406+
):
407+
"""_safe_invalidate_caches() recovers from the 3.14 editable-finder
408+
KeyError on the REAL component code + container interpreter (#1891/#1985).
409+
410+
The reaching-ready ``embedded_ha`` fixture already proves the happy path
411+
(bring-up runs ``_installed_ha_mcp_version`` → ``_safe_invalidate_caches``
412+
without crashing). This drives the FAILURE path: a ``python3`` process in
413+
the same container injects the exact KeyError and asserts the helper
414+
prunes the stale entry and the retry completes. Reverting the fix makes
415+
the probe exit non-zero.
416+
417+
NOTE (skip-ceiling coupling): like its siblings this module is
418+
``container_only`` + ``not_on_embedded`` (see the file-level
419+
``pytestmark``), so this test is SKIPPED on the haos, haos_inaddon,
420+
haos_embedded, and embedded lanes and RUNS only on the container lane.
421+
Each such skip counts toward ``_SKIP_CEILING_PER_LANE`` in
422+
tests/src/e2e/basic/test_backend_dispatch_smoke.py — adding this test
423+
tripped ``test_session_skipped_count_below_ceiling`` until those four
424+
ceilings were bumped by 1. Any future marker-gated test added here will
425+
trip that guard the same way until its ceilings are bumped.
426+
"""
427+
_base_url, _session_id, _config, container = embedded_ha
428+
result = container.get_wrapped_container().exec_run(
429+
["python3", "-c", _INVALIDATE_RECOVERY_PROBE]
430+
)
431+
output = (result.output or b"").decode("utf-8", "replace")
432+
assert result.exit_code == 0, (
433+
"in-container _safe_invalidate_caches() did not recover from the "
434+
f"injected editable-finder KeyError (exit {result.exit_code}):\n{output}"
435+
)
436+
assert "RECOVERY_OK" in output, output
437+
368438
def test_llm_api_registered_inside_ha(self, embedded_ha):
369439
"""The bring-up registered the toolset as an LLM API in the REAL HA.
370440
@@ -376,7 +446,7 @@ def test_llm_api_registered_inside_ha(self, embedded_ha):
376446
(registration runs right after webhook bring-up and imports the mcp
377447
SDK on the executor first), so the log is polled briefly.
378448
"""
379-
_base_url, _session_id, config_path = embedded_ha
449+
_base_url, _session_id, config_path, _container = embedded_ha
380450
log_file = config_path / "home-assistant.log"
381451
needle = "Registered the HA-MCP toolset as LLM API"
382452
deadline = time.monotonic() + 60
@@ -413,7 +483,7 @@ async def test_llm_api_client_path_full_catalog(self, embedded_ha):
413483
from mcp.client.streamable_http import streamable_http_client
414484
from voluptuous_openapi import convert_to_voluptuous
415485

416-
base_url, _session_id, _config = embedded_ha
486+
base_url, _session_id, _config, _container = embedded_ha
417487
url = f"{base_url}/api/webhook/{_WEBHOOK_ID}"
418488

419489
async with asyncio.timeout(120):

tests/src/unit/test_embedded_server.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,6 +1402,110 @@ def _version(name):
14021402
assert es._installed_dist_version(DIST_NAME_STABLE) is None
14031403

14041404

1405+
class TestSafeInvalidateCaches:
1406+
"""Guard for the Python-3.14 setuptools editable-finder KeyError (#1891, #1985).
1407+
1408+
On Python 3.14, ``homeassistant`` as a setuptools *editable* install makes
1409+
``PathFinder.invalidate_caches()`` raise ``KeyError`` at its
1410+
``del sys.path_importer_cache[name]`` line, aborting
1411+
``importlib.invalidate_caches()`` and crashing in-process bring-up. The
1412+
helper prunes the stale entries CPython choked on, then re-runs the call so
1413+
the full sweep (finder invalidation + namespace-path epoch + metadata)
1414+
completes.
1415+
"""
1416+
1417+
_EDITABLE_KEY = "__editable__.homeassistant-2026.7.2.finder.__path_hook__"
1418+
1419+
@pytest.fixture(autouse=True)
1420+
def _isolate_path_cache(self, monkeypatch):
1421+
# Recovery prunes sys.path_importer_cache entries; hand each test a
1422+
# private copy so it never mutates the real process cache.
1423+
monkeypatch.setattr(sys, "path_importer_cache", dict(sys.path_importer_cache))
1424+
1425+
def _raise_once(self, monkeypatch, *, then=None):
1426+
"""Patch invalidate_caches to raise the editable KeyError on the first
1427+
call, then delegate to ``then`` (a no-op by default) on the retry."""
1428+
calls = []
1429+
1430+
def _invalidate():
1431+
calls.append(1)
1432+
if len(calls) == 1:
1433+
raise KeyError(self._EDITABLE_KEY)
1434+
if then is not None:
1435+
then()
1436+
1437+
monkeypatch.setattr(es.importlib, "invalidate_caches", _invalidate)
1438+
return calls
1439+
1440+
def test_no_recovery_when_first_call_succeeds(self, monkeypatch):
1441+
calls = []
1442+
monkeypatch.setattr(es.importlib, "invalidate_caches", lambda: calls.append(1))
1443+
es._safe_invalidate_caches()
1444+
assert calls == [1] # succeeded first try, no retry
1445+
1446+
def test_prunes_stale_entries_then_retries(self, monkeypatch):
1447+
calls = self._raise_once(monkeypatch)
1448+
sys.path_importer_cache.clear()
1449+
sys.path_importer_cache["/abs/deps/dir"] = object() # abs + live → kept
1450+
sys.path_importer_cache[self._EDITABLE_KEY] = None # non-abs None → pruned
1451+
sys.path_importer_cache[""] = None # relative None → pruned
1452+
# Non-absolute key with a LIVE (non-None) finder: the None disjunct is
1453+
# False here, so ONLY the ``not os.path.isabs`` disjunct can prune it —
1454+
# this independently exercises the abspath branch (a real editable
1455+
# placeholder has a live finder, not None).
1456+
sys.path_importer_cache["rel/not/absolute"] = object()
1457+
1458+
es._safe_invalidate_caches()
1459+
1460+
assert len(calls) == 2 # aborted once, retried once
1461+
assert self._EDITABLE_KEY not in sys.path_importer_cache
1462+
assert "" not in sys.path_importer_cache
1463+
assert "rel/not/absolute" not in sys.path_importer_cache
1464+
assert "/abs/deps/dir" in sys.path_importer_cache
1465+
1466+
def test_propagates_non_keyerror(self, monkeypatch):
1467+
def _boom():
1468+
raise RuntimeError("unrelated import-system failure")
1469+
1470+
monkeypatch.setattr(es.importlib, "invalidate_caches", _boom)
1471+
with pytest.raises(RuntimeError):
1472+
es._safe_invalidate_caches()
1473+
1474+
def test_retry_keyerror_is_tolerated(self, monkeypatch):
1475+
# If a concurrent import re-adds a stale placeholder between the prune and
1476+
# the retry, the retry can raise KeyError a SECOND time. That must not
1477+
# re-crash bring-up (the helper's whole purpose) — it is logged
1478+
# best-effort and swallowed, not re-raised.
1479+
def _always_keyerror():
1480+
raise KeyError(self._EDITABLE_KEY)
1481+
1482+
monkeypatch.setattr(es.importlib, "invalidate_caches", _always_keyerror)
1483+
es._safe_invalidate_caches() # must not raise despite the retry also failing
1484+
1485+
def test_recovery_advances_namespace_epoch(self, monkeypatch):
1486+
# Codex (#1987): recovery must preserve PathFinder's namespace-path epoch
1487+
# bump so a newly-installed PEP 420 namespace portion stays discoverable.
1488+
# The retry runs the REAL invalidate_caches, which advances the epoch.
1489+
from importlib._bootstrap_external import _NamespacePath
1490+
1491+
real = importlib.invalidate_caches
1492+
self._raise_once(monkeypatch, then=real)
1493+
1494+
before = _NamespacePath._epoch
1495+
es._safe_invalidate_caches()
1496+
assert _NamespacePath._epoch > before
1497+
1498+
def test_installed_version_survives_editable_finder_keyerror(self, monkeypatch):
1499+
# End-to-end regression for the exact reported traceback:
1500+
# _installed_ha_mcp_version() -> importlib.invalidate_caches() raised
1501+
# KeyError and crashed bring-up. With the guard the version still
1502+
# resolves.
1503+
self._raise_once(monkeypatch)
1504+
monkeypatch.setattr(es.importlib.util, "find_spec", lambda name: object())
1505+
monkeypatch.setattr(importlib.metadata, "version", lambda name: "7.14.1")
1506+
assert es._installed_ha_mcp_version() == "7.14.1"
1507+
1508+
14051509
# ---------------------------------------------------------------------------
14061510
# Worker-thread env staging
14071511
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)