unsloth/studio/backend/tests/test_namespace_shadow_guard_pr6269.py
Daniel Han cba73457df
Studio: self-heal unsloth namespace shadows; clearer failed-load messages (#6532)
* Studio: self-heal unsloth namespace-package shadows in all subprocess workers

A directory named `unsloth` (or `unsloth_zoo`) without an __init__.py on
PYTHONPATH/sys.path, a stray source checkout or a polluted PYTHONPATH, makes
`import unsloth` resolve to an empty namespace package, so a worker's
`from unsloth import FastLanguageModel` dies with a cryptic
"cannot import name ... (unknown location)".

The LLM training path already recovered from this via `_ensure_real_packages`
in trainer.py (PR #6269), but the inference, export, and embedding-training
subprocesses imported Unsloth directly with no guard. Extract that helper into
a shared, dependency-free core/import_guards.py and call it before the Unsloth
import in every subprocess: it drops the offending sys.path entries, imports
the real packages (unsloth before unsloth_zoo so the pre-zoo GPU fixes run),
then restores sys.path. trainer.py now imports the shared helper instead of its
local copy.

Covers both unsloth and unsloth_zoo and both namespace origin forms (None and
"namespace"). The existing PR #6269 test now exercises the shared helper.

* Studio: distinguish a failed model load from no model in the attach gates

A failed load never sets the checkpoint, so the image and audio attach gates
fell through to "Load a model before adding images/audio", which reads as if
the user simply forgot to pick a model rather than that the load errored. Add a
dedicated lastModelLoadError to the chat runtime store, set only when an actual
load attempt fails (not on refresh, list, status, or unload errors, which keep
using modelsError) and cleared when the next load starts. The image gate (all
three call sites) and the audio gate now use it to report a failed load and
point at the server logs, while still blocking in exactly the same cases.

* Tighten namespace-shadow guard and load-error comments
2026-06-21 22:43:31 -07:00

285 lines
11 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
"""Verification tests for PR #6269 (namespace-shadow guard).
`ensure_real_packages` (core/import_guards.py) drops namespace-package
shadow dirs (a `unsloth`/`unsloth_zoo` dir with no __init__.py on sys.path)
before `from unsloth import ...`. Order matters: `unsloth.__init__` runs its
ROCm/Windows bnb fixes before importing unsloth_zoo, so the guard must import
unsloth first. Each test runs the real guard (ast-extracted from source, no
GPU/torch) in a subprocess, with fake packages reachable only via a meta path
finder to mimic an editable/PEP 660 install where the shadow wins.
Originally defined in core/training/trainer.py; extracted to the shared
core/import_guards.py so the inference, export and embedding workers reuse it.
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
GUARD_PY = Path(__file__).resolve().parents[1] / "core" / "import_guards.py"
# ── fake package bodies ──────────────────────────────────────────────
# Real `unsloth` sets a sentinel (mimics _gpu_init's pre-zoo fixes) then
# imports unsloth_zoo, which records whether that sentinel was set first.
_REAL_UNSLOTH_INIT = textwrap.dedent(
"""
import os
with open(os.environ["GUARD_ORDER_FILE"], "a") as _f:
_f.write("unsloth\\n")
# mimic _gpu_init: pre-zoo ROCm/Windows fixes run before importing zoo
os.environ["UNSLOTH_GPU_INIT_RAN"] = "1"
import unsloth_zoo # noqa: F401
REAL = True
"""
)
_REAL_ZOO_INIT = textwrap.dedent(
"""
import os
with open(os.environ["GUARD_ORDER_FILE"], "a") as _f:
_f.write("unsloth_zoo\\n")
with open(os.environ["GUARD_SENTINEL_FILE"], "w") as _f:
_f.write(os.environ.get("UNSLOTH_GPU_INIT_RAN", "0"))
REAL = True
"""
)
# ── subprocess driver ────────────────────────────────────────────────
# Reads a JSON config, rebuilds sys.path / sys.meta_path to model the
# scenario, runs the real guard, and writes the observed result as JSON.
_DRIVER = textwrap.dedent(
"""
import ast, importlib.abc, importlib.util, json, os, sys
cfg = json.load(open(sys.argv[1]))
# Extract the real ensure_real_packages from import_guards.py source
# without importing the heavy module or its `from unsloth import ...` line.
src = open(cfg["guard_py"]).read()
tree = ast.parse(src)
fn = next(n for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name == "ensure_real_packages")
mod = ast.Module(body=[fn], type_ignores=[])
ast.fix_missing_locations(mod)
ns = {"os": os, "sys": sys}
exec(compile(mod, cfg["guard_py"], "exec"), ns)
_ensure_real_packages = ns["ensure_real_packages"]
# Under -S site-packages is off, so a shadow root placed first wins the
# path finder; the real packages come only from the meta finder below.
for root in reversed(cfg["shadow_roots"]):
sys.path.insert(0, root)
# Real packages are reachable only via a meta path finder appended AFTER
# the standard PathFinder -- the editable / PEP 660 install shape.
real_root = cfg.get("real_root")
if real_root:
class _RealFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split(".")[0] not in ("unsloth", "unsloth_zoo"):
return None
parts = fullname.split(".")
init = os.path.join(real_root, *parts, "__init__.py")
if os.path.isfile(init):
return importlib.util.spec_from_file_location(
fullname, init,
submodule_search_locations=[os.path.dirname(init)],
)
return None
sys.meta_path.append(_RealFinder())
# optionally force importlib.invalidate_caches to raise, to prove the guard
# still restores sys.path in that case
if cfg.get("raise_on_invalidate"):
def _boom():
raise RuntimeError("invalidate_caches failed")
importlib.invalidate_caches = _boom
path_before = list(sys.path)
result = {"error": None}
try:
_ensure_real_packages(*cfg["names"])
except Exception as e:
result["error"] = type(e).__name__
result["path_restored"] = list(sys.path) == path_before
of = os.environ["GUARD_ORDER_FILE"]
result["order"] = open(of).read().split() if os.path.isfile(of) else []
sf = os.environ["GUARD_SENTINEL_FILE"]
result["sentinel_when_zoo_imported"] = (
open(sf).read().strip() if os.path.isfile(sf) else None
)
def _real(name):
m = sys.modules.get(name)
return bool(m and getattr(m, "REAL", False))
result["unsloth_real"] = _real("unsloth")
result["unsloth_zoo_real"] = _real("unsloth_zoo")
json.dump(result, open(cfg["out"], "w"))
"""
)
def _make_namespace_shadow(root: Path, name: str) -> None:
"""A directory named `name` with no __init__.py -> namespace portion."""
(root / name).mkdir(parents = True, exist_ok = True)
def _make_real_pkg(root: Path) -> None:
(root / "unsloth").mkdir(parents = True, exist_ok = True)
(root / "unsloth" / "__init__.py").write_text(_REAL_UNSLOTH_INIT)
(root / "unsloth_zoo").mkdir(parents = True, exist_ok = True)
(root / "unsloth_zoo" / "__init__.py").write_text(_REAL_ZOO_INIT)
def _run(
tmp_path: Path,
*,
shadow_roots,
real: bool,
names = ("unsloth_zoo", "unsloth"),
guard_py: Path = GUARD_PY,
raise_on_invalidate: bool = False,
):
order_file = tmp_path / "order.txt"
sentinel_file = tmp_path / "sentinel.txt"
out = tmp_path / "result.json"
real_root = tmp_path / "real"
if real:
_make_real_pkg(real_root)
cfg = {
"guard_py": str(guard_py),
"shadow_roots": [str(r) for r in shadow_roots],
"real_root": str(real_root) if real else None,
"names": list(names),
"out": str(out),
"raise_on_invalidate": raise_on_invalidate,
}
cfg_path = tmp_path / "cfg.json"
cfg_path.write_text(json.dumps(cfg))
driver = tmp_path / "driver.py"
driver.write_text(_DRIVER)
env = dict(os.environ)
env["GUARD_ORDER_FILE"] = str(order_file)
env["GUARD_SENTINEL_FILE"] = str(sentinel_file)
env.pop("UNSLOTH_GPU_INIT_RAN", None)
# Drop PYTHONPATH too so nothing re-introduces the real packages onto the
# path; -S already keeps site-packages off.
env.pop("PYTHONPATH", None)
proc = subprocess.run(
# -S: skip site-packages so the installed unsloth_zoo can't shadow-beat
# the namespace portion; the real package is served by the meta finder.
[sys.executable, "-S", str(driver), str(cfg_path)],
env = env,
capture_output = True,
text = True,
timeout = 120,
)
assert (
out.is_file()
), f"driver did not produce a result\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
return json.loads(out.read_text())
# ── scenarios ────────────────────────────────────────────────────────
def test_only_zoo_shadowed_imports_unsloth_first(tmp_path):
"""Core concern: a unsloth_zoo shadow must not make the guard import
unsloth_zoo before unsloth -- that would skip _gpu_init's pre-zoo fixes."""
shadow = tmp_path / "shadow"
_make_namespace_shadow(shadow, "unsloth_zoo")
res = _run(tmp_path, shadow_roots = [shadow], real = True)
assert res["error"] is None
assert res["order"] == ["unsloth", "unsloth_zoo"], res
assert res["sentinel_when_zoo_imported"] == "1", res
assert res["unsloth_real"] and res["unsloth_zoo_real"], res
assert res["path_restored"], res
def test_both_shadowed_imports_unsloth_first(tmp_path):
shadow = tmp_path / "shadow"
_make_namespace_shadow(shadow, "unsloth")
_make_namespace_shadow(shadow, "unsloth_zoo")
res = _run(tmp_path, shadow_roots = [shadow], real = True)
assert res["error"] is None
assert res["order"] == ["unsloth", "unsloth_zoo"], res
assert res["sentinel_when_zoo_imported"] == "1", res
assert res["unsloth_real"] and res["unsloth_zoo_real"], res
assert res["path_restored"], res
def test_only_unsloth_shadowed_recovers(tmp_path):
shadow = tmp_path / "shadow"
_make_namespace_shadow(shadow, "unsloth")
res = _run(tmp_path, shadow_roots = [shadow], real = True)
assert res["error"] is None
assert res["order"] == ["unsloth", "unsloth_zoo"], res
assert res["unsloth_real"] and res["unsloth_zoo_real"], res
assert res["path_restored"], res
def test_healthy_install_is_noop(tmp_path):
"""No shadow on sys.path: the guard imports nothing and leaves sys.path."""
res = _run(tmp_path, shadow_roots = [], real = True)
assert res["error"] is None
assert res["order"] == [], res # guard short-circuits before importing
assert res["path_restored"], res
def test_real_package_absent_surfaces_module_not_found(tmp_path):
shadow = tmp_path / "shadow"
_make_namespace_shadow(shadow, "unsloth_zoo")
res = _run(tmp_path, shadow_roots = [shadow], real = False)
assert res["error"] == "ModuleNotFoundError", res
assert res["path_restored"], res # restored even on failure
def test_multiple_shadow_entries_all_removed(tmp_path):
shadow_a = tmp_path / "shadow_a"
shadow_b = tmp_path / "shadow_b"
_make_namespace_shadow(shadow_a, "unsloth_zoo")
_make_namespace_shadow(shadow_b, "unsloth_zoo")
res = _run(tmp_path, shadow_roots = [shadow_a, shadow_b], real = True)
assert res["error"] is None
# if only one offending entry were dropped, zoo would still resolve to a
# namespace shadow and unsloth_zoo_real would be False
assert res["unsloth_zoo_real"], res
assert res["order"] == ["unsloth", "unsloth_zoo"], res
assert res["path_restored"], res
def test_sys_path_restored_if_invalidate_caches_raises(tmp_path):
"""sys.path is restored even if importlib.invalidate_caches raises."""
shadow = tmp_path / "shadow"
_make_namespace_shadow(shadow, "unsloth_zoo")
res = _run(tmp_path, shadow_roots = [shadow], real = True, raise_on_invalidate = True)
assert res["error"] == "RuntimeError", res
assert res["path_restored"], res
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))