fix(update): self-heal venv after failed lazy backend refresh

Upgrade pip before lazy refreshes, probe core imports when a lazy
install fails, force-reinstall corrupted packages with pyproject pins,
use package-only install (no shim quarantine) for repair, and keep the
.update-incomplete marker until refresh/repair succeeds (#57828).
This commit is contained in:
HexLab98 2026-07-04 08:43:38 +07:00 • committed by Teknium
parent acbc3abe8b
commit de602b7298

View file

@ -8178,7 +8178,187 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
pass
def _refresh_active_lazy_features() -> None:
# Import probes for venv corruption after a failed lazy ``uv pip install``.
# Metadata can look fine while ``.py`` files were removed mid-install (#57828).
_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
("yaml", "SafeDumper"),
("dotenv", "load_dotenv"),
("click", "Command"),
("certifi", "contents"),
("rich", "print"),
("cryptography", "__version__"),
("jwt", "encode"),
)
_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = {
"yaml": "PyYAML",
"dotenv": "python-dotenv",
"click": "click",
"certifi": "certifi",
"rich": "rich",
"cryptography": "cryptography",
"jwt": "PyJWT",
}
def _run_package_only_install(
cmd: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Run a package-only pip/uv install without quarantining entry-point shims.
``pip install --upgrade pip`` and ``--force-reinstall <pkg>`` do not
rewrite ``hermes.exe``. The editable-install quarantine path would rename
shims without uv recreating them on Windows (#57828).
"""
_run_install_with_heartbeat(cmd, env=env)
def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
"""Map repair package names to their declared pin specs in pyproject.toml."""
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return packages
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return packages
try:
with open(pyproject, "rb") as f:
raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or []
except Exception as exc:
logger.debug("lazy refresh repair spec lookup failed: %s", exc)
return packages
name_to_spec: dict[str, str] = {}
try:
from packaging.requirements import Requirement # type: ignore
for spec in raw_deps:
try:
req = Requirement(spec)
name_to_spec[req.name.lower()] = spec.split(";", 1)[0].strip()
except Exception:
continue
except Exception:
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
key = bare.strip().split("[", 1)[0].strip().lower()
if key:
name_to_spec[key] = head
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
def _upgrade_pip_before_lazy_refresh(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Upgrade pip before lazy-backend refreshes.
Older pip (e.g. 24.0 on Python 3.11) can fail setuptools-backed source
builds during lazy installs and leave a partially-written venv (#57828).
Never raises.
"""
try:
_run_package_only_install(
install_cmd_prefix + ["install", "--upgrade", "pip"],
env=env,
)
except subprocess.CalledProcessError as exc:
logger.debug("pip upgrade before lazy refresh failed: %s", exc)
def _detect_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> list[str]:
"""Return pip distribution names whose import probes fail."""
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
if venv_python is None:
return []
probe_lines = "\n".join(
f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES
)
check_script = (
"import sys\n"
"probes = [\n"
f"{probe_lines}\n"
"]\n"
"broken = []\n"
"for mod, attr in probes:\n"
" try:\n"
" imported = __import__(mod)\n"
" if not hasattr(imported, attr):\n"
" broken.append(mod)\n"
" except Exception:\n"
" broken.append(mod)\n"
"print('\\n'.join(broken))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check_script],
capture_output=True,
text=True,
check=False,
env=env,
)
except Exception as exc:
logger.debug("lazy refresh import probe failed: %s", exc)
return []
broken_modules = [
line.strip() for line in result.stdout.splitlines() if line.strip()
]
packages: list[str] = []
seen: set[str] = set()
for mod in broken_modules:
pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
if pkg and pkg not in seen:
seen.add(pkg)
packages.append(pkg)
return packages
def _repair_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
packages: list[str],
*,
env: dict[str, str] | None = None,
) -> bool:
"""Force-reinstall ``packages`` and re-probe imports. Never raises."""
if not packages:
return True
specs = _lazy_refresh_repair_specs(packages)
try:
_run_package_only_install(
install_cmd_prefix + ["install", "--force-reinstall", *specs],
env=env,
)
except subprocess.CalledProcessError as exc:
logger.warning("lazy refresh venv repair failed: %s", exc)
return False
return not _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
def _refresh_active_lazy_features(
install_cmd_prefix: list[str] | None = None,
*,
env: dict[str, str] | None = None,
) -> bool:
"""Refresh lazy-installed backends after a code update.
When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most
@ -8192,22 +8372,27 @@ def _refresh_active_lazy_features() -> None:
activated and reinstalls them under the current pins. Features the
user never enabled stay quiet — no churn for cold backends.
Returns True when the venv is safe to use (refresh succeeded, or no
active lazy backends, or post-failure import repair succeeded). Returns
False when a failed lazy install left broken core imports that automatic
repair could not fix (#57828).
Never raises. A failure here must not block the rest of the update.
"""
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return
return True
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return
return True
if not active:
return
return True
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
@ -8218,7 +8403,7 @@ def _refresh_active_lazy_features() -> None:
# refresh_active_features is documented as never-raise, but defend
# the update flow against future regressions.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
return
results = {}
refreshed = [f for f, s in results.items() if s == "refreshed"]
current = [f for f, s in results.items() if s == "current"]
@ -8242,8 +8427,37 @@ def _refresh_active_lazy_features() -> None:
if len(reason) > 200:
reason = reason[:200] + "..."
print(f" ⚠ {feature} failed to refresh: {reason}")
print(" Backends keep their previously-installed version; rerun")
print(" `hermes update` once the upstream issue is resolved.")
if install_cmd_prefix is None:
print(" ⚠ Lazy refresh failed; rerun `hermes update` once resolved.")
return False
broken = _detect_broken_lazy_refresh_imports(
install_cmd_prefix, env=env
)
if broken:
print(" → Detected corrupted venv packages; repairing...")
if _repair_broken_lazy_refresh_imports(
install_cmd_prefix, broken, env=env
):
print(" ✓ Venv repair succeeded")
print(" Lazy backend(s) keep their previous version until refresh succeeds.")
return True
manual = " ".join(
repr(p) for p in broken
)
print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
print(
f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}"
)
return False
print(" Lazy backend(s) keep their previous version; core venv looks intact.")
print(" Rerun `hermes update` once the upstream issue is resolved.")
return True
return True
def _install_python_dependencies_with_optional_fallback(
@ -10746,13 +10960,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
# Core Python deps installed AND verified (the fallback helper runs
# _verify_core_dependencies_installed). Clear the interrupted-install
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()
install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd
lazy_env = uv_env if uv_bin else None
_refresh_active_lazy_features()
# Upgrade pip before lazy refreshes — stale pip can fail source builds
# and leave partially-written packages (#57828).
_upgrade_pip_before_lazy_refresh(install_prefix, env=lazy_env)
# Lazy refresh can corrupt the venv when a backend install fails.
# Keep the interrupted-install marker until refresh + repair succeed.
lazy_ok = _refresh_active_lazy_features(install_prefix, env=lazy_env)
if lazy_ok:
_clear_update_incomplete_marker()
else:
print(
" ⚠ Update incomplete — run `hermes` again to finish venv recovery."
)
node_failures = _update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")