unsloth/studio/backend/utils/mlx_repair.py
Daniel Han c2a7b78f6b
Studio: exclude mlx-lm 0.31.3 (broke gemma4/qwen3_5 QK-norm load on Apple Silicon) (#6803)
* Studio: exclude mlx-lm 0.31.3 (broke gemma4/qwen3_5 QK-norm load)

mlx-lm 0.31.3 regressed the QK-norm archs: its strict load_weights rejects the
q_norm/k_norm tensors with "Received N parameters not in model", so gemma4 and
qwen3_5 checkpoints fail to load. Studio installs the MLX stack unpinned at
latest, which pulls 0.31.3. Verified on a real macos-14 runner: gemma4 fails to
load on 0.31.3 but loads and generates coherently on 0.31.2 and on git-main
(future 0.31.4). See mlx-lm #1242.

Exclude just that release (!=0.31.3) in the installer and the self-heal floor so
--upgrade still resolves to the newest good build, and treat an already-installed
0.31.3 as unsatisfied so the self-heal replaces it.

* Studio MLX: cover fresh-install path + robust bad-version compare

Address PR review:
- Fresh install.sh (Apple Silicon) runs the base 'uv pip install unsloth' with
  SKIP_STUDIO_BASE=1, skipping the guarded MLX-stack step, so transitive
  resolution could still pull mlx-lm 0.31.3. install.sh already exports
  UV_OVERRIDE -> overrides-darwin-arm64.txt before that install, so exclude
  mlx-lm 0.31.3 there too; this also strengthens the self-heal (same override).
- Match the known-bad version with parsed packaging.Version so 0.31.3 == 0.31.3.0
  (trailing-zero normalization) instead of raw string equality.

* Studio: exclude mlx-lm 0.31.3 on the fresh Apple Silicon install too

The overrides file only applies via UV_OVERRIDE when it exists relative to the
script, which is not true for a curl-piped install, and the guarded MLX step in
install_python_stack.py is skipped there (SKIP_STUDIO_BASE=1). So the base
install could still resolve the transitive mlx-lm to the broken 0.31.3. Append
mlx-lm!=0.31.3 to the base install on Apple Silicon (empty elsewhere), so the
fresh path pins away from 0.31.3 without waiting for the runtime self-heal.

* Studio: exclude mlx-lm 0.31.3 on the migrated install; keep the >=0.22.0 floor

The with-deps migrated install did not append ${_MLX_LM_EXCLUDE_ARG:-}, so a
curl-piped Apple Silicon migration (no repo overrides file, UV_OVERRIDE unset)
could resolve mlx-lm 0.31.3 transitively. Append the exclusion there, matching
the fresh install path. The no-torch migration is left alone since --no-deps
never resolves mlx-lm (same as the fresh no-torch path).

Also restore the >=0.22.0 floor in overrides-darwin-arm64.txt: a uv override
replaces the transitive constraint, so a bare !=0.31.3 could let the resolver
drop below the supported minimum that mlx_repair.py enforces at runtime.

* Triage huggingface_hub 1.22.0 / fastapi / multiprocess scanner false positives

The scan-packages gate red-failed on all three shards after transitive deps
bumped. Every new CRITICAL is a benign false positive, verified against upstream:

- huggingface_hub 1.22.0 added _sandbox.py for the remote HF sandbox feature.
  Its job-startup bootstrap string (fetch sbx-server into the container /tmp and
  exec it) and the SandboxPool host-reservation loop trip the staged-dropper and
  C2-loop heuristics; that script runs inside a remote HF container, not on the
  user machine. The bump also re-hashed the already-reviewed benign polling loops
  in hf_api.py and utils/_http.py. The PyPI artifact is byte-identical to the
  official v1.22.0 tag.
- fastapi 0.139.0 routing.py re-hashed the websocket keepalive while-True loop;
  byte-identical to upstream 0.139.0.
- multiprocess 0.70.19 forkserver.py and tests/__init__.py re-hashed the AF_UNIX
  fork-server IPC and fd-inheritance tests; genuine uqfoundation release, local
  IPC not network.

Added 7 reviewed allowlist entries (no blind regenerate). All three shards
(hf-stack, studio, extras) exit 0 locally.

* Tighten mlx-lm 0.31.3 exclusion comments

* Trim mlx-lm 0.31.3 exclusion comments
2026-07-06 19:40:06 -07:00

364 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