* feat(install): opt-in Vulkan llama.cpp backend and HIP gfx fallback (#7357) Add UNSLOTH_LLAMA_BACKEND=vulkan and --llama-backend vulkan to force the upstream Vulkan prebuilt on any host, persist llama_backend in the install marker, and re-assert it during Studio updates. On Windows AMD, auto-fallback to Vulkan when no detected gfx arch is in the upstream win-hip-radeon GPU_TARGETS set (e.g. gfx803 / RX 480). Mixed setups where at least one card is HIP-supported still default to HIP unless opted in. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(install): address Codex P2s on Vulkan gfx routing (#7357) Honor ROCm family tokens (gfx110X), include fork-supported gfx1103, require a known active gfx before auto-Vulkan, and base the HIP floor check on the visible-device target instead of every physical GPU in hipinfo. * Address Codex review: env namespace, physical-NVIDIA guard, test kwarg - llama_backend_from_env: stop reading UNSLOTH_LLAMA_CPP_BACKEND. That is a separate pre-existing setup variable meaning auto/cpu; setup.sh/setup.ps1 warn and ignore other values, so reading it here forced Vulkan behind that warning. Vulkan opt-in stays on UNSLOTH_LLAMA_BACKEND / UNSLOTH_FORCE_VULKAN. - _should_auto_vulkan_for_amd_windows: gate on not has_physical_nvidia (not merely has_usable_nvidia). A CUDA-masked NVIDIA card keeps has_physical_nvidia while has_usable_nvidia goes False; Vulkan ignores CUDA_VISIBLE_DEVICES and could enumerate the reserved card. Mirrors the Intel auto path. Explicit opt-in still overrides. - test fakes: validate_prebuilt_attempts/validate_prebuilt_choice gained a llama_backend kwarg; the four fake signatures in the fallback tests now accept it, clearing the TypeError that reddened Backend CI / Repo tests (CPU). Tests: UNSLOTH_LLAMA_CPP_BACKEND=vulkan no longer triggers Vulkan; hidden physical NVIDIA suppresses AMD auto-Vulkan while explicit opt-in overrides. * Keep gfx1034 on the ROCm path (fork gfx103X bundle covers it) The WINDOWS_HIP_PREBUILT_GFX_TARGETS allow-list omitted gfx1034, so _route_to_vulkan_prebuilt downgraded RX 6500/6400-class hosts to the upstream Vulkan prebuilt before published_rocm_choice_for_host could match the fork windows-rocm gfx103X bundle (whose members include gfx1034). Add gfx1034 to the allow-list and a regression test asserting it stays on the fork ROCm asset. * Fix auto-Vulkan stealing fork windows-rocm gfx908/gfx90a hosts for PR #7373 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix Vulkan marker claiming a backend that was never installed for PR #7373 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the Vulkan backend routing comments for PR #7373 * Keep the visible-device-aware gfx when setup forwards --rocm-gfx setup.ps1 resolves the gfx arch from its own probe, and that pick is not fully visible-device aware: neither the hipinfo nor the amd-smi branch reads CUDA_VISIBLE_DEVICES, and the amd-smi branch matches a bare integer only, so a comma-separated HIP/ROCR mask such as 1,0 also falls back to GPU 0. The resulting arch was then forwarded through --rocm-gfx and replaced the arch detect_host() had already resolved for the runtime-visible GPU. On a mixed-AMD Windows host that flipped the auto-Vulkan decision: with GPU 0 gfx1100 and a masked-in gfx1010, the forward reinstated gfx1100, _should_auto_vulkan_for_amd_windows() saw a HIP-supported arch and the HIP bundle was installed for a GPU that cannot run it. Fold the forward in as a fill rather than a replacement: it still supplies the arch on amd-smi-only, driver-only and name-inferred hosts where the probe reports none, which is what --rocm-gfx exists for, but no longer overwrites a successfully detected active arch. An explicit UNSLOTH_ROCM_GFX_ARCH stays authoritative, since it is the documented manual override for hosts whose arch the probes get wrong. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope the Windows AMD Vulkan fallback per device and per repo Three follow-ups on the auto-Vulkan routing for #7357. Keep an explicit --rocm-gfx authoritative. The previous round stopped a forwarded gfx from replacing an arch detect_host() had already resolved, but --rocm-gfx is also the documented operator override for hosts whose probe is wrong or stale, and both arrive as the same argv. Narrow the advisory case to the two shapes setup can actually be describing: an arch the probe saw on this host (setup picked a different physical GPU of the same box), or a family label such as gfx110X, which is a bundle name the update path derives from the marker asset rather than a real GPU arch. Any other value is an override for an arch no probe reported and stays authoritative. Keeping family labels advisory also preserves the rule that an in-generation-but-unbuilt arch (gfx1033) is never upgraded into the gfx103X bundle. Do not auto-route to Vulkan from a HIP-only device mask. HIP_VISIBLE_DEVICES, ROCR_VISIBLE_DEVICES and CUDA_VISIBLE_DEVICES select the active arch, but the Vulkan runtime honours none of them: it enumerates through GGML_VK_VISIBLE_DEVICES and Vulkan ordinals in LlamaCppBackend._get_gpu_free_memory_vulkan. Masking down to a below-floor card therefore used to install a backend that could still enumerate the HIP-capable card the user deliberately hid, possibly one reserved for another workload. Require every physical AMD gfx to be below the floor, matching the has_physical_nvidia gate right above it. So the per-GPU list survives to that check, a forward that agrees with the probe no longer collapses rocm_gfx_targets to a single entry. Make the HIP support predicate repository-specific. The floor constant is a union of ggml-org's windows-hip gpu_targets and the fork's windows-rocm bundles, so it only answers "is this arch served" for the fork. With --published-repo ggml-org/llama.cpp, direct_upstream_release_plan() offers win-hip-radeon then CPU and never Vulkan, so the four fork-only archs (gfx908, gfx90a, gfx1034, gfx1103) were declared supported and fell through to CPU instead of the Vulkan bundle that would actually run. Add UPSTREAM_WINDOWS_HIP_GFX_TARGETS and select the set from the planned repo. * Keep probe-confirmed AMD GPUs in the physical list when a gfx is forwarded rocm_gfx_targets is the physical inventory _should_auto_vulkan_for_amd_windows() reads, so a forwarded --rocm-gfx that the probe never reported was deleting cards the probe had confirmed. On a mixed Windows AMD box whose active device is masked down to a below-floor card, a stale UNSLOTH_ROCM_GFX_ARCH or a name-inferred arch for the other GPU collapsed the list to that one arch, the floor check concluded no AMD GPU on the host reaches the Windows HIP prebuilt, and the install auto-fell back to Vulkan, which honours no HIP mask and would enumerate the reserved HIP-capable card. Add the forwarded arch to the list instead of replacing it: it selects the HIP target, it does not redefine what hardware is present. An empty probe still yields a single-entry list, so the driver-only Windows AMD host the forward exists for keeps its automatic Vulkan fallback, and an explicit --llama-backend vulkan is unaffected. * Do not auto-fall back to Vulkan when a HIP device mask filtered the probe hipinfo is itself a HIP application, and AMD documents HIP_VISIBLE_DEVICES as "only devices whose index is present in the sequence are visible to HIP", with that spelling recommended on Windows. Under a mask the Windows probe therefore enumerates the visible devices, so rocm_gfx_targets is what survived the mask rather than the physical inventory the auto-Vulkan floor check assumes. A masked-out gfx1100 next to a visible gfx803 made the check conclude that no AMD GPU on the box reaches the Windows HIP prebuilt and route the install to Vulkan, which honours none of these masks and would enumerate the reserved card. Decline to guess when a mask is set: the physical inventory is unknowable from a masked probe, so keep the HIP / fork / source path. This only ever turns the automatic fallback off, never on. The driver-only single-GPU host the fallback exists for sets no mask, an all-hiding "" / -1 mask is still handled as no active target rather than a partial view, and an explicit --llama-backend vulkan or UNSLOTH_LLAMA_BACKEND=vulkan is unaffected. Reading the physical inventory through an unmasked re-probe would also correct _pick_rocm_gfx_target, which indexes the token list by the mask value and so already assumes an unmasked probe. That is pre-existing behaviour on main and is left alone here. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Treat an all-hiding HIP device mask as suppressing the Vulkan fallback too The mask guard exempted an empty or -1 value on the grounds that the probe reports no active target under it, but that only holds for the probe: a forwarded --rocm-gfx still reconstructs an active arch, and setup infers that arch from the display-adapter name, which no HIP mask touches. A user who hid every AMD GPU from HIP could therefore still be auto-routed to Vulkan, which honours none of these masks and would then use all of them. That is the strongest form of the hazard the guard exists for, not an exemption from it. Presence of any of the three variables is now the whole test, which also removes the value parsing. An explicit --llama-backend vulkan or UNSLOTH_LLAMA_BACKEND is still unaffected. * Grant the fork-only Windows HIP coverage to the fork, not to every mirror The floor set is a union of the fork's windows-rocm bundles and only the fork is planned from its manifest: resolve_simple_install_release_plans() compares == DEFAULT_PUBLISHED_REPO and sends every other --published-repo through direct_upstream_release_plan(), whose AMD branch offers win-hip-radeon then CPU and never Vulkan. Exempting only the exact ggml-org spelling therefore told a mirror carrying upstream-standard assets that fork-only archs such as gfx1034, gfx1103 and gfx908 were HIP-served, landing them on HIP or CPU instead of the Vulkan bundle that would actually run. Gate on the fork instead. Matching the dispatch exactly, spelling included, also fixes a differently cased repo: that really does take the upstream path, so it must be answered with upstream coverage rather than the fork superset. An empty repo still defaults to the fork, as the resolver does. * Derive the Windows HIP gfx floor guard from the published manifest The guard compared WINDOWS_HIP_PREBUILT_GFX_TARGETS against a second hardcoded tuple in the same test file, so a windows-rocm arch newly published by the fork passed both. Affected hosts would then be routed off the hash-approved fork ROCm bundle onto an unhashed upstream Vulkan build with nothing failing. Read the fork's llama-prebuilt-manifest.json through the installer's own resolver instead, and assert the floor, the family labels, and the routing tuple all still cover what it publishes. The manifest ships only as a release asset, so an unreachable release skips with an explicit reason rather than flaking. Both literals match the manifest as published today. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Compress the Vulkan backend routing comments and docstrings for PR #7373 * Correct the family-label rationale in the Windows HIP coverage check The comment justified serving gfx103X / gfx110X against any repository by claiming upstream's windows-hip targets build every member of those families. The fork manifest maps gfx103X to gfx1030..1032 plus gfx1034 and gfx110X to gfx1100..1102 plus gfx1103, and UPSTREAM_WINDOWS_HIP_GFX_TARGETS carries neither gfx1034 nor gfx1103, so the stated reason is wrong even though the answer is right. State the real reason instead. A family label is a bundle name, not an arch, so the concrete GPU is unknown at this point; answering unsupported to cover the two uncovered members would move gfx1030..1032 and gfx1100..1102 off a working HIP build onto Vulkan for a card the label cannot identify. Those two archs still reach Vulkan through the concrete-arch branch below, which does answer per repository. Comment only. No behaviour change: the 5850-combination override sweep still reports 0 rocm_gfx_target changes, 0 auto_vulkan False to True flips and 680 True to False flips all backed by a probe-confirmed HIP GPU, and both the feature and override profile matrices are byte-identical. * Pin that a deliberate CPU install outranks Vulkan for PR #7373 UNSLOTH_LLAMA_CPP_BACKEND (setup.sh / setup.ps1, "auto" or "cpu") and UNSLOTH_LLAMA_BACKEND (this module, a backend name) are separate variables at separate layers, and both accept "cpu". setup translates its own =cpu into --force-cpu, which is what pins the CPU-only bundle on a GPU host and keeps Intel iGPU Vulkan crashes away (#7213), so no trigger this PR adds may outrank it. _route_to_vulkan_prebuilt already gets this right, since force_cpu short-circuits ahead of the forced, auto-Intel and auto-no-HIP triggers. Cover it so it stays that way: the matrix runs [Linux, Windows, macOS] x [NVIDIA, AMD, Intel, CPU only] x [unset, vulkan, hip, rocm, cpu] with the legacy UNSLOTH_FORCE_VULKAN set as well, and asserts the published bundle survives every one. WSL presents as Linux to this resolver, so it rides the Linux row. Also assert the guard is not vacuous: the same host still takes Vulkan once the CPU pin is gone, so the matrix cannot pass on a resolver that had simply stopped routing to Vulkan. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: LeoBorcherding <borchborchmail@gmail.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
1144 lines
44 KiB
Python
1144 lines
44 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
|
|
|
|
"""Hermetic tests for the in-app llama.cpp update orchestration.
|
|
|
|
No network, no real install: the GitHub release lookup and the installer
|
|
subprocess are both monkeypatched. Verifies detection (update_available) and
|
|
the apply flow (job lifecycle, installer invocation, post-swap re-read).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
import pytest
|
|
|
|
_BACKEND = Path(__file__).resolve().parents[1]
|
|
if str(_BACKEND) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND))
|
|
|
|
import utils.llama_cpp_freshness as freshness # noqa: E402
|
|
import utils.llama_cpp_update as upd # noqa: E402
|
|
|
|
MARKER = "UNSLOTH_PREBUILT_INFO.json"
|
|
|
|
|
|
class _FakeInstallerPopen:
|
|
"""Stands in for the streamed installer process in _run_update."""
|
|
|
|
def __init__(
|
|
self,
|
|
cmd,
|
|
*,
|
|
returncode = 0,
|
|
lines = None,
|
|
on_start = None,
|
|
captured_kwargs = None,
|
|
**kwargs,
|
|
):
|
|
if captured_kwargs is not None:
|
|
captured_kwargs.update(kwargs)
|
|
if on_start is not None:
|
|
on_start(list(cmd))
|
|
self.returncode = returncode
|
|
self.stdout = iter(lines or [])
|
|
|
|
def wait(self):
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
pass
|
|
|
|
|
|
def _patch_installer_popen(
|
|
monkeypatch,
|
|
*,
|
|
returncode = 0,
|
|
lines = None,
|
|
on_start = None,
|
|
captured_kwargs = None,
|
|
):
|
|
monkeypatch.setattr(
|
|
upd.subprocess,
|
|
"Popen",
|
|
lambda cmd, **kw: _FakeInstallerPopen(
|
|
cmd,
|
|
returncode = returncode,
|
|
lines = lines,
|
|
on_start = on_start,
|
|
captured_kwargs = captured_kwargs,
|
|
**kw,
|
|
),
|
|
)
|
|
|
|
|
|
def _write_install(
|
|
dir_: Path,
|
|
tag: str,
|
|
repo: str = "unslothai/llama.cpp",
|
|
asset: str | None = None,
|
|
release_tag: str | None = None,
|
|
force_cpu: bool | None = None,
|
|
) -> str:
|
|
"""Create a fake prebuilt install and return the llama-server path."""
|
|
bin_dir = dir_ / "build" / "bin"
|
|
bin_dir.mkdir(parents = True, exist_ok = True)
|
|
binary = bin_dir / "llama-server"
|
|
binary.write_text("#!/bin/sh\necho stub\n")
|
|
marker = {
|
|
"tag": tag,
|
|
"release_tag": release_tag or tag,
|
|
"published_repo": repo,
|
|
"installed_at_utc": "2020-01-01T00:00:00Z",
|
|
"bundle_profile": "cuda13-newer",
|
|
"runtime_line": "cuda13",
|
|
}
|
|
if asset is not None:
|
|
marker["asset"] = asset
|
|
if force_cpu is not None:
|
|
marker["force_cpu"] = force_cpu
|
|
(dir_ / MARKER).write_text(json.dumps(marker))
|
|
return str(binary)
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _clean_state(monkeypatch, tmp_path):
|
|
freshness.reset_caches()
|
|
upd._reset_job_for_tests()
|
|
upd._resolve_memo.clear()
|
|
# Isolate the freshness disk cache so the suite never writes the real
|
|
# ~/.unsloth cache (the default when storage_roots can't be imported).
|
|
monkeypatch.setattr(freshness, "_cache_dir", lambda: tmp_path / ".freshness_cache")
|
|
# Deterministic markerless paths: no host-pinned binary, no custom dir.
|
|
monkeypatch.delenv("LLAMA_SERVER_PATH", raising = False)
|
|
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
|
# Never hit the network in these tests.
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
|
|
# Keep the whisper piggyback out of the llama-only tests: no host probe, no
|
|
# whisper phase (test_combined_update.py covers the chained flow).
|
|
monkeypatch.setattr(upd, "_whisper_chain_status", lambda **kwargs: None)
|
|
yield
|
|
freshness.reset_caches()
|
|
upd._reset_job_for_tests()
|
|
upd._resolve_memo.clear()
|
|
|
|
|
|
def _no_prebuilt(monkeypatch):
|
|
"""Stub the host prebuilt probe to 'none available' (no source-build offer)."""
|
|
monkeypatch.setattr(upd, "_resolve_prebuilt_for_host", lambda *, force_refresh = False: None)
|
|
|
|
|
|
def _prebuilt(
|
|
monkeypatch,
|
|
*,
|
|
repo = "unslothai/llama.cpp",
|
|
release_tag = "b9585",
|
|
llama_tag = None,
|
|
asset = None,
|
|
):
|
|
"""Stub the host prebuilt probe to report an available prebuilt."""
|
|
payload = {
|
|
"prebuilt_available": True,
|
|
"repo": repo,
|
|
"release_tag": release_tag,
|
|
"llama_tag": llama_tag or release_tag,
|
|
"asset": asset or f"llama-{release_tag}-bin-macos-arm64.tar.gz",
|
|
"install_kind": "macos-arm64",
|
|
}
|
|
monkeypatch.setattr(upd, "_resolve_prebuilt_for_host", lambda *, force_refresh = False: payload)
|
|
|
|
|
|
def test_status_no_marker_no_prebuilt(monkeypatch, tmp_path):
|
|
# No marker AND no prebuilt available for the host -> unsupported (the genuine
|
|
# source-build-with-nothing-to-offer case).
|
|
binary = tmp_path / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub") # no marker file alongside
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_no_prebuilt(monkeypatch)
|
|
st = upd.get_update_status()
|
|
assert st["supported"] is False
|
|
assert st["update_available"] is False
|
|
assert st["installed_tag"] is None
|
|
|
|
|
|
def test_status_source_build_offers_prebuilt(monkeypatch, tmp_path):
|
|
# Markerless source build with a prebuilt now available for the host: surface
|
|
# the update. Unknown installed version (source build) is treated as behind.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch, release_tag = "b9585")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: None)
|
|
st = upd.get_update_status()
|
|
assert st["supported"] is True
|
|
assert st["update_available"] is True
|
|
assert st["source_build"] is True
|
|
assert st["latest_tag"] == "b9585"
|
|
assert st["published_repo"] == "unslothai/llama.cpp"
|
|
|
|
|
|
def test_status_source_build_compares_llama_tag(monkeypatch, tmp_path):
|
|
# release_tag may be a fork wrapper (v1.0); compare/display the upstream
|
|
# llama_tag (b9457) so a source build is not wrongly judged newer.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch, release_tag = "v1.0", llama_tag = "b9457")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9000)
|
|
st = upd.get_update_status()
|
|
assert st["latest_tag"] == "b9457" # not the wrapper tag
|
|
assert st["update_available"] is True # 9000 < 9457
|
|
|
|
|
|
def test_status_source_build_pinned_binary_not_offered(monkeypatch, tmp_path):
|
|
# LLAMA_SERVER_PATH pins a custom binary outside any llama.cpp dir; an apply
|
|
# could not take effect, so the button must not surface.
|
|
binary = tmp_path / "custom" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setenv("LLAMA_SERVER_PATH", str(binary))
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch)
|
|
st = upd.get_update_status()
|
|
assert st["supported"] is False
|
|
assert st["update_available"] is False
|
|
|
|
|
|
def test_llama_install_root_pinned_returns_none(monkeypatch, tmp_path):
|
|
binary = tmp_path / "custom" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setenv("LLAMA_SERVER_PATH", str(binary))
|
|
assert upd._llama_install_root(str(binary)) is None
|
|
|
|
|
|
def test_status_source_build_suppressed_when_newer(monkeypatch, tmp_path):
|
|
# A source build already newer than the latest prebuilt is not nagged.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch, release_tag = "b9518")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9600)
|
|
st = upd.get_update_status()
|
|
assert st["supported"] is True
|
|
assert st["update_available"] is False
|
|
assert st["installed_tag"] == "b9600"
|
|
|
|
|
|
def test_status_source_build_offers_same_base_mix(monkeypatch, tmp_path):
|
|
# The reported banner bug: a source build at the same upstream base as a new
|
|
# Unsloth prebuilt that adds a mix-<sha> suffix. The base build numbers match
|
|
# (9596 == 9596) but the mix carries extra patches the source build lacks, so
|
|
# the update must still surface -- mirroring the marker path's is_behind.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch, release_tag = "b9596-mix-e6f2453", llama_tag = "b9596")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9596)
|
|
st = upd.get_update_status()
|
|
assert st["supported"] is True
|
|
assert st["update_available"] is True
|
|
assert st["source_build"] is True
|
|
assert st["installed_tag"] == "b9596"
|
|
assert st["latest_tag"] == "b9596-mix-e6f2453"
|
|
|
|
|
|
def test_status_source_build_same_base_bare_not_offered(monkeypatch, tmp_path):
|
|
# Same base, but the prebuilt is a bare rebuild (no mix suffix): nothing extra
|
|
# to gain, so do not nag.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(monkeypatch, release_tag = "b9596", llama_tag = "b9596")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9596)
|
|
st = upd.get_update_status()
|
|
assert st["update_available"] is False
|
|
assert st["latest_tag"] == "b9596"
|
|
|
|
|
|
def test_status_source_build_skips_probe_while_job_runs(monkeypatch, tmp_path):
|
|
# While the updater swaps the tree, status polls must not exec the binary
|
|
# being replaced (on Windows that exec can fail the installer's os.replace);
|
|
# the 3s poller only consumes job progress.
|
|
binary = tmp_path / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
probes = {"resolve": 0, "version": 0}
|
|
|
|
def _count_resolve(*, force_refresh = False):
|
|
probes["resolve"] += 1
|
|
return None
|
|
|
|
def _count_version(b):
|
|
probes["version"] += 1
|
|
return None
|
|
|
|
monkeypatch.setattr(upd, "_resolve_prebuilt_for_host", _count_resolve)
|
|
monkeypatch.setattr(upd, "_installed_build_number", _count_version)
|
|
with upd._job_lock:
|
|
upd._job["state"] = upd._JOB_RUNNING
|
|
st = upd.get_update_status()
|
|
assert st["job"]["state"] == "running"
|
|
assert probes == {"resolve": 0, "version": 0}
|
|
|
|
|
|
def test_installed_version_skips_probe_while_job_runs(monkeypatch, tmp_path):
|
|
# Markerless build: get_installed_llama_version falls back to exec'ing
|
|
# `llama-server --version`. While the updater swaps the tree that exec can
|
|
# fail the installer's os.replace on Windows, so the About-panel probe must
|
|
# be skipped (return None) exactly like get_update_status's source probe.
|
|
binary = tmp_path / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub") # markerless: no UNSLOTH_PREBUILT_INFO.json
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
probed = {"n": 0}
|
|
|
|
def _count_version(b):
|
|
probed["n"] += 1
|
|
return 9585
|
|
|
|
monkeypatch.setattr(upd, "_installed_build_number", _count_version)
|
|
|
|
with upd._job_lock:
|
|
upd._job["state"] = upd._JOB_RUNNING
|
|
assert upd.get_installed_llama_version() is None
|
|
assert probed["n"] == 0 # never exec'd the binary mid-swap
|
|
|
|
upd._reset_job_for_tests() # back to idle -> probe runs
|
|
assert upd.get_installed_llama_version() == "b9585"
|
|
assert probed["n"] == 1
|
|
|
|
|
|
def test_status_update_available(monkeypatch, tmp_path):
|
|
binary = _write_install(tmp_path, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
st = upd.get_update_status(force_refresh = True)
|
|
assert st["supported"] is True
|
|
assert st["installed_tag"] == "b9493"
|
|
assert st["latest_tag"] == "b9518"
|
|
assert st["update_available"] is True
|
|
|
|
|
|
def test_status_up_to_date(monkeypatch, tmp_path):
|
|
binary = _write_install(tmp_path, "b9518")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
st = upd.get_update_status(force_refresh = True)
|
|
assert st["installed_tag"] == "b9518"
|
|
assert st["latest_tag"] == "b9518"
|
|
assert st["update_available"] is False
|
|
|
|
|
|
def test_start_update_no_marker_no_prebuilt_refuses(monkeypatch, tmp_path):
|
|
binary = tmp_path / "llama-server"
|
|
binary.write_text("stub") # no marker
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
_no_prebuilt(monkeypatch)
|
|
res = upd.start_update()
|
|
assert res["started"] is False
|
|
assert res["reason"] == "no_prebuilt_available"
|
|
|
|
|
|
def test_start_update_source_build_installs_prebuilt(monkeypatch, tmp_path):
|
|
# Markerless install + available prebuilt: install in place into the resolved
|
|
# root, with the asset-derived ROCm forwarding and the resolved repo.
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = install_dir / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub") # no marker
|
|
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
_prebuilt(
|
|
monkeypatch, repo = "unslothai/llama.cpp", asset = "app-b9585-linux-x64-rocm-gfx110X.tar.gz"
|
|
)
|
|
|
|
captured = {}
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = "installed"
|
|
stderr = ""
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
cmd = list(cmd)
|
|
assert "--version" in cmd # only status polls still use run()
|
|
return _Proc()
|
|
|
|
def _on_start(cmd):
|
|
captured["cmd"] = cmd
|
|
_write_install(install_dir, "b9585") # installer writes the marker
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
|
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True, res
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
if upd.get_update_status()["job"]["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
cmd = captured["cmd"]
|
|
assert "--install-dir" in cmd and str(install_dir) in cmd
|
|
assert "--published-repo" in cmd and "unslothai/llama.cpp" in cmd
|
|
assert "--llama-tag" in cmd and "latest" in cmd
|
|
assert cmd[cmd.index("--rocm-gfx") + 1] == "gfx110x"
|
|
assert "--simple-policy" not in cmd and "--cpu-fallback" not in cmd
|
|
# No pin: source-build detection and the unpinned apply share the same
|
|
# "latest" resolver, so they already agree.
|
|
assert "--published-release-tag" not in cmd
|
|
|
|
|
|
def test_start_update_happy_path(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
captured = {}
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = "installed"
|
|
stderr = ""
|
|
|
|
def _on_start(cmd):
|
|
captured["cmd"] = cmd
|
|
# Simulate the installer writing a new marker with the latest tag.
|
|
_write_install(install_dir, "b9518")
|
|
|
|
popen_kwargs: dict = {}
|
|
_patch_installer_popen(
|
|
monkeypatch,
|
|
lines = [
|
|
"[llama-prebuilt] resolving release\n",
|
|
"Downloading llama.zip: 35.0% (12.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
|
|
"Downloading llama.zip: 80.0% (28.0 MiB/35.0 MiB) at 9.0 MiB/s\n",
|
|
],
|
|
on_start = _on_start,
|
|
captured_kwargs = popen_kwargs,
|
|
)
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
assert res["job"]["from_tag"] == "b9493"
|
|
assert res["job"]["progress"] == 0.0
|
|
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "success", job
|
|
assert job["to_tag"] == "b9518"
|
|
assert job["reload_required"] is False
|
|
assert "--install-dir" in captured["cmd"]
|
|
assert str(install_dir) in captured["cmd"]
|
|
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
|
|
assert "unslothai/llama.cpp" in captured["cmd"]
|
|
assert job["progress"] == 1.0
|
|
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
|
|
|
|
|
|
def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path):
|
|
# A Vulkan install (marker asset carries 'vulkan') must re-assert
|
|
# UNSLOTH_FORCE_VULKAN on update, or detect_host on a GPU box re-routes to
|
|
# CUDA/ROCm and silently replaces the Vulkan build.
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(
|
|
install_dir,
|
|
"b9493",
|
|
repo = "ggml-org/llama.cpp",
|
|
asset = "llama-b9493-bin-ubuntu-vulkan-x64.tar.gz",
|
|
)
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
def _on_start(cmd):
|
|
captured["cmd"] = cmd
|
|
_write_install(
|
|
install_dir,
|
|
"b9518",
|
|
repo = "ggml-org/llama.cpp",
|
|
asset = "llama-b9518-bin-ubuntu-vulkan-x64.tar.gz",
|
|
)
|
|
|
|
captured: dict = {}
|
|
popen_kwargs: dict = {}
|
|
_patch_installer_popen(
|
|
monkeypatch,
|
|
lines = ["installed\n"],
|
|
on_start = _on_start,
|
|
captured_kwargs = popen_kwargs,
|
|
)
|
|
|
|
assert upd.start_update()["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "success", job
|
|
assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1"
|
|
assert popen_kwargs["env"]["UNSLOTH_LLAMA_BACKEND"] == "vulkan"
|
|
assert "--llama-backend" in captured["cmd"] and "vulkan" in captured["cmd"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"force_cpu, expect_flag",
|
|
[
|
|
# A deliberate CPU install (marker force_cpu=True) re-asserts --force-cpu on
|
|
# update so detect_host on a GPU host cannot re-route and revive the crash
|
|
# (#7213); --force-cpu also re-persists the flag for the next update.
|
|
(True, True),
|
|
# A transient fallback (or a legacy marker without the flag) stays free to
|
|
# heal to a GPU bundle (#6097).
|
|
(False, False),
|
|
(None, False),
|
|
],
|
|
)
|
|
def test_start_update_cpu_fallback_preserved_by_flag(monkeypatch, tmp_path, force_cpu, expect_flag):
|
|
asset = "llama-b9493-bin-ubuntu-x64.tar.gz"
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493", asset = asset, force_cpu = force_cpu)
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
captured: dict = {}
|
|
|
|
def _on_start(cmd):
|
|
captured["cmd"] = cmd
|
|
_write_install(install_dir, "b9518", asset = asset, force_cpu = force_cpu)
|
|
|
|
_patch_installer_popen(monkeypatch, lines = ["installed\n"], on_start = _on_start)
|
|
|
|
assert upd.start_update()["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "success", job
|
|
assert ("--force-cpu" in captured["cmd"]) is expect_flag
|
|
assert "--cpu-fallback" not in captured["cmd"]
|
|
|
|
|
|
def test_start_update_reports_full_release_tag(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9595")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(
|
|
freshness,
|
|
"_fetch_latest_release_tag",
|
|
lambda repo, timeout = 5.0: "b9596-mix-e6f2453",
|
|
)
|
|
|
|
def _on_start(cmd):
|
|
_write_install(install_dir, "b9596", release_tag = "b9596-mix-e6f2453")
|
|
|
|
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "success", job
|
|
assert job["to_tag"] == "b9596-mix-e6f2453"
|
|
assert "Updated llama.cpp to b9596-mix-e6f2453." in job["message"]
|
|
|
|
|
|
def _run_start_update_to_completion():
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
return job
|
|
time.sleep(0.05)
|
|
return upd.get_update_status()["job"]
|
|
|
|
|
|
def test_start_update_pinned_tag_mismatch_fails(monkeypatch, tmp_path):
|
|
# Installer stays on the pinned repo but produces a different tag -> it
|
|
# ignored the pin (the silent mismatch this pin exists to prevent). Fail loud.
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9595")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(
|
|
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9601-mix-a0e2906"
|
|
)
|
|
_patch_installer_popen(
|
|
monkeypatch,
|
|
on_start = lambda cmd: _write_install(install_dir, "b9500", release_tag = "b9500-mix-deadbee"),
|
|
)
|
|
job = _run_start_update_to_completion()
|
|
assert job["state"] == "error", job
|
|
assert "b9601-mix-a0e2906" in (job["error"] or "")
|
|
|
|
|
|
def test_start_update_pinned_reroute_to_other_repo_ok(monkeypatch, tmp_path):
|
|
# A Vulkan/Intel host reroutes fork->upstream and drops the pin, installing a
|
|
# different-repo tag. Legitimate: the pin check must not flag the repo switch.
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9595", repo = "unslothai/llama.cpp")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(
|
|
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9601-mix-a0e2906"
|
|
)
|
|
_patch_installer_popen(
|
|
monkeypatch,
|
|
on_start = lambda cmd: _write_install(install_dir, "b9601", repo = "ggml-org/llama.cpp"),
|
|
)
|
|
job = _run_start_update_to_completion()
|
|
assert job["state"] == "success", job
|
|
|
|
|
|
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
_patch_installer_popen(monkeypatch, returncode = 2, lines = ["boom: network error\n"])
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "error"
|
|
assert "boom" in (job["error"] or "")
|
|
|
|
|
|
# --- installer-argument construction (mirrors the post-#5963 setup scripts) ---
|
|
|
|
|
|
def test_rocm_install_args_gfx_family():
|
|
# Per-gfx ROCm bundle: gfx family lives in the asset name.
|
|
assert upd._rocm_install_args("app-b9585-linux-x64-rocm-gfx110X.tar.gz") == [
|
|
"--rocm-gfx",
|
|
"gfx110x",
|
|
]
|
|
assert upd._rocm_install_args("app-b9585-windows-x64-rocm-gfx1150.zip") == [
|
|
"--rocm-gfx",
|
|
"gfx1150",
|
|
]
|
|
|
|
|
|
def test_rocm_install_args_fork_version_bundle():
|
|
# Fork ROCm bundles encode a ROCm version, not a gfx -> forward --has-rocm.
|
|
assert upd._rocm_install_args("llama-b9334-bin-ubuntu-rocm-6.4-x64.tar.gz") == ["--has-rocm"]
|
|
|
|
|
|
def test_rocm_install_args_windows_hip():
|
|
assert upd._rocm_install_args("llama-b9334-bin-win-hip-radeon-x64.zip") == ["--has-rocm"]
|
|
|
|
|
|
def test_rocm_install_args_non_rocm_and_missing():
|
|
assert upd._rocm_install_args("llama-b9334-bin-ubuntu-x64.tar.gz") == []
|
|
assert upd._rocm_install_args("app-b9585-linux-x64-cuda13.tar.gz") == []
|
|
assert upd._rocm_install_args(None) == []
|
|
|
|
|
|
def _capture_install_cmd(
|
|
monkeypatch,
|
|
tmp_path,
|
|
*,
|
|
tag = "b9493",
|
|
repo = "unslothai/llama.cpp",
|
|
asset = None,
|
|
latest = "b9518",
|
|
) -> list:
|
|
"""Run start_update() with the installer subprocess stubbed; return the argv."""
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, tag, repo = repo, asset = asset)
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: latest)
|
|
|
|
captured = {}
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = "installed"
|
|
stderr = ""
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
cmd = list(cmd)
|
|
assert "--version" in cmd # only status polls still use run()
|
|
return _Proc()
|
|
|
|
def _on_start(cmd):
|
|
captured["cmd"] = cmd
|
|
_write_install(install_dir, latest, repo = repo, asset = asset)
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
|
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True, res
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
if upd.get_update_status()["job"]["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
return captured.get("cmd", [])
|
|
|
|
|
|
def test_install_cmd_rocm_marker_forwards_gfx(monkeypatch, tmp_path):
|
|
cmd = _capture_install_cmd(
|
|
monkeypatch, tmp_path, asset = "app-b9585-linux-x64-rocm-gfx110X.tar.gz"
|
|
)
|
|
assert "--rocm-gfx" in cmd
|
|
assert cmd[cmd.index("--rocm-gfx") + 1] == "gfx110x"
|
|
assert "--has-rocm" not in cmd
|
|
assert "--force-cpu" not in cmd
|
|
assert "--simple-policy" not in cmd
|
|
assert "--published-repo" in cmd and "unslothai/llama.cpp" in cmd
|
|
|
|
|
|
def test_install_cmd_fork_rocm_marker_forwards_has_rocm(monkeypatch, tmp_path):
|
|
cmd = _capture_install_cmd(
|
|
monkeypatch, tmp_path, asset = "llama-b9334-bin-ubuntu-rocm-6.4-x64.tar.gz"
|
|
)
|
|
assert "--has-rocm" in cmd
|
|
assert "--rocm-gfx" not in cmd
|
|
|
|
|
|
def test_install_cmd_ggml_cpu_marker_has_no_cpu_fallback(monkeypatch, tmp_path):
|
|
# Legacy CPU installs recorded a ggml-org marker (new installs use the fork) with
|
|
# no force_cpu field. Re-running into the same install-dir/repo reproduces the same
|
|
# CPU bundle; --force-cpu (the persisted-CPU re-assert) must not appear for a marker
|
|
# that never recorded a deliberate CPU choice, so it can still heal to GPU (#6097).
|
|
cmd = _capture_install_cmd(
|
|
monkeypatch,
|
|
tmp_path,
|
|
repo = "ggml-org/llama.cpp",
|
|
asset = "llama-b9334-bin-ubuntu-x64.tar.gz",
|
|
)
|
|
assert "--force-cpu" not in cmd
|
|
assert "--rocm-gfx" not in cmd
|
|
assert "--has-rocm" not in cmd
|
|
assert "--simple-policy" not in cmd
|
|
assert "--published-repo" in cmd and "ggml-org/llama.cpp" in cmd
|
|
|
|
|
|
def test_install_cmd_cuda_marker_minimal_and_backward_compatible(monkeypatch, tmp_path):
|
|
# Marker without an asset field (older install): no ROCm flags, no crash, and
|
|
# never the obsolete --simple-policy that #5963 removed from setup.
|
|
cmd = _capture_install_cmd(monkeypatch, tmp_path, asset = None)
|
|
assert "--simple-policy" not in cmd
|
|
assert "--rocm-gfx" not in cmd
|
|
assert "--has-rocm" not in cmd
|
|
assert "--force-cpu" not in cmd
|
|
|
|
|
|
def test_install_cmd_pins_offered_release_tag(monkeypatch, tmp_path):
|
|
# Apply must install exactly the release the banner offered. The installer's
|
|
# own "latest" comes from commit-date-ordered sources, which can lag the
|
|
# published_at-newest tag detection picked; unpinned, that lag makes Update
|
|
# reinstall the current build while the banner never clears.
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
cmd = _capture_install_cmd(monkeypatch, tmp_path, latest = "b9601-mix-a0e2906")
|
|
# The full release identity is pinned, not the bare upstream base.
|
|
assert cmd[cmd.index("--published-release-tag") + 1] == "b9601-mix-a0e2906"
|
|
|
|
|
|
def test_install_cmd_pins_on_windows(monkeypatch, tmp_path):
|
|
# The darwin exemption must not leak to other platforms.
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
cmd = _capture_install_cmd(monkeypatch, tmp_path)
|
|
assert cmd[cmd.index("--published-release-tag") + 1] == "b9518"
|
|
|
|
|
|
def test_install_cmd_does_not_pin_on_macos(monkeypatch, tmp_path):
|
|
# A pinned tag disables the installer's older-release walk-back, which macOS
|
|
# needs to skip prebuilts built for a newer macOS than the host.
|
|
monkeypatch.setattr(sys, "platform", "darwin")
|
|
cmd = _capture_install_cmd(monkeypatch, tmp_path)
|
|
assert "--published-release-tag" not in cmd
|
|
assert "--llama-tag" in cmd and "latest" in cmd
|
|
|
|
|
|
# --- refusal + maintenance-state coordination ---
|
|
|
|
|
|
def test_start_update_already_running_refuses(monkeypatch, tmp_path):
|
|
binary = _write_install(tmp_path / "llama.cpp", "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
with upd._job_lock:
|
|
upd._job.update(state = upd._JOB_RUNNING)
|
|
res = upd.start_update()
|
|
assert res["started"] is False
|
|
assert res["reason"] == "already_running"
|
|
|
|
|
|
def test_start_update_installer_missing_refuses(monkeypatch, tmp_path):
|
|
binary = _write_install(tmp_path / "llama.cpp", "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: None)
|
|
res = upd.start_update()
|
|
assert res["started"] is False
|
|
assert res["reason"] == "installer_missing"
|
|
|
|
|
|
class _FakeBackend:
|
|
"""Fake backend for update coordination."""
|
|
|
|
def __init__(self):
|
|
import threading
|
|
|
|
self._serial_load_lock = threading.Lock()
|
|
self._llama_update_in_progress = False
|
|
self.is_active = True
|
|
self.unloaded = False
|
|
|
|
def unload_model(self):
|
|
self.unloaded = True
|
|
|
|
|
|
def _inject_backend(monkeypatch, backend):
|
|
routes_pkg = ModuleType("routes")
|
|
routes_pkg.__path__ = []
|
|
inference_mod = ModuleType("routes.inference")
|
|
inference_mod.get_llama_cpp_backend = lambda: backend
|
|
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
|
|
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
|
|
|
|
|
|
def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
backend = _FakeBackend()
|
|
_inject_backend(monkeypatch, backend)
|
|
|
|
seen = {}
|
|
|
|
def _on_start(cmd):
|
|
seen["flag_during_install"] = backend._llama_update_in_progress
|
|
_write_install(install_dir, "b9518")
|
|
|
|
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
if upd.get_update_status()["job"]["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
assert backend.unloaded is True
|
|
assert upd.get_update_status()["job"]["reload_required"] is True
|
|
assert seen.get("flag_during_install") is True
|
|
assert backend._llama_update_in_progress is False
|
|
|
|
|
|
def test_update_clears_maintenance_flag_on_installer_failure(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
backend = _FakeBackend()
|
|
_inject_backend(monkeypatch, backend)
|
|
|
|
_patch_installer_popen(monkeypatch, returncode = 1, lines = ["boom\n"])
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
if upd.get_update_status()["job"]["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert upd.get_update_status()["job"]["state"] == "error"
|
|
assert backend._llama_update_in_progress is False
|
|
|
|
|
|
def test_update_fails_open_when_backend_unavailable(monkeypatch, tmp_path):
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = _write_install(install_dir, "b9493")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
|
|
def _raise():
|
|
raise RuntimeError("no backend")
|
|
|
|
inference_mod = ModuleType("routes.inference")
|
|
inference_mod.get_llama_cpp_backend = lambda: _raise()
|
|
routes_pkg = ModuleType("routes")
|
|
routes_pkg.__path__ = []
|
|
monkeypatch.setitem(sys.modules, "routes", routes_pkg)
|
|
monkeypatch.setitem(sys.modules, "routes.inference", inference_mod)
|
|
|
|
_patch_installer_popen(monkeypatch, on_start = lambda cmd: _write_install(install_dir, "b9518"))
|
|
|
|
res = upd.start_update()
|
|
assert res["started"] is True
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
job = upd.get_update_status()["job"]
|
|
if job["state"] in ("success", "error"):
|
|
break
|
|
time.sleep(0.05)
|
|
assert job["state"] == "success", job
|
|
|
|
|
|
# --- markerless helper units ---
|
|
|
|
|
|
def test_resolve_prebuilt_parses_and_caches(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
calls = {"n": 0}
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
# stderr noise plus the JSON line on stdout (installer logs to stderr).
|
|
stdout = (
|
|
'{"prebuilt_available": true, "repo": "unslothai/llama.cpp", "release_tag": "b9585"}'
|
|
)
|
|
stderr = "[llama-prebuilt] some log\n"
|
|
|
|
def _fake_run(cmd, **kwargs):
|
|
calls["n"] += 1
|
|
assert "--resolve-prebuilt" in cmd
|
|
return _Proc()
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", _fake_run)
|
|
res = upd._resolve_prebuilt_for_host()
|
|
assert res["prebuilt_available"] is True and res["release_tag"] == "b9585"
|
|
# Second call is memoized (no second subprocess).
|
|
upd._resolve_prebuilt_for_host()
|
|
assert calls["n"] == 1
|
|
|
|
|
|
def test_resolve_prebuilt_fails_open(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
|
|
def _boom(cmd, **kwargs):
|
|
raise OSError("subprocess failed")
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", _boom)
|
|
assert upd._resolve_prebuilt_for_host() is None
|
|
# Failures are not cached: a later success is observed.
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = '{"prebuilt_available": false}'
|
|
stderr = ""
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
|
|
assert upd._resolve_prebuilt_for_host() == {"prebuilt_available": False}
|
|
|
|
|
|
def test_installed_build_number(monkeypatch):
|
|
def _ver(text):
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = text
|
|
|
|
monkeypatch.setattr(upd.subprocess, "run", lambda cmd, **kw: _Proc())
|
|
return upd._installed_build_number("/bin/llama-server")
|
|
|
|
assert _ver("version: 9585 (abc1234)\nbuilt with clang\n") == 9585
|
|
assert _ver("version: 1 (deadbee)\n") is None # source build without tags
|
|
assert _ver("no version here") is None
|
|
assert upd._installed_build_number(None) is None
|
|
|
|
|
|
def test_llama_install_root_finds_llama_cpp_ancestor(monkeypatch, tmp_path):
|
|
root = tmp_path / "llama.cpp"
|
|
binary = root / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
|
assert upd._llama_install_root(str(binary)) == root
|
|
|
|
|
|
def test_llama_install_root_unmanaged_path_returns_none(monkeypatch, tmp_path):
|
|
# A binary on PATH (no marker, no env pin, no llama.cpp ancestor) is foreign:
|
|
# installing elsewhere would not replace it, so report no manageable root.
|
|
binary = tmp_path / "usr" / "local" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
|
assert upd._llama_install_root(str(binary)) is None
|
|
|
|
|
|
def test_llama_install_root_unsloth_env_dir(monkeypatch, tmp_path):
|
|
# UNSLOTH_LLAMA_CPP_PATH dir holding the active binary is the managed root.
|
|
root = tmp_path / "vendor" / "llama"
|
|
binary = root / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setenv("UNSLOTH_LLAMA_CPP_PATH", str(root))
|
|
assert upd._llama_install_root(str(binary)) == root
|
|
|
|
|
|
def test_llama_install_root_ignores_inactive_env_root(monkeypatch, tmp_path):
|
|
# UNSLOTH_LLAMA_CPP_PATH set but the active binary is not under it: do not
|
|
# target the stale env root, resolve from the binary's own llama.cpp tree.
|
|
inactive = tmp_path / "custom-empty"
|
|
inactive.mkdir()
|
|
active = tmp_path / "llama.cpp"
|
|
binary = active / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setenv("UNSLOTH_LLAMA_CPP_PATH", str(inactive))
|
|
assert upd._llama_install_root(str(binary)) == active
|
|
|
|
|
|
def test_llama_install_root_refuses_pinned_checkout_under_llama_cpp(monkeypatch, tmp_path):
|
|
# The LLAMA_SERVER_PATH pin guard must run before the ancestor scan, or a
|
|
# user's own llama.cpp checkout could be handed to the installer.
|
|
root = tmp_path / "my-project" / "llama.cpp"
|
|
binary = root / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub")
|
|
monkeypatch.setenv("LLAMA_SERVER_PATH", str(binary))
|
|
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
|
|
assert upd._llama_install_root(str(binary)) is None
|
|
|
|
|
|
def test_start_update_source_build_refuses_when_newer(monkeypatch, tmp_path):
|
|
# A direct POST on a source build already newer than the prebuilt must not
|
|
# downgrade it; start_update mirrors the detection suppression.
|
|
install_dir = tmp_path / "llama.cpp"
|
|
binary = install_dir / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub") # no marker
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
_prebuilt(monkeypatch, release_tag = "b9518")
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9600)
|
|
res = upd.start_update()
|
|
assert res["started"] is False
|
|
assert res["reason"] == "up_to_date"
|
|
|
|
|
|
# --- mix-tag detection + apply guard (the reported banner bug) ---
|
|
|
|
|
|
def test_status_not_offered_on_mix_latest(monkeypatch, tmp_path):
|
|
# Installed the mix latest; GitHub latest is that same full tag -> no banner.
|
|
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(
|
|
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
|
|
)
|
|
st = upd.get_update_status()
|
|
assert st["update_available"] is False
|
|
assert st["installed_tag"] == "b9596"
|
|
assert st["latest_tag"] == "b9596-mix-e6f2453"
|
|
|
|
|
|
def test_status_not_offered_when_latest_lags(monkeypatch, tmp_path):
|
|
# A lagging latest (older build than installed) must never be offered.
|
|
binary = _write_install(tmp_path / "llama.cpp", "b9585")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
st = upd.get_update_status()
|
|
assert st["update_available"] is False
|
|
|
|
|
|
def test_start_update_marked_refuses_when_not_behind(monkeypatch, tmp_path):
|
|
# A direct POST / stale banner must not reinstall when already on the latest.
|
|
binary = _write_install(tmp_path / "llama.cpp", "b9596", release_tag = "b9596-mix-e6f2453")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
|
monkeypatch.setattr(
|
|
freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9596-mix-e6f2453"
|
|
)
|
|
res = upd.start_update()
|
|
assert res["started"] is False
|
|
assert res["reason"] == "up_to_date"
|
|
|
|
|
|
def test_status_update_available_includes_size(monkeypatch, tmp_path):
|
|
# Marker (prebuilt) update path attaches the download size of the asset the
|
|
# banner would fetch.
|
|
binary = _write_install(tmp_path, "b9493", asset = "app-b9493-linux-x64-cuda13-newer.tar.gz")
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
|
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: "b9518")
|
|
monkeypatch.setattr(
|
|
freshness,
|
|
"latest_release_assets",
|
|
lambda repo, *, force_refresh = False: {
|
|
"app-b9518-linux-x64-cuda13-newer.tar.gz": 88_000_000
|
|
},
|
|
)
|
|
st = upd.get_update_status(force_refresh = True)
|
|
assert st["update_available"] is True
|
|
assert st["update_size_bytes"] == 88_000_000
|
|
|
|
|
|
def test_status_source_build_includes_update_size(monkeypatch, tmp_path):
|
|
# #6338 P3: a source build offered a prebuilt must carry the asset size too.
|
|
binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server"
|
|
binary.parent.mkdir(parents = True)
|
|
binary.write_text("stub") # no marker -> source build
|
|
monkeypatch.setattr(upd, "_find_binary", lambda: str(binary))
|
|
_prebuilt(
|
|
monkeypatch,
|
|
repo = "unslothai/llama.cpp",
|
|
release_tag = "b9585",
|
|
asset = "app-b9585-linux-x64-cpu.tar.gz",
|
|
)
|
|
monkeypatch.setattr(upd, "_installed_build_number", lambda b: None)
|
|
monkeypatch.setattr(
|
|
upd,
|
|
"latest_release_assets",
|
|
lambda repo, *, force_refresh = False: (
|
|
{"app-b9585-linux-x64-cpu.tar.gz": 77_000_000}
|
|
if repo == "unslothai/llama.cpp"
|
|
else None
|
|
),
|
|
)
|
|
st = upd.get_update_status()
|
|
assert st["source_build"] is True
|
|
assert st["update_available"] is True
|
|
assert st["update_size_bytes"] == 77_000_000
|