unsloth/tests/python/test_docker_update_helpers.py
Daniel Han 50b9deacb0
Trim comments in tests (#13844)
Comment and docstring changes only, in test files, split out of #12968 so the test-only part can merge first.
2026-10-15 05:30:27 -07:00

1123 lines
51 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-Present the Unsloth team. See /studio/LICENSE.AGPL-3.0
"""studio-update only warned on a failed import; llama-update --check said current when unreachable."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
STUDIO_UPDATE = REPO_ROOT / "docker" / "unsloth_studio_update.sh"
LLAMA_UPDATE = REPO_ROOT / "docker" / "unsloth_llama_update.sh"
pytestmark = pytest.mark.skipif(
shutil.which("bash") is None,
reason = "needs bash",
)
def _stub(directory: Path, name: str, body: str) -> None:
directory.mkdir(parents = True, exist_ok = True)
path = directory / name
path.write_text("#!/usr/bin/env bash\n" + body, encoding = "utf-8")
path.chmod(0o755)
def _run(
script: Path,
args,
env,
cwd = None,
):
return subprocess.run(
["bash", str(script), *args],
capture_output = True,
text = True,
env = env,
cwd = cwd,
timeout = 120,
)
def _studio_env(
tmp_path: Path,
*,
import_ok: bool = True,
dist_ok: bool = True,
status_exit: int = 0,
git_ls_exit: int = 0,
npm_build_ok: bool = True,
src_link: bool = False,
restart_exit: int = 0,
) -> dict:
"""A Studio home whose venv python is the real interpreter over a fake `studio`
package, so the script's own import and frontend checks run for real. pip,
supervisorctl, git and the bundled npm are recording stubs."""
home = tmp_path / "studio"
site = tmp_path / "site"
(site / "studio" / "backend").mkdir(parents = True)
(site / "studio" / "__init__.py").write_text("")
(site / "studio" / "backend" / "__init__.py").write_text("")
(site / "studio" / "backend" / "main.py").write_text(
"" if import_ok else "raise ImportError('No module named structlog')\n"
)
for name, ver in (("unsloth", "2026.9.4"), ("unsloth_zoo", "2026.9.3")):
info = site / f"{name}-{ver}.dist-info"
info.mkdir(parents = True)
(info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {name}\nVersion: {ver}\n")
if dist_ok:
(site / "studio" / "frontend" / "dist").mkdir(parents = True)
(site / "studio" / "frontend" / "dist" / "index.html").write_text("<html></html>")
src = (tmp_path / "app" / "src") if src_link else (home / "src")
(src / "studio").mkdir(parents = True)
(src / "OLD_TREE").write_text("previous source tree\n")
if src_link:
home.mkdir(parents = True, exist_ok = True)
(home / "src").symlink_to(src)
venv_bin = home / "unsloth_studio" / "bin"
_stub(
venv_bin,
"python",
'if [ "$1" = "-m" ] && [ "$2" = "pip" ]; then\n'
' echo "STUB-PIP $*" >> "$STUB_LOG"\n'
# STUB_FREEZE_EXIT fails every freeze; STUB_FREEZE_EXIT_AFTER only the ones after an install
' if [ "$3" = "freeze" ]; then [ -n "${STUB_FREEZE_EXIT:-}" ] && exit "$STUB_FREEZE_EXIT"; [ -n "${STUB_FREEZE_EXIT_AFTER:-}" ] && [ -e "$STUB_LOG.installed" ] && exit "$STUB_FREEZE_EXIT_AFTER"; echo "transformers==4.0.0"; echo "torch==2.11.0+cu128"; [ -e "$STUB_LOG.installed" ] && echo "newdep==1.0"; exit 0; fi\n'
' case " $* " in *" -r "*) ;; *" install "*) : > "$STUB_LOG.installed" ;; esac\n'
' _c=0; for _a in "$@"; do [ "$_c" = 1 ] && { echo "STUB-PIP-CONSTRAINTS $(tr "\\n" " " < "$_a")" >> "$STUB_LOG"; _c=0; }; [ "$_a" = "-c" ] && _c=1; done\n'
' _r=0; for _a in "$@"; do [ "$_r" = 1 ] && { echo "STUB-PIP-REQ $(tr "\\n" " " < "$_a")" >> "$STUB_LOG"; _r=0; }; [ "$_a" = "-r" ] && _r=1; done\n'
# The updater waits on this child, so the signal lands on it and its trap runs on exit.
' case " $* " in *" -e "*) [ -n "${STUB_PIP_INTERRUPT:-}" ] && kill -INT "$PPID" ;; esac\n'
' case " $* " in *" -U "*) [ -n "${STUB_PIP_INTERRUPT_RELEASE:-}" ] && kill -INT "$PPID" ;; esac\n'
' case " $* " in *" -r "*) [ -n "${STUB_PIP_INTERRUPT_RESTORE:-}" ] && kill -TERM "$PPID" ;; esac\n'
' case " $* " in *" -r "*)\n'
f' : > "{site}/studio/backend/main.py"\n'
f' mkdir -p "{site}/studio/frontend/dist"; echo ok > "{site}/studio/frontend/dist/index.html" ;;\n'
" esac\n"
# STUB_PIP_INSTALL_EXIT spares the restore's -r installs; STUB_PIP_EXIT fails every call.
' case " $* " in *" -r "*) ;; *) [ -n "${STUB_PIP_INSTALL_EXIT:-}" ] && exit "$STUB_PIP_INSTALL_EXIT" ;; esac\n'
' exit "${STUB_PIP_EXIT:-0}"\n'
"fi\n"
f'PYTHONPATH="{site}" exec "{shutil.which("python3")}" "$@"\n',
)
_stub(
home / "node" / "bin",
"npm",
'echo "STUB-NPM $* in $PWD" >> "$STUB_LOG"\n'
'if [ "$1" = "ci" ]; then exit "${STUB_NPM_CI_EXIT:-0}"; fi\n'
'if [ "$*" = "run build" ]; then\n'
+ (
" mkdir -p dist && echo '<html></html>' > dist/index.html; exit 0\n"
if npm_build_ok
else " exit 1\n"
)
+ "fi\nexit 0\n",
)
bin_dir = tmp_path / "bin"
_stub(
bin_dir,
"supervisorctl",
'echo "STUB-SUPERVISORCTL $*" >> "$STUB_LOG"\n'
f'if [ "$1" = "status" ]; then [ -e "$STUB_LOG.started" ] && exit 0; exit {status_exit}; fi\n'
f'if [ "$1" = "restart" ] || [ "$1" = "start" ]; then [ {restart_exit} = 0 ] && : > "$STUB_LOG.started"; exit {restart_exit}; fi\nexit 0\n',
)
_stub(
bin_dir,
"git",
'dir=""; if [ "$1" = "-C" ]; then dir="$2"; shift 2; fi\n'
'case "$1" in\n'
f" ls-remote) exit {git_ls_exit} ;;\n"
' checkout) mkdir -p "$dir/studio/frontend/src" "$dir/.git"'
' "$dir/studio/backend/core/data_recipe/oxc-validator";'
' echo "{}" > "$dir/studio/frontend/package.json";'
' [ -n "${STUB_LOCKFILE:-}" ] && echo "{}" > "$dir/studio/frontend/package-lock.json";'
' echo "{}" > "$dir/studio/backend/core/data_recipe/oxc-validator/package.json";'
' [ -n "${STUB_OXC_LOCKFILE:-}" ] && echo "{}" > "$dir/studio/backend/core/data_recipe/oxc-validator/package-lock.json";'
' echo new > "$dir/NEW_TREE" ;;\n'
"esac\nexit 0\n",
)
env = dict(os.environ)
env["PATH"] = f"{bin_dir}{os.pathsep}" + env["PATH"]
env["UNSLOTH_STUDIO_HOME"] = str(home)
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "0"
env["STUB_LOG"] = str(tmp_path / "calls.log")
return env
def _scratch(d: Path, pattern: str = ".src-*") -> list:
"""The updater's scratch entries, minus the lock file it keeps on purpose."""
return [p for p in d.glob(pattern) if p.name != ".src-update.lock"]
def _calls(env) -> str:
log = Path(env["STUB_LOG"])
return log.read_text() if log.exists() else ""
def test_studio_update_restarts_when_the_backend_imports(tmp_path: Path):
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert "STUB-SUPERVISORCTL restart studio" in _calls(env), _calls(env)
def test_studio_update_does_not_restart_into_a_backend_that_cannot_import(tmp_path: Path):
env = _studio_env(tmp_path, import_ok = False)
res = _run(STUDIO_UPDATE, [], env)
calls = _calls(env)
assert "STUB-SUPERVISORCTL restart studio" not in calls, (
"restarting into code that cannot import kills a process that is serving "
"fine and parks supervisord's studio program in FATAL:\n" + calls
)
assert res.returncode != 0, "a broken update must not report success"
assert "--with-deps" in res.stdout, "the remedy must still be printed"
# --force-reinstall: pip treats a same-version editable as already satisfying the pin.
assert "install --no-deps --force-reinstall -r" in calls, (
"the previous install must be put back:\n" + calls
)
def test_studio_update_does_not_restart_into_a_tree_without_a_built_frontend(tmp_path: Path):
"""The --ref failure: a git build has no studio/frontend/dist, `unsloth studio`
exits 1 on start, and three quick exits leave supervisord's program FATAL."""
env = _studio_env(tmp_path, dist_ok = False)
res = _run(STUDIO_UPDATE, [], env)
calls = _calls(env)
assert res.returncode != 0
assert "no built frontend" in res.stdout
assert "STUB-SUPERVISORCTL restart studio" not in calls, calls
assert "install --no-deps --force-reinstall -r" in calls, calls
@pytest.mark.parametrize("status_exit, verb", [(0, "restart"), (3, "start")])
def test_studio_update_restarts_a_studio_that_is_not_running(tmp_path: Path, status_exit, verb):
"""`supervisorctl status` exits 3 for STOPPED/EXITED/FATAL. That is still a program
supervisord manages, and FATAL is what a failed earlier update left behind. It gets
`start`: `restart` stops it first, which supervisorctl reports as an error."""
env = _studio_env(tmp_path, status_exit = status_exit)
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert f"STUB-SUPERVISORCTL {verb} studio" in _calls(env), _calls(env)
assert "not managing" not in res.stdout
def test_studio_update_fails_when_supervisor_cannot_restart_studio(tmp_path: Path):
"""A restart that fails used to leave the new install in place with Studio down and
the previous tree already deleted. The previous tree is kept until the service is
up, so a failed restart puts it back and starts that."""
env = _studio_env(tmp_path, restart_exit = 1)
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env)
assert res.returncode != 0, res.stdout
assert "ERROR: supervisorctl restart studio failed" in res.stdout, res.stdout
assert "previous install is back in place" in res.stderr, res.stderr
assert (home / "src" / "OLD_TREE").exists(), "the previous source tree was not put back"
assert not (home / "src" / "NEW_TREE").exists()
assert not _scratch(home)
assert "install --no-deps --force-reinstall -r" in calls, calls
sup = [l for l in calls.splitlines() if l.startswith("STUB-SUPERVISORCTL")]
assert sup[-1] == "STUB-SUPERVISORCTL status studio", calls
assert "STUB-SUPERVISORCTL restart studio" in sup[1:], (
"the previous install must be started again:\n" + calls
)
env = _studio_env(tmp_path / "release", restart_exit = 1)
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode != 0, res.stdout
assert "install --no-deps --force-reinstall -r" in _calls(env), _calls(env)
def test_studio_update_refuses_to_run_beside_another_updater(tmp_path: Path):
"""Two updaters at once would take each other's staging and previous trees for
leftovers and swap over each other's src."""
fcntl = pytest.importorskip("fcntl")
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
lock = home / ".src-update.lock"
fd = os.open(lock, os.O_WRONLY | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
try:
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
assert res.returncode == 1, res.stderr + res.stdout
assert "another unsloth-studio-update is running" in res.stderr, res.stderr
assert "STUB-PIP" not in _calls(env) and "STUB-NPM" not in _calls(env), _calls(env)
assert (home / "src" / "OLD_TREE").exists()
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
assert res.returncode == 0, res.stderr + res.stdout
assert (home / "src" / "NEW_TREE").exists()
# The lock file shares the scratch prefix; the startup sweep must not treat it as leftover.
assert "left behind" not in res.stdout, res.stdout
assert not _scratch(home), "the lock file must not outlive the run"
def test_studio_update_reports_an_unmanaged_studio(tmp_path: Path):
env = _studio_env(tmp_path, status_exit = 4)
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert "STUB-SUPERVISORCTL restart studio" not in _calls(env)
assert "not managing 'studio'" in res.stdout
def test_studio_update_ref_builds_the_frontend_and_swaps_the_source_tree(tmp_path: Path):
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
calls = _calls(env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode == 0, res.stderr + res.stdout
assert "STUB-NPM run build" in calls, calls
assert (home / "src" / "NEW_TREE").exists(), "src was not replaced by the fetched ref"
assert (home / "src" / "studio" / "frontend" / "dist" / "index.html").exists()
assert not (home / "src" / ".git").exists()
assert not _scratch(home, ".src-prev.*"), "the previous tree was not cleaned up"
assert not _scratch(home, ".src-update.*"), "the staging tree was not cleaned up"
assert f"install --no-deps -e {home / 'src'}" in calls, calls
def test_studio_update_ref_writes_through_a_linked_source_tree(tmp_path: Path):
"""Replacing that link with a directory would put the tree outside the image's copy,
where the next container start relinks over it and the update is silently gone."""
env = _studio_env(tmp_path, src_link = True)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
real_src = tmp_path / "app" / "src"
assert res.returncode == 0, res.stderr + res.stdout
assert (home / "src").is_symlink(), "the link was replaced by a directory"
assert (real_src / "NEW_TREE").exists(), "the ref was not installed where the code lives"
assert not _scratch(home), "staging trees must not land in the data volume"
assert not _scratch(real_src.parent, ".src-prev.*")
assert f"install --no-deps -e {home / 'src'}" in _calls(env), _calls(env)
def test_studio_update_ref_with_a_failed_frontend_build_changes_nothing(tmp_path: Path):
env = _studio_env(tmp_path, npm_build_ok = False)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env)
assert res.returncode != 0
assert (home / "src" / "OLD_TREE").exists(), "the running source tree was touched"
assert "STUB-PIP" not in calls, calls
assert not _scratch(home, ".src-update.*")
# errexit is off inside `( ... ) || return`, so the build step must stop by itself.
assert "npm run build failed" in res.stdout, res.stdout
assert "oxc-validator" not in calls, (
"a failed build must not go on to the oxc install:\n" + calls
)
def test_studio_update_ref_installs_the_oxc_runtime_after_a_good_build(tmp_path: Path):
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
oxc = [
l
for l in _calls(env).splitlines()
if "STUB-NPM install" in l and l.endswith("oxc-validator")
]
assert oxc, _calls(env)
env = _studio_env(tmp_path / "locked")
env["STUB_OXC_LOCKFILE"] = "1"
env["UNSLOTH_NPM_REGISTRY"] = "https://mirror.example/npm/"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
oxc = [l for l in _calls(env).splitlines() if "STUB-NPM" in l and l.endswith("oxc-validator")]
assert len(oxc) == 1 and oxc[0].startswith("STUB-NPM ci "), _calls(env)
assert "--registry https://mirror.example/npm/" in oxc[0], oxc[0]
env = _studio_env(tmp_path / "drift")
env["STUB_OXC_LOCKFILE"] = "1"
env["STUB_NPM_CI_EXIT"] = "1"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode != 0
assert "npm ci failed for the oxc validator" in res.stdout, res.stdout
assert not [
l
for l in _calls(env).splitlines()
if "STUB-NPM install" in l and l.endswith("oxc-validator")
], "lockfile drift must not be re-resolved:\n" + _calls(env)
assert (Path(env["UNSLOTH_STUDIO_HOME"]) / "src" / "OLD_TREE").exists()
def test_studio_update_rollback_keeps_the_editable_uri_pip_recorded(tmp_path: Path):
"""direct_url.json holds a file:// URI; a path with a space comes back percent
encoded, and pip rejects that as a bare path but takes it as the URI."""
env = _studio_env(tmp_path, import_ok = False)
site = tmp_path / "site"
info = site / "unsloth-2026.9.4.dist-info" # seeded by _studio_env
(info / "direct_url.json").write_text(
'{"url": "file:///opt/my%20studio/src", "dir_info": {"editable": true}}'
)
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
assert res.returncode != 0
assert "STUB-PIP-REQ -e file:///opt/my%20studio/src" in _calls(env), _calls(env)
def test_studio_update_ref_restores_the_tree_when_interrupted_after_the_swap(tmp_path: Path):
"""Ctrl-C or `docker stop` between the swap and the checks left the half-installed
tree in place and the previous one beside it as .src-prev.*, which the next
container start links into the Studio home."""
env = _studio_env(tmp_path)
env["STUB_PIP_INTERRUPT"] = "1"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode == 130, res.stderr + res.stdout
assert (home / "src" / "OLD_TREE").exists(), "the previous source tree was not put back"
assert not (home / "src" / "NEW_TREE").exists()
assert not _scratch(home, ".src-prev.*"), "the previous tree was left beside src"
assert not _scratch(home, ".src-update.*")
assert "interrupted" in res.stdout, res.stdout
def test_studio_update_release_restores_the_pins_when_interrupted_during_pip(tmp_path: Path):
"""The release path swaps nothing, but a pip interrupted half-way can have replaced
the packages already; the recorded previous install goes back the same way."""
env = _studio_env(tmp_path)
env["STUB_PIP_INTERRUPT_RELEASE"] = "1"
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
calls = _calls(env)
assert res.returncode == 130, res.stderr + res.stdout
assert "install --no-deps --force-reinstall -r" in calls, (
"the previous install was not put back:\n" + calls
)
assert "interrupted" in res.stdout, res.stdout
def test_studio_update_restores_once_and_leaves_no_temp_files_when_signalled_mid_restore(
tmp_path: Path,
):
"""After a failed tree check the script restores explicitly, then exits; the exit
trap must not restore a second time, and a signal during the restore must not skip
the temp-file cleanup."""
env = _studio_env(tmp_path, dist_ok = False)
env["STUB_PIP_INTERRUPT_RESTORE"] = "1"
tmpd = tmp_path / "tmpd"
tmpd.mkdir()
env["TMPDIR"] = str(tmpd)
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
calls = _calls(env)
assert res.returncode != 0
assert calls.count("--force-reinstall -r") == 1, "restore ran more than once:\n" + calls
assert not list(tmpd.iterdir()), "temp files left behind: " + str(list(tmpd.iterdir()))
def test_studio_update_ref_refuses_a_venv_without_a_source_tree(tmp_path: Path):
"""A wheel-only install has no src; `mv` of a missing tree must not be the error."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
shutil.rmtree(home / "src")
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode != 0
assert "no source tree" in res.stderr, res.stderr
assert "STUB-NPM" not in _calls(
env
), "nothing should be built for a tree that cannot be swapped"
def test_studio_update_with_deps_puts_the_dependency_set_back(tmp_path: Path):
"""--with-deps lets pip move every dependency; restoring unsloth alone would leave
the new dependency set under the old code."""
env = _studio_env(tmp_path, import_ok = False)
res = _run(STUDIO_UPDATE, ["--with-deps"], env)
calls = _calls(env)
assert res.returncode != 0
assert "STUB-PIP -m pip freeze --exclude-editable" in calls, calls
assert calls.count("install --no-deps -r") == 1, "dependency snapshot:\n" + calls
assert calls.count("install --no-deps --force-reinstall -r") == 1, "previous install:\n" + calls
# torch/CUDA must not be re-resolved: the venv's nvidia libs are linked into the base venv.
assert "unsloth[studio]" in calls, calls
assert "STUB-PIP-CONSTRAINTS torch==2.11.0+cu128" in calls, calls
assert "transformers==4.0.0" not in [
l for l in calls.splitlines() if l.startswith("STUB-PIP-CONSTRAINTS")
], "only the torch/CUDA stack is a constraint"
env = _studio_env(tmp_path / "nodeps", import_ok = False)
res = _run(STUDIO_UPDATE, [], env)
assert "freeze" not in _calls(env), "a --no-deps update has nothing to snapshot"
assert " -c " not in _calls(env)
def test_studio_update_ref_with_deps_installs_the_studio_extra(tmp_path: Path):
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--with-deps", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode == 0, res.stderr + res.stdout
assert f"-e {home / 'src'}[studio]" in _calls(env), _calls(env)
assert "STUB-PIP-CONSTRAINTS torch==2.11.0+cu128" in _calls(env), _calls(env)
def test_studio_update_ref_puts_the_old_tree_back_when_the_new_one_cannot_start(tmp_path: Path):
env = _studio_env(tmp_path, import_ok = False)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode != 0
assert (home / "src" / "OLD_TREE").exists(), "the previous source tree was not restored"
assert not (home / "src" / "NEW_TREE").exists()
def _zoo_spec(calls: str) -> str:
for line in calls.splitlines():
for token in line.split():
if token.startswith("git+https://github.com/unslothai/unsloth-zoo.git@"):
return token
return ""
def test_studio_update_mirrors_the_ref_when_the_zoo_has_it(tmp_path: Path):
env = _studio_env(tmp_path, git_ls_exit = 0)
res = _run(STUDIO_UPDATE, ["--ref", "v2026.7.5", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
assert _zoo_spec(_calls(env)).endswith("@v2026.7.5#egg=unsloth_zoo"), _calls(env)
def test_studio_update_falls_back_to_zoo_main_when_the_ref_is_absent(tmp_path: Path):
env = _studio_env(tmp_path, git_ls_exit = 2)
res = _run(STUDIO_UPDATE, ["--ref", "v2026.7.5", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
assert _zoo_spec(_calls(env)).endswith("@main#egg=unsloth_zoo"), _calls(env)
assert "has no ref" in res.stdout, res.stdout
def test_studio_update_aborts_when_the_zoo_lookup_never_reached_the_remote(tmp_path: Path):
# Exit 2 and 128 differ: conflating them pairs unrelated unsloth and zoo revisions.
env = _studio_env(tmp_path, git_ls_exit = 128)
res = _run(STUDIO_UPDATE, ["--ref", "v2026.7.5", "--no-restart"], env)
calls = _calls(env)
assert "STUB-PIP" not in calls, "a transport failure must not install anything:\n" + calls
assert res.returncode != 0, "an unresolvable zoo ref must not report success"
assert "has no ref" not in res.stdout, (
"an unreachable remote must not be reported as a missing ref:\n" + res.stdout
)
assert "--zoo-ref" in res.stderr, "the remedy must be printed"
def test_studio_update_puts_the_install_back_when_pip_itself_fails(tmp_path: Path):
env = _studio_env(tmp_path)
env["STUB_PIP_INSTALL_EXIT"] = "1"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode != 0
assert "previous install is back in place" in res.stderr, res.stderr
assert (home / "src" / "OLD_TREE").exists(), "the previous source tree was not put back"
assert not _scratch(home)
assert "STUB-SUPERVISORCTL" not in _calls(env)
def test_a_signal_during_the_health_wait_puts_the_service_back_too(tmp_path: Path):
"""Ctrl-C while waiting for /api/health: the service is already running the new
code, so restoring the files alone would leave it serving a mix of versions.
The cleanup restarts it on the restored install, as back_out does."""
env = _studio_env(tmp_path)
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "30"
_stub(
tmp_path / "bin",
"curl",
'echo "STUB-CURL $*" >> "$STUB_LOG"\nkill -TERM "$PPID"\nexit 22\n',
)
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env).splitlines()
assert res.returncode == 143, res.stdout + res.stderr
assert "interrupted after the install started" in res.stdout, res.stdout
assert (home / "src" / "OLD_TREE").exists() and not (home / "src" / "NEW_TREE").exists()
reinst = [i for i, l in enumerate(calls) if "install --no-deps --force-reinstall -r" in l]
assert reinst, calls
after = calls[reinst[-1] + 1 :]
assert "STUB-SUPERVISORCTL restart studio" in after, calls
assert "the previous install is running again" in res.stdout, res.stdout
assert not _scratch(home)
def test_a_health_wait_that_expires_before_its_first_check_still_asks_once(tmp_path: Path):
"""The deadline check must run at least once, or a Studio that was never probed gets rolled back."""
env = _studio_env(tmp_path)
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "1"
clock = tmp_path / "clock"
clock.write_text("1000000000", encoding = "utf-8")
_stub(
tmp_path / "bin",
"date",
'if [ "$*" = "+%s" ]; then\n'
f' t=$(( $(cat "{clock}") + 5 )); echo "$t" > "{clock}"; echo "$t"; exit 0\n'
"fi\n"
'exec /bin/date "$@"\n',
)
_stub(tmp_path / "bin", "curl", 'echo "STUB-CURL $*" >> "$STUB_LOG"\nexit 0\n')
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env)
assert "STUB-CURL" in calls, "the wait expired without probing Studio once:\n" + calls
assert res.returncode == 0, res.stdout + res.stderr
assert "answering on port 8000" in res.stdout, res.stdout
assert (home / "src" / "NEW_TREE").exists(), "a healthy update was rolled back"
def test_studio_update_fails_when_studio_does_not_answer_after_the_restart(tmp_path: Path):
"""A backend that imports can still die at startup; the previous tree is kept
until /api/health answers, and goes back when it does not."""
env = _studio_env(tmp_path)
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "1"
_stub(tmp_path / "bin", "curl", 'echo "STUB-CURL $*" >> "$STUB_LOG"\nexit 22\n')
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env)
assert res.returncode != 0, res.stdout
assert "did not answer on port 8000" in res.stdout, res.stdout
assert "STUB-CURL" in calls
# A container-wide HTTP_PROXY must not answer for the loopback probe.
assert "--noproxy * http://127.0.0.1:8000/api/health" in calls, calls
assert "previous install is back in place" in res.stderr, res.stderr
assert (home / "src" / "OLD_TREE").exists(), "the previous source tree was not put back"
assert not (home / "src" / "NEW_TREE").exists()
assert not _scratch(home)
assert "install --no-deps --force-reinstall -r" in calls, calls
assert calls.splitlines()[-1] == "STUB-SUPERVISORCTL status studio", calls
env = _studio_env(tmp_path / "ok")
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "5"
_stub(tmp_path / "ok" / "bin", "curl", "exit 0\n")
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode == 0, res.stdout
assert "answering on port 8000" in res.stdout
assert (home / "src" / "NEW_TREE").exists()
assert not _scratch(home), "the previous tree must go once Studio is up"
env = _studio_env(tmp_path / "junk")
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "soon"
_stub(tmp_path / "junk" / "bin", "curl", "exit 0\n")
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stdout
assert "not a number" in res.stdout
def test_studio_update_health_wait_zero_commits_once_the_restart_command_succeeds(tmp_path: Path):
"""0 asks for no validation: the previous tree goes as soon as the restart command
returns, and nothing is fetched from port 8000."""
env = _studio_env(tmp_path)
env["UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT"] = "0"
_stub(tmp_path / "bin", "curl", 'echo "STUB-CURL $*" >> "$STUB_LOG"\nexit 22\n')
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode == 0, res.stderr + res.stdout
assert "the update is committed" in res.stdout, res.stdout
assert "STUB-CURL" not in _calls(env)
assert (home / "src" / "NEW_TREE").exists()
assert not _scratch(home)
env = _studio_env(tmp_path / "down", restart_exit = 1)
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode != 0
assert (home / "src" / "OLD_TREE").exists()
def test_studio_update_clears_leftovers_of_a_killed_run_before_it_starts(tmp_path: Path):
"""`docker stop` ends in SIGKILL, so no trap ran: a previous tree can sit beside src
with a pid-derived name that a later run could reuse (mv would nest into it) and a
staging tree can sit there with its node_modules."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / ".src-prev.4242" / "junk").mkdir(parents = True)
(home / ".src-update.abc123" / "node_modules").mkdir(parents = True)
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
assert "left behind by an earlier update" in res.stdout, res.stdout
assert (home / "src" / "NEW_TREE").exists()
assert not (home / "src" / "junk").exists(), "the tree was nested into the stale dir"
assert not _scratch(home)
def test_studio_update_recovers_a_source_tree_a_killed_run_moved_aside(tmp_path: Path):
"""SIGKILL between the two moves of the swap leaves no src at all, only .src-prev.*.
A later plain update must put it back rather than fail on a missing tree."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / "src").rename(home / ".src-prev.abc123")
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert "recovering the source tree" in res.stdout, res.stdout
assert (home / "src" / "OLD_TREE").exists()
assert not _scratch(home, ".src-prev.*")
def test_studio_update_puts_back_a_previous_tree_a_killed_run_never_committed(tmp_path: Path):
"""A killed update leaves the previous tree beside src; restoring it keeps the only known-good tree."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / "src").rename(home / ".src-prev.abc123")
(home / "src" / "studio").mkdir(parents = True)
(home / "src" / "UNVERIFIED").write_text("never passed the health check\n")
(home / ".src-update.rollback").write_text("-e file:///opt/prev-src\n")
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert "putting it back over the unverified one" in res.stdout, res.stdout
assert (home / "src" / "OLD_TREE").exists()
assert not (home / "src" / "UNVERIFIED").exists()
assert not _scratch(home), "the unverified tree was left behind"
def test_studio_update_reads_the_install_record_from_the_venv_not_the_cwd(tmp_path: Path):
"""`python -` searches the caller's cwd first, so a checkout there with its own
dist-info would answer for the venv's installed unsloth and a rollback would
reinstall the wrong thing."""
env = _studio_env(tmp_path, import_ok = False)
site = tmp_path / "site"
info = site / "unsloth-2026.9.4.dist-info" # seeded by _studio_env
(info / "direct_url.json").write_text(
'{"url": "file:///opt/venv-src", "dir_info": {"editable": true}}'
)
checkout = tmp_path / "checkout"
decoy = checkout / "unsloth-0.0.1.dist-info"
decoy.mkdir(parents = True)
(decoy / "METADATA").write_text("Metadata-Version: 2.1\nName: unsloth\nVersion: 0.0.1\n")
(decoy / "direct_url.json").write_text(
'{"url": "file:///checkout", "dir_info": {"editable": true}}'
)
res = _run(STUDIO_UPDATE, ["--no-restart"], env, cwd = checkout)
assert res.returncode != 0
assert "STUB-PIP-REQ -e file:///opt/venv-src" in _calls(env), _calls(env)
assert "checkout" not in _calls(env), _calls(env)
assert "before: unsloth 2026.9.4" in res.stdout, res.stdout
def test_studio_update_records_every_packages_target_for_the_rollback(tmp_path: Path):
"""--packages can name more than unsloth and unsloth_zoo; a restore that put only
those two back would leave the extra target upgraded, or newly installed, while
reporting the previous install is back."""
env = _studio_env(tmp_path, import_ok = False)
site = tmp_path / "site"
info = site / "bar-1.0.dist-info"
info.mkdir()
(info / "METADATA").write_text("Metadata-Version: 2.1\nName: bar\nVersion: 1.0\n")
res = _run(
STUDIO_UPDATE, ["--no-restart", "--packages", "unsloth unsloth_zoo bar>=2 foo==2"], env
)
calls = _calls(env)
assert res.returncode != 0
req = [l for l in calls.splitlines() if l.startswith("STUB-PIP-REQ")][0]
assert "bar==1.0" in req, req
assert "# absent: foo" in req, req
assert "STUB-PIP -m pip uninstall -y foo" in calls, calls
assert "uninstall -y bar" not in calls
def test_studio_update_with_deps_stops_when_the_dependency_snapshot_fails(tmp_path: Path):
"""An empty snapshot would let the install run with nothing to pin back."""
env = _studio_env(tmp_path, import_ok = False)
env["STUB_FREEZE_EXIT"] = "1"
res = _run(STUDIO_UPDATE, ["--with-deps"], env)
assert res.returncode != 0
assert "pip freeze failed" in res.stderr and "nothing was changed" in res.stderr, res.stderr
assert "install" not in _calls(env), _calls(env)
def test_studio_update_finishes_the_package_restore_a_killed_run_left(tmp_path: Path):
"""SIGKILL after pip had replaced the packages but before the commit: the record
kept beside the tree says what to put back, and the next run does that before it
records anything as the previous install."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / ".src-update.rollback").write_text("-e file:///opt/prev-src\n# absent: foo\n")
(home / ".src-update.freeze").write_text("transformers==3.9.0\n")
res = _run(STUDIO_UPDATE, [], env)
calls = _calls(env)
assert res.returncode == 0, res.stderr + res.stdout
assert "putting the previous packages back first" in res.stdout, res.stdout
reqs = [l for l in calls.splitlines() if l.startswith("STUB-PIP-REQ")]
assert reqs[0] == "STUB-PIP-REQ transformers==3.9.0 ", reqs
assert "-e file:///opt/prev-src" in reqs[1], reqs
assert "STUB-PIP -m pip uninstall -y foo" in calls, calls
assert not _scratch(home, ".src-update.*")
@pytest.mark.parametrize("status_exit, verb", [(0, "restart"), (3, "start")])
def test_studio_update_recovery_puts_the_service_on_the_restored_install(
tmp_path: Path, status_exit, verb
):
"""A killed run may have left Studio FATAL, so recovery restarts it on the restored install first."""
env = _studio_env(tmp_path, status_exit = status_exit, git_ls_exit = 1)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / ".src-update.rollback").write_text("-e file:///opt/prev-src\n")
res = _run(STUDIO_UPDATE, ["--ref", "main"], env)
calls = _calls(env)
assert res.returncode != 0, res.stdout
assert "the previous packages are back" in res.stdout, res.stdout
assert "running the restored install" in res.stdout, res.stdout
sup = [l for l in calls.splitlines() if l.startswith("STUB-SUPERVISORCTL")]
assert sup[1] == f"STUB-SUPERVISORCTL {verb} studio", sup
assert not (home / ".src-update.rollback").exists()
env = _studio_env(tmp_path / "noreset", git_ls_exit = 1)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / ".src-update.rollback").write_text("-e file:///opt/prev-src\n")
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert "STUB-SUPERVISORCTL" not in _calls(env), _calls(env)
assert "restart it to load the restored install" in res.stdout, res.stdout
def test_studio_update_with_deps_rollback_fails_when_pip_cannot_list_packages(tmp_path: Path):
"""The list of what the update pulled in comes from a second `pip freeze`; when
that fails, nothing would be removed, so the restore is reported as unfinished
and the record stays."""
env = _studio_env(tmp_path, import_ok = False)
env["STUB_FREEZE_EXIT_AFTER"] = "1"
res = _run(STUDIO_UPDATE, ["--with-deps", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode != 0
assert "could not list the installed packages" in res.stdout, res.stdout
assert "could not be restored cleanly" in res.stdout, res.stdout
assert (
home / ".src-update.rollback"
).is_file(), "the record was dropped after a failed restore"
def test_recover_finishes_a_killed_update_and_does_nothing_else(tmp_path: Path):
"""The container start runs --recover before supervisord starts Studio: a previous
tree beside src goes back over the unverified one, the recorded packages are
reinstalled, and no update is attempted."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / "src" / "OLD_TREE").unlink()
(home / "src" / "NEW_TREE").write_text("unverified\n")
(home / ".src-prev.k9x2Qa" / "studio").mkdir(parents = True)
(home / ".src-prev.k9x2Qa" / "OLD_TREE").write_text("previous\n")
(home / ".src-update.rollback").write_text("-e file:///opt/prev-src\n")
res = _run(STUDIO_UPDATE, ["--recover"], env)
calls = _calls(env)
assert res.returncode == 0, res.stdout + res.stderr
assert "recovery done" in res.stdout, res.stdout
assert (home / "src" / "OLD_TREE").exists() and not (home / "src" / "NEW_TREE").exists()
assert not (home / ".src-prev.k9x2Qa").exists()
assert not (home / ".src-update.rollback").exists()
assert "install --no-deps --force-reinstall -r" in calls, calls
assert "install -U" not in calls and "STUB-GIT" not in calls, calls
assert not _scratch(home)
env = _studio_env(tmp_path / "clean")
res = _run(STUDIO_UPDATE, ["--recover"], env)
assert res.returncode == 0 and "nothing to recover" in res.stdout, res.stdout
assert "STUB-PIP -m pip install" not in _calls(env)
def test_studio_update_says_when_the_restore_did_not_finish(tmp_path: Path):
"""pip failing during the restore itself must not be reported as the previous
install being back; the record stays so the next run finishes it."""
env = _studio_env(tmp_path, import_ok = False)
env["STUB_PIP_EXIT"] = "1"
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
assert res.returncode != 0
assert "back in place" not in res.stderr, res.stderr
assert "could not put every previous package back" in res.stderr, res.stderr
assert (
home / ".src-update.rollback"
).is_file(), "the record was dropped after a failed restore"
del env["STUB_PIP_EXIT"]
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
assert "putting the previous packages back first" in res.stdout, res.stdout
assert not (home / ".src-update.rollback").exists()
def test_studio_update_keeps_the_lock_file(tmp_path: Path):
"""Unlinking the lock would let a run that opened the old inode take the lock after
this one exits while a later run locks a fresh file at the same path."""
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, ["--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
assert (Path(env["UNSLOTH_STUDIO_HOME"]) / ".src-update.lock").is_file()
def test_studio_update_treats_a_previous_tree_without_a_record_as_scratch(tmp_path: Path):
"""The record is unlinked before the previous tree is deleted, so a previous tree
with no record is a committed update that was killed mid-delete, and src is the
tree that passed the health check."""
env = _studio_env(tmp_path)
home = Path(env["UNSLOTH_STUDIO_HOME"])
(home / ".src-prev.abc123" / "studio").mkdir(parents = True)
(home / ".src-prev.abc123" / "COMMITTED_AWAY").write_text("")
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode == 0, res.stderr + res.stdout
assert (home / "src" / "OLD_TREE").exists()
assert "putting it back" not in res.stdout, res.stdout
assert not _scratch(home)
def test_studio_update_with_deps_adds_the_studio_extra_to_a_qualified_unsloth_spec(tmp_path: Path):
"""--packages can pin or qualify unsloth; the studio extra must still ride along,
and unsloth_zoo must not be mistaken for it."""
env = _studio_env(tmp_path)
res = _run(
STUDIO_UPDATE,
["--with-deps", "--no-restart", "--packages", "unsloth==2026.9.1 unsloth_zoo"],
env,
)
assert res.returncode == 0, res.stderr + res.stdout
calls = _calls(env)
assert "unsloth[studio]==2026.9.1" in calls, calls
assert "unsloth[studio]_zoo" not in calls and " unsloth_zoo" in calls, calls
env = _studio_env(tmp_path / "extras")
res = _run(
STUDIO_UPDATE, ["--with-deps", "--no-restart", "--packages", "unsloth[cu128]>=2026.9"], env
)
assert "unsloth[studio,cu128]>=2026.9" in _calls(env), _calls(env)
def test_studio_update_with_deps_rollback_removes_what_the_update_pulled_in(tmp_path: Path):
"""Reinstalling the snapshot puts back what was there; a dependency the update
introduced would otherwise stay and become part of the next snapshot."""
env = _studio_env(tmp_path, import_ok = False)
res = _run(STUDIO_UPDATE, ["--with-deps", "--no-restart"], env)
assert res.returncode != 0
calls = _calls(env)
assert "STUB-PIP -m pip uninstall -y newdep" in calls, calls
assert "uninstall -y transformers" not in calls and "uninstall -y torch" not in calls, calls
def test_studio_update_ref_uses_the_lockfile_and_does_not_fall_back_to_npm_install(tmp_path: Path):
env = _studio_env(tmp_path)
env["STUB_LOCKFILE"] = "1"
env["STUB_NPM_CI_EXIT"] = "1"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
home = Path(env["UNSLOTH_STUDIO_HOME"])
calls = _calls(env)
assert res.returncode != 0
assert "npm ci failed" in res.stdout, res.stdout
frontend_installs = [
l for l in calls.splitlines() if "STUB-NPM install" in l and l.endswith("frontend")
]
assert not frontend_installs, (
"a lockfile that does not apply must not be re-resolved:\n" + calls
)
assert (home / "src" / "OLD_TREE").exists()
env = _studio_env(tmp_path / "nolock")
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
calls = _calls(env)
assert res.returncode == 0, res.stderr + res.stdout
assert "STUB-NPM ci" not in calls, calls
assert "no package-lock.json" in res.stdout, res.stdout
def test_studio_update_ref_threads_the_npm_registry_override(tmp_path: Path):
env = _studio_env(tmp_path)
env["STUB_LOCKFILE"] = "1"
env["UNSLOTH_NPM_REGISTRY"] = "https://mirror.example/npm/"
res = _run(STUDIO_UPDATE, ["--ref", "main", "--no-restart"], env)
assert res.returncode == 0, res.stderr + res.stdout
for line in [
l for l in _calls(env).splitlines() if "STUB-NPM ci" in l or "STUB-NPM install" in l
]:
assert "--registry https://mirror.example/npm/" in line, line
def test_studio_update_help_prints_only_the_header(tmp_path: Path):
env = _studio_env(tmp_path)
res = _run(STUDIO_UPDATE, ["--help"], env)
assert res.returncode == 0
assert "set -euo pipefail" not in res.stdout
assert "UNSLOTH_STUDIO_UPDATE_HEALTH_WAIT" in res.stdout
def _llama_env(
tmp_path: Path,
*,
latest: str | None,
marker: str = '{"tag": "b1111-old"}',
) -> dict:
install = tmp_path / "llama.cpp"
install.mkdir(parents = True)
(install / "UNSLOTH_PREBUILT_INFO.json").write_text(
marker + "\n",
encoding = "utf-8",
)
fetcher = tmp_path / "fetch_llama_prebuilt.py"
resolve = (
" raise RuntimeError('unreachable')\n" if latest is None else f" return {latest!r}\n"
)
fetcher.write_text(
"def resolve_latest_tag(repo):\n" + resolve,
encoding = "utf-8",
)
env = dict(os.environ)
env["UNSLOTH_LLAMA_CPP_PATH"] = str(install)
env["UNSLOTH_LLAMA_FETCHER"] = str(fetcher)
return env
def _llama_check(tmp_path: Path, latest, **kwargs):
env = _llama_env(tmp_path, latest = latest, **kwargs)
return _run(LLAMA_UPDATE, ["--check"], env)
def test_llama_check_reports_an_available_update(tmp_path: Path):
res = _llama_check(tmp_path, "b2222-new")
assert res.returncode == 0, res.stderr
assert "an update is available" in res.stdout
def test_llama_check_reports_up_to_date(tmp_path: Path):
res = _llama_check(tmp_path, "b1111-old")
assert res.returncode == 0, res.stderr
assert "up to date" in res.stdout
def test_llama_check_reads_the_full_release_tag_not_the_base_build(tmp_path: Path):
# The latest pointer is the full tag_name; reading the normalized tag offers updates forever.
res = _llama_check(
tmp_path,
"b10715-mix-86bd2d3",
marker = '{"tag": "b10715", "release_tag": "b10715-mix-86bd2d3"}',
)
assert res.returncode == 0, res.stderr
assert "up to date" in res.stdout, (
"the installed release IS the latest release; reporting an update here nags "
"forever because applying it cannot change the comparison:\n" + res.stdout
)
assert "installed: b10715-mix-86bd2d3" in res.stdout, (
"the reported installed version must be the full release identity it is "
"compared against:\n" + res.stdout
)
def test_llama_check_still_offers_a_genuinely_newer_release(tmp_path: Path):
res = _llama_check(
tmp_path,
"b10800-mix-aaaaaaa",
marker = '{"tag": "b10715", "release_tag": "b10715-mix-86bd2d3"}',
)
assert res.returncode == 0, res.stderr
assert "an update is available" in res.stdout
def test_llama_check_does_not_claim_up_to_date_when_it_could_not_look(tmp_path: Path):
res = _llama_check(tmp_path, None)
assert "up to date" not in res.stdout, (
"--check exists to report update status; saying 'up to date' for a lookup "
"that never happened is the one answer it must never give:\n" + res.stdout
)
assert res.returncode != 0, "an unperformed check must not exit 0"
assert "UNKNOWN" in res.stdout + res.stderr
def _llama_inplace_env(tmp_path: Path, old: list[str], new: list[str]) -> dict:
"""An in-place (volume-mounted) install whose activation fails part-way."""
install = tmp_path / "llama.cpp"
install.mkdir(parents = True)
for name in old:
(install / name).write_text("OLD\n", encoding = "utf-8")
(install / "UNSLOTH_PREBUILT_INFO.json").write_text(
'{"tag": "b1111-old"}\n',
encoding = "utf-8",
)
fetcher = tmp_path / "fetch_llama_prebuilt.py"
fetcher.write_text(
"import os, sys\n"
"def resolve_latest_tag(repo):\n"
" return 'b2222-new'\n"
"if __name__ == '__main__':\n"
" dest = sys.argv[3]\n"
" os.makedirs(dest, exist_ok = True)\n"
f" for name in {new!r}:\n"
" open(os.path.join(dest, name), 'w').write('NEW\\n')\n"
" open(os.path.join(dest, 'UNSLOTH_PREBUILT_INFO.json'), 'w')"
'.write(\'{"tag": "b2222-new"}\\n\')\n',
encoding = "utf-8",
)
bin_dir = tmp_path / "bin"
_stub(
bin_dir,
"mv",
'if [ "$1" = "-t" ] && [ "$2" = "$FAIL_MV_TARGET" ]; then\n'
" shift 2\n"
' for _s in "$@"; do /bin/mv "$_s" "$FAIL_MV_TARGET/"; done\n'
" exit 1\n"
"fi\n"
'exec /bin/mv "$@"\n',
)
env = dict(os.environ)
env["PATH"] = f"{bin_dir}{os.pathsep}" + env["PATH"]
env["UNSLOTH_LLAMA_CPP_PATH"] = str(install)
env["UNSLOTH_LLAMA_FETCHER"] = str(fetcher)
env["UNSLOTH_LLAMA_UPDATE_IN_PLACE"] = "1"
env["FAIL_MV_TARGET"] = str(install)
return env
def test_llama_rollback_leaves_no_new_release_files_behind(tmp_path: Path):
# Only in the new release, so a rollback over the backup's entries cannot remove it.
old = ["libggml-base.so", "libggml-cpu-icelake.so", "llama-cli"]
new = [
"libggml-base.so",
"libggml-cpu-icelake.so",
"llama-cli",
"libggml-hexagon.so",
"llama-mtmd-cli",
]
env = _llama_inplace_env(tmp_path, old, new)
res = _run(LLAMA_UPDATE, [], env)
assert res.returncode != 0, "a failed swap must not report success"
install = tmp_path / "llama.cpp"
present = sorted(p.name for p in install.iterdir())
leftovers = [n for n in ("libggml-hexagon.so", "llama-mtmd-cli") if n in present]
assert not leftovers, f"new-release-only files survived the rollback: {leftovers} in {present}"
for name in old:
assert (
install / name
).read_text() == "OLD\n", f"{name} was not restored from the backup: {present}"
def test_llama_rollback_keeps_every_old_file_when_the_drain_is_interrupted(tmp_path: Path):
old = ["libggml-base.so", "libggml-cpu-icelake.so", "llama-cli", "llama-quantize"]
env = _llama_inplace_env(tmp_path, old, old)
install = tmp_path / "llama.cpp"
_stub(
tmp_path / "bin",
"mv",
'case "${1:-}:${2:-}" in\n'
" -t:*/.old.*)\n"
' _t="$2"; shift 2\n'
' [ $# -gt 0 ] && /bin/mv "$1" "$_t/"\n'
" exit 1;;\n"
"esac\n"
'exec /bin/mv "$@"\n',
)
res = _run(LLAMA_UPDATE, [], env)
assert res.returncode != 0
survivors = sorted(p.name for p in install.rglob("*") if p.is_file())
for name in old:
assert name in survivors, f"{name} was lost during an interrupted drain: {survivors}"
def _fetcher_module():
"""Import the build-time fetcher by path; stdlib-only, with a __main__ guard."""
import importlib.util
path = REPO_ROOT / "docker" / "fetch_llama_prebuilt.py"
spec = importlib.util.spec_from_file_location("_fetch_llama_prebuilt", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize(
"release_tag, expected",
[
("b10715-mix-86bd2d3", "b10715"),
("b10715", "b10715"),
(" b9596-mix-e6f2453 ", "b9596"),
("not-a-build-tag", "not-a-build-tag"),
],
)
def test_fetcher_normalizes_the_base_build_for_the_marker_tag(release_tag, expected):
assert _fetcher_module().base_build_tag(release_tag) == expected
def test_a_failed_studio_update_puts_the_previous_install_back(tmp_path: Path):
"""Not restarting protected only the code the running process had already
imported; the venv on disk stayed replaced, so any lazy import or later restart
failed the same way. The update now reinstalls exactly what it started from."""
env = _studio_env(tmp_path, import_ok = False)
res = _run(STUDIO_UPDATE, [], env)
assert res.returncode != 0, "a broken update must not report success"
assert "previous install was restored" in res.stdout, res.stdout
assert "not restarted" in res.stdout, res.stdout