Files
unslothai--unsloth/studio/backend/utils/mlx_repair.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

365 lines
14 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Best-effort MLX self-heal for Apple Silicon.
On macOS, Studio enables Train/Export only when the MLX training/export stack is
usable (see utils.hardware.hardware.detect_hardware -> CHAT_ONLY). MLX is pulled
only transitively via unsloth-zoo, and a resolver backtrack (mlx-vlm ->
transformers>=5 vs the single-env transformers pin) can silently drop it, leaving
Train/Export greyed out after a reinstall/update. This reinstalls mlx by name on
a background thread, then re-detects so the gate re-opens without a manual
`unsloth studio update`.
The install mirrors the main Apple Silicon installer (install_python_stack.py):
it points UV_OVERRIDE at overrides-darwin-arm64.txt so the resolver keeps the
Studio transformers pin AND installs a current mlx-vlm, and it requires the same
minimum versions unsloth-zoo declares so a backtracked old mlx-vlm (which still
imports but breaks VLM Train/Export) is never accepted as healthy.
Mirrors the runtime backend self-heal already used for tilelang
(core.training.worker._ensure_tilelang_backend_unconditional): default-on,
best-effort, opt out with UNSLOTH_DISABLE_MLX_AUTOREPAIR=1.
"""
from __future__ import annotations
import importlib
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
import structlog
from utils.uv_path_safety import uv_safe_path
logger = structlog.get_logger(__name__)
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_MLX_AUTOREPAIR"
# Minimum versions unsloth-zoo requires on Apple Silicon (its pyproject darwin
# deps). mlx-vlm especially must be >=0.4.4: an older one still imports but
# breaks VLM Train/Export, so installing it would wrongly clear chat-only.
_MLX_MIN_VERSIONS = {"mlx": "0.22.0", "mlx-lm": "0.22.0", "mlx-vlm": "0.4.4"}
# mlx-lm 0.31.3 regressed QK-norm archs (gemma4 / qwen3_5): strict load_weights
# rejects q_norm/k_norm, so a self-heal must not pull it. mlx-lm #1242.
_MLX_BAD_VERSIONS = {"mlx-lm": ("0.31.3",)}
_MLX_PACKAGE_NAMES = tuple(_MLX_MIN_VERSIONS)
_MLX_RUNTIME_IMPORTS = ("mlx.core", "mlx_lm", "mlx_lm.sample_utils", "mlx_vlm")
def _mlx_spec(name: str, version: str) -> str:
spec = f"{name}>={version}"
for bad in _MLX_BAD_VERSIONS.get(name, ()):
spec += f",!={bad}"
return spec
MLX_PACKAGES = tuple(_mlx_spec(name, version) for name, version in _MLX_MIN_VERSIONS.items())
_MLX_REINSTALL_ARGS = tuple(
arg for name in _MLX_PACKAGE_NAMES for arg in ("--reinstall-package", name)
)
# Require pre-built wheels for the unattended self-heal. A source distribution's
# PEP 517 build backend runs arbitrary code at install time, and this install is
# default-on, resolver-driven, and runs before the post-install stack check can
# reject anything. mlx/mlx-metal ship wheels only (no sdist on PyPI) and
# mlx-lm/mlx-vlm publish py3-none-any wheels, so requiring wheels does not break a
# healthy self-heal; if a wheel is genuinely unavailable the install fails and
# Studio stays chat-only (the existing safe fallback) until `unsloth studio update`.
_ONLY_BINARY_ARG = "--only-binary=:all:"
# Allowlist of environment variables forwarded to the install subprocess. The
# self-heal runs without confirmation on the default startup path, so it must not
# hand resolver/build code the full Studio environment. Everything outside this
# set is dropped, which excludes three dangerous classes by construction:
# * secrets (HF_TOKEN, AWS_*, WANDB_API_KEY, ...) that a malicious wheel/sdist
# build hook would otherwise read straight out of os.environ;
# * package-source redirects (UV_INDEX*, UV_DEFAULT_INDEX, UV_FIND_LINKS,
# PIP_INDEX_URL, ...) so a poisoned process env cannot silently repoint the
# install at an attacker-controlled index/find-links;
# * cache-dir redirects (UV_CACHE_DIR, XDG_CACHE_HOME) so a poisoned env cannot
# point uv at an attacker-staged cache (cache poisoning / symlink writes). uv
# falls back to its safe user-owned default cache, reused across runs anyway.
# uv still honours on-disk config (uv.toml / pip.conf), so a corporate mirror
# configured there keeps working; only process-env redirects are dropped. We set
# UV_OVERRIDE ourselves in _mlx_install_env, so a poisoned one here is ignored.
_MLX_ENV_ALLOWLIST = frozenset(
{
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
# proxies + custom CA bundles so installs behind a corporate gateway work
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
}
)
_REPAIR_TIMEOUT_S = 900
# Attempt at most once per process; success is sticky (mlx then imports and the
# guard short-circuits on the next boot).
_attempted = False
_attempted_lock = threading.Lock()
def is_apple_silicon() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
def mlx_available() -> bool:
try:
import mlx.core # noqa: F401
return True
except Exception:
return False
def _mlx_runtime_imports_available() -> bool:
for module in _MLX_RUNTIME_IMPORTS:
try:
importlib.import_module(module)
except Exception:
return False
return True
def _mlx_versions_satisfy_minimums() -> bool:
try:
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _dist_version
from packaging.version import Version
except Exception:
return False
for name, minimum in _MLX_MIN_VERSIONS.items():
try:
installed = Version(_dist_version(name))
if installed < Version(minimum):
return False
# A known-broken build counts as unsatisfied so the self-heal
# reinstalls a good one; Version compare matches 0.31.3(.0/+local).
if any(installed == Version(bad) for bad in _MLX_BAD_VERSIONS.get(name, ())):
return False
except PackageNotFoundError:
return False
except Exception:
return False
return True
def mlx_stack_available() -> bool:
"""`import mlx.core` works AND mlx/mlx-lm/mlx-vlm meet unsloth-zoo's minimums.
Check distribution versions before imports so a too-old but importable MLX
module is not loaded into this process before repair can replace it."""
if not _mlx_versions_satisfy_minimums():
return False
return _mlx_runtime_imports_available()
def _uv_executable() -> str | None:
"""Find uv even when macOS GUI launchers start with a minimal PATH."""
found = shutil.which("uv")
if found:
return found
for candidate in (
Path.home() / ".local" / "bin" / "uv",
Path.home() / ".cargo" / "bin" / "uv",
Path("/opt/homebrew/bin/uv"),
Path("/usr/local/bin/uv"),
):
try:
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
except OSError:
continue
return None
def _uv_install_cmd(*args: str) -> list[str] | None:
uv = _uv_executable()
if not uv:
return None
return [uv, "pip", "install", "--python", sys.executable, *args]
def _mlx_install_env() -> dict[str, str]:
"""Minimal, allowlisted environment for the unattended mlx install.
The self-heal runs without confirmation on the default startup path, so it
forwards only the variables uv genuinely needs (see _MLX_ENV_ALLOWLIST) instead
of the full Studio environment: secrets and package-source redirects in
os.environ are dropped so a malicious resolver-selected artifact cannot read
Studio secrets or be steered to a hostile index.
Mirror the main installer (install_python_stack.py) by pointing UV_OVERRIDE at
overrides-darwin-arm64.txt, which relaxes mlx-vlm/mlx-lm's transformers>=5
requirement to >=4.57.6. Without it, uv keeps the Studio transformers pin only
by silently backtracking mlx-vlm to an old, unsupported version (uv honours
UV_OVERRIDE; plain pip ignores it, so the transformers constraint below is the
pip-path safety net). We set UV_OVERRIDE ourselves, so a poisoned one in the
process env is ignored."""
env = {key: os.environ[key] for key in _MLX_ENV_ALLOWLIST if key in os.environ}
override = (
Path(__file__).resolve().parents[1]
/ "requirements"
/ "single-env"
/ "overrides-darwin-arm64.txt"
)
if override.is_file():
# uv truncates UV_OVERRIDE at the first space (issue #6503).
env.setdefault("UV_OVERRIDE", uv_safe_path(override))
return env
def _transformers_constraint_args() -> tuple[list[str], str | None]:
"""Pin transformers to the running version for the mlx install.
The install must never upgrade transformers underneath a running Studio
(the single-env install pins transformers==4.57.6). With UV_OVERRIDE set this
is belt-and-suspenders; on the plain-pip path (no UV_OVERRIDE support) it is
the actual guard -- the resolver either finds an mlx build compatible with the
pin or fails, leaving us chat-only rather than breaking Studio. Returns
(pip args, temp file path to clean up).
Read the version from installed metadata rather than `import transformers`:
transformers can have valid metadata yet fail to import (e.g. an incompatible
huggingface_hub), and in that case we still want to pin it so the mlx install
cannot quietly upgrade it out from under Studio."""
from importlib.metadata import PackageNotFoundError, version as _dist_version
try:
transformers_version = _dist_version("transformers")
except PackageNotFoundError:
return [], None
except Exception:
return [], None
fd, path = tempfile.mkstemp(prefix = "mlx_repair_", suffix = ".txt")
with os.fdopen(fd, "w") as fh:
fh.write(f"transformers=={transformers_version}\n")
return ["--constraint", path], path
def attempt_mlx_repair(*, timeout: int = _REPAIR_TIMEOUT_S) -> bool:
"""Install a usable mlx/mlx-lm/mlx-vlm stack by name into the running venv.
Best-effort; returns True iff the resulting stack meets unsloth-zoo's minimums
(so a backtracked old mlx-vlm is rejected, not accepted). transformers is held
at its pinned version so the install can never upgrade it underneath Studio."""
# Prepare the constraint inside the try: this runs on a daemon thread, so an
# exception here (e.g. tempfile.mkstemp failing on a full disk or bad TMPDIR)
# must leave Studio chat-only, not crash the background self-heal thread.
constraint_path = None
try:
constraint_args, constraint_path = _transformers_constraint_args()
cmd = _uv_install_cmd(
"--upgrade",
_ONLY_BINARY_ARG,
*_MLX_REINSTALL_ARGS,
*constraint_args,
*MLX_PACKAGES,
)
if cmd is None:
logger.warning(
"MLX self-heal requires uv so Studio can apply dependency overrides; "
"staying chat-only. Run `unsloth studio update` to restore uv."
)
return False
logger.info("MLX self-heal: installing %s", ", ".join(MLX_PACKAGES))
result = subprocess.run(
cmd,
env = _mlx_install_env(),
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
timeout = timeout,
)
except subprocess.TimeoutExpired:
logger.warning("MLX self-heal timed out after %ss; staying chat-only", timeout)
return False
except Exception as exc: # pragma: no cover - environment dependent
logger.warning("MLX self-heal could not start: %s", exc)
return False
finally:
if constraint_path and os.path.exists(constraint_path):
try:
os.remove(constraint_path)
except OSError:
pass
if result.returncode != 0:
tail = (result.stdout or "")[-2000:]
logger.warning("MLX self-heal failed (staying chat-only):\n%s", tail)
return False
importlib.invalidate_caches()
if not mlx_stack_available():
logger.warning(
"MLX self-heal produced an incomplete or too-old MLX stack "
"(need %s); staying chat-only.",
", ".join(f"{name}>={ver}" for name, ver in _MLX_MIN_VERSIONS.items()),
)
return False
return True
def _run_repair_and_redetect() -> None:
if not attempt_mlx_repair():
return
try:
from utils.hardware import hardware as hw
hw.detect_hardware() # flips CHAT_ONLY / DEVICE now that mlx imports
logger.info(
"MLX self-heal succeeded; Train/Export enabled (reload the page). chat_only=%s",
hw.CHAT_ONLY,
)
except Exception as exc: # pragma: no cover - defensive
logger.warning("MLX installed but hardware re-detection failed: %s", exc)
def start_mlx_autorepair_if_needed() -> bool:
"""If this is an Apple Silicon host whose MLX stack is missing or too old,
reinstall it on a daemon thread (off the startup critical path) and re-detect
on success. Returns True iff a repair thread was started. No-op (returns False)
off Apple Silicon, when the stack is already adequate, when already attempted
this process, or when disabled via UNSLOTH_DISABLE_MLX_AUTOREPAIR=1."""
global _attempted
if os.environ.get(DISABLE_ENV_VAR) == "1":
return False
if not is_apple_silicon():
return False
if mlx_stack_available():
return False
with _attempted_lock:
if _attempted:
return False
_attempted = True
logger.warning(
"Apple Silicon without a usable MLX stack; attempting a one-time background "
"reinstall of mlx/mlx-lm/mlx-vlm to re-enable Train/Export. "
"Set %s=1 to disable.",
DISABLE_ENV_VAR,
)
threading.Thread(
target = _run_repair_and_redetect,
daemon = True,
name = "mlx-autorepair",
).start()
return True