From 109a6662602ff4c295306ee53ece6f364c94e9ec Mon Sep 17 00:00:00 2001 From: OrbisAI Security Date: Sun, 31 May 2026 12:02:54 +0530 Subject: [PATCH 1/6] fix: bump openssl, rand, rustls-webpki, tar in Studio src-tauri for security advisories (#5866) Lockfile-only security bumps in studio/src-tauri: - openssl 0.10.76 -> 0.10.80 (CVE-2026-41676) - rand 0.8.5 -> 0.8.6 and 0.10.0 -> 0.10.1 (RUSTSEC-2026-0097) - rustls-webpki 0.103.10 -> 0.103.13 (RUSTSEC-2026-0098/0099/0104) - tar 0.4.45 -> 0.4.46 (GHSA-3pv8-6f4r-ffg2) Verified with a Tauri Linux debug build and the lockfile supply-chain audit. --- studio/src-tauri/Cargo.lock | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/studio/src-tauri/Cargo.lock b/studio/src-tauri/Cargo.lock index c73d7cc0c5..df350e4011 100644 --- a/studio/src-tauri/Cargo.lock +++ b/studio/src-tauri/Cargo.lock @@ -2731,15 +2731,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2763,9 +2762,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2978,7 +2977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -2988,7 +2987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3361,9 +3360,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3382,9 +3381,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3765,9 +3764,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4450,9 +4449,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5380,7 +5379,7 @@ dependencies = [ "log", "open", "process-wrap", - "rand 0.10.0", + "rand 0.10.1", "regex", "reqwest 0.12.28", "serde", From 87ee06a9932fed77bb78093b24cf3c3c0d390fe5 Mon Sep 17 00:00:00 2001 From: Suyadi <104284194+afsuyadi@users.noreply.github.com> Date: Sun, 31 May 2026 14:05:42 +0700 Subject: [PATCH 2/6] Fix error when context-resizing (#5860) Harden local GGUF detection so a .gguf path is not misrouted to the transformers backend during the brief Windows lock window after llama-server is killed. Catch OSError from stat() and treat the path as the file, while a directory named *.gguf still falls through to the directory scan. Adds regression tests for the lock-window and directory cases. --- studio/backend/tests/test_gguf_routing.py | 102 ++++++++++++++++++++ studio/backend/utils/models/model_config.py | 15 ++- 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 studio/backend/tests/test_gguf_routing.py diff --git a/studio/backend/tests/test_gguf_routing.py b/studio/backend/tests/test_gguf_routing.py new file mode 100644 index 0000000000..1b299cba19 --- /dev/null +++ b/studio/backend/tests/test_gguf_routing.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Tests for GGUF routing in detect_gguf_model. + +Regression test for the bug where a .gguf file temporarily appears +inaccessible on Windows during llama-server process teardown, causing +is_file() to return False and the model to be routed to the transformers +backend instead of llama-server. +""" + +import sys +import os +import types +from pathlib import Path +from unittest.mock import patch + +# Stub structlog before importing backend modules (mirrors other tests in this suite) +if "structlog" not in sys.modules: + + class _DummyLogger: + def __getattr__(self, _): + return lambda *a, **k: None + + sys.modules["structlog"] = types.SimpleNamespace( + get_logger = lambda *a, **k: _DummyLogger(), + BoundLogger = _DummyLogger, + ) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils.models.model_config import detect_gguf_model + + +def test_detects_gguf_file_normally(tmp_path): + """Normal case: .gguf file exists and is accessible.""" + gguf = tmp_path / "gpt-oss-20b-MXFP4.gguf" + gguf.write_bytes(b"") + result = detect_gguf_model(str(gguf)) + assert result is not None + assert result.endswith("gpt-oss-20b-MXFP4.gguf") + + +def test_detects_gguf_when_stat_raises_oserror(tmp_path): + """ + Regression: on Windows, both is_file() and exists() call stat() internally. + During the brief lock window after llama-server is killed, stat() raises + OSError, causing both to return False. detect_gguf_model must still route + to llama-server based on the file extension alone. + """ + gguf = tmp_path / "gpt-oss-20b-MXFP4.gguf" + gguf.write_bytes(b"") + + original_stat = Path.stat + + def flaky_stat(self, **kwargs): + if self.suffix.lower() == ".gguf": + raise OSError("file temporarily inaccessible (Windows lock window)") + return original_stat(self, **kwargs) + + with patch.object(Path, "stat", flaky_stat): + result = detect_gguf_model(str(gguf)) + + assert result is not None, ( + "detect_gguf_model returned None when stat() raised OSError. " + "This causes the model to fall through to the transformers backend." + ) + + +def test_does_not_detect_mmproj_as_main_model(tmp_path): + """mmproj files must never be returned as the primary model.""" + mmproj = tmp_path / "mmproj-model-f16.gguf" + mmproj.write_bytes(b"") + result = detect_gguf_model(str(mmproj)) + assert result is None + + +def test_detects_gguf_in_directory(tmp_path): + """Directory containing a .gguf file is resolved to that file.""" + gguf = tmp_path / "model-Q4_K_M.gguf" + gguf.write_bytes(b"") + result = detect_gguf_model(str(tmp_path)) + assert result is not None + assert result.endswith("model-Q4_K_M.gguf") + + +def test_directory_named_like_gguf_scans_inside(tmp_path): + """A directory named *.gguf resolves the real .gguf inside, not itself.""" + gguf_dir = tmp_path / "mymodel.gguf" + gguf_dir.mkdir() + inner = gguf_dir / "model-Q4_K_M.gguf" + inner.write_bytes(b"") + result = detect_gguf_model(str(gguf_dir)) + assert result is not None + assert result.endswith("model-Q4_K_M.gguf") + + +def test_returns_none_for_non_gguf_path(tmp_path): + """Non-.gguf paths with no .gguf files inside return None.""" + result = detect_gguf_model(str(tmp_path)) + assert result is None diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 993995ee57..dc34444ccb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1156,13 +1156,18 @@ def detect_gguf_model(path: str) -> Optional[str]: p = Path(path) # Case 1: direct .gguf file - if p.suffix.lower() == ".gguf" and p.is_file(): + if p.suffix.lower() == ".gguf": if _is_mmproj(p.name): return None - # Use absolute (not resolve) to preserve symlink names -- e.g. - # Ollama .studio_links/model.gguf -> blobs/sha256-... should - # keep the readable symlink name, not the opaque blob hash. - return str(p.absolute()) + # Extension is authoritative: don't gate on is_file()/exists(), which + # can fail in the Windows lock window after llama-server is killed. + try: + is_dir = p.is_dir() + except OSError: + is_dir = False # stat() unavailable in the lock window + if not is_dir: + return str(p.absolute()) # absolute() keeps symlink names readable + # Directory named "*.gguf": fall through to the dir scan below. # Case 2: directory containing .gguf files (skip mmproj) if p.is_dir(): From 1016fff1d6e13d2d61afb0c815a89dbb7a6ef19d Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sun, 31 May 2026 00:53:40 -0700 Subject: [PATCH 3/6] Update Studio sidebar logout icon to logout-05 (#5878) Co-authored-by: Unsloth --- studio/frontend/src/components/app-sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 849e017ea8..4e4a6130cd 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -49,7 +49,7 @@ import { Edit03Icon, Globe02Icon, HelpCircleIcon, - Logout01Icon, + Logout05Icon, Search01Icon, PowerIcon, PencilEdit02Icon, @@ -796,7 +796,7 @@ export function AppSidebar() { void navigate({ to: "/login" }); }} > - + {t("shell.navigation.logOut")} setShutdownOpen(true)}> From 366937de44ded599b569f9a90521ee9da32dcdee Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 00:59:25 -0700 Subject: [PATCH 4/6] studio: pick a macOS llama.cpp prebuilt that loads on the host OS (#5883) Make macOS llama.cpp prebuilt selection host-OS-version aware: skip a prebuilt whose minimum-OS exceeds the host and walk back to the newest release that loads (macOS 26 keeps latest; 14/15 land on a compatible older release). Source-build fallback pins CMAKE_OSX_DEPLOYMENT_TARGET=13.3. CI: binary-load assertion plus a macos-14/15/26 install matrix. No change to Linux/Windows or CUDA selection. --- .github/scripts/assert-llama-loads.sh | 57 ++++ .github/workflows/studio-mac-api-smoke.yml | 9 +- .../workflows/studio-mac-inference-smoke.yml | 27 +- .../workflows/studio-mac-install-matrix.yml | 80 +++++ .github/workflows/studio-mac-ui-smoke.yml | 9 +- .github/workflows/studio-mac-update-smoke.yml | 17 +- studio/install_llama_prebuilt.py | 228 ++++++++++++- studio/setup.sh | 9 + .../install/test_macos_version_compat.py | 322 ++++++++++++++++++ 9 files changed, 705 insertions(+), 53 deletions(-) create mode 100755 .github/scripts/assert-llama-loads.sh create mode 100644 .github/workflows/studio-mac-install-matrix.yml create mode 100644 tests/studio/install/test_macos_version_compat.py diff --git a/.github/scripts/assert-llama-loads.sh b/.github/scripts/assert-llama-loads.sh new file mode 100755 index 0000000000..c2ffe27469 --- /dev/null +++ b/.github/scripts/assert-llama-loads.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests +# the contract that matters (binaries load and their minimum-OS is <= this host) +# instead of the old "did install.sh fall back to a source build?" grep, since a +# source build with a correct deployment target is a valid outcome. +set -uo pipefail + +UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}" +LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}" +BIN_DIR="$LLAMA_DIR/build/bin" + +fail() { + echo "::error::$*" + if [ -f logs/install.log ]; then + echo "---- install.log (llama.cpp lines) ----" + grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true + fi + exit 1 +} + +SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)" +QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)" +[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install" +[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install" + +HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')" +HOST_MAJOR="${HOST_VER%%.*}" + +# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode +# command line tools, which GitHub macOS runners always have; if it is somehow +# missing we skip the static check and rely on the runtime launch below. +if command -v vtool >/dev/null 2>&1; then + while IFS= read -r macho; do + [ -n "$macho" ] || continue + minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')" + [ -n "$minos" ] || continue + min_major="${minos%%.*}" + if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then + fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)" + fi + done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null) +fi + +# Runtime launch: --version forces dyld to load every linked dylib (including +# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here. +if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then + echo "---- llama-server --version output ----" + cat /tmp/llama-server-version.txt || true + fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)" +fi + +echo "llama.cpp load validation passed on macOS $HOST_VER" +echo " server: $SERVER" +sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index b4e274155e..8839b559fd 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -89,13 +89,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index fab0a36bd1..1096b1abb4 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -114,13 +114,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' @@ -369,13 +364,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Reset auth + boot Studio (API-only, default tool policy) # We deliberately use the API-only mode rather than @@ -760,13 +750,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' diff --git a/.github/workflows/studio-mac-install-matrix.yml b/.github/workflows/studio-mac-install-matrix.yml new file mode 100644 index 0000000000..4e2722d1cd --- /dev/null +++ b/.github/workflows/studio-mac-install-matrix.yml @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Proves Studio's llama.cpp install loads on every supported macOS. The heavy +# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply +# (install.sh + binary-load assert). Regression guard for the macOS-version +# selection in studio/install_llama_prebuilt.py. + +name: Mac Studio Install Matrix CI + +on: + pull_request: + paths: + - 'studio/install_llama_prebuilt.py' + - 'studio/setup.sh' + - 'install.sh' + - '.github/scripts/assert-llama-loads.sh' + - '.github/workflows/studio-mac-install-matrix.yml' + push: + branches: [main, pip] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + install-load: + name: Install + load (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 # Apple Silicon, macOS 14 Sonoma + experimental: false + - os: macos-15 # Apple Silicon, macOS 15 Sequoia + experimental: false + - os: macos-26 # Apple Silicon, macOS 26 Tahoe + experimental: false + - os: macos-15-intel # Intel x86_64, macOS 15 (informational) + experimental: true + - os: macos-26-intel # Intel x86_64, macOS 26 (last Intel macOS) + experimental: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Install Studio (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh + + - name: Upload install log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mac-install-matrix-${{ matrix.os }}-log + path: logs/install.log + retention-days: 7 diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index b353f0ec83..0176b0a168 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -89,13 +89,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install Playwright + Chromium # No --with-deps on Mac: that flag installs Linux apt packages. diff --git a/.github/workflows/studio-mac-update-smoke.yml b/.github/workflows/studio-mac-update-smoke.yml index b65439f174..1eca227096 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -67,21 +67,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - # Mac install must take the prebuilt path. Source-build - # fallback here is an Unsloth bug. - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi - if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated|bin-macos-arm64" logs/install.log; then - echo "::error::no Mac prebuilt llama.cpp marker in install.log." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi - echo "install.sh installed the Mac prebuilt llama.cpp" + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: First update should be a no-op (prebuilt already validated) env: diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 9443b13fd7..38fa31fff8 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -19,6 +19,7 @@ import re import shutil import site import socket +import struct import subprocess import sys import tarfile @@ -160,6 +161,14 @@ DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( 2, minimum = 1, ) +# Deeper macOS-only walk-back: upstream can ship a run of prebuilts built for a +# newer macOS than the host, only caught at validate time, so an older host must +# skip the whole run. Free on new hosts (first plan validates, extras unused). +DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int( + "UNSLOTH_LLAMA_MAX_MACOS_RELEASE_FALLBACKS", + 16, + minimum = 1, +) FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { @@ -231,6 +240,9 @@ class HostInfo: has_usable_nvidia: bool has_rocm: bool = False rocm_gfx_target: str | None = None + # (major, minor) from platform.mac_ver(); None off macOS or if unparseable. + # Skips a macos prebuilt whose minimum-OS exceeds this host. + macos_version: tuple[int, int] | None = None @dataclass @@ -1528,6 +1540,14 @@ def resolve_simple_install_release_plans( requested_tag == "latest" and not published_release_tag ) release_limit = max(1, max_release_fallbacks) + # macOS may need to walk past a run of too-new prebuilts. Only when the host + # version is known; otherwise keep the default (cannot tell up front). + if ( + host.is_macos + and allow_older_release_fallback + and host.macos_version is not None + ): + release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS) plans: list[InstallReleasePlan] = [] last_error: PrebuiltFallback | None = None @@ -2750,6 +2770,8 @@ def detect_host() -> HostInfo: is_x86_64 = machine in {"x86_64", "amd64"} is_arm64 = machine in {"arm64", "aarch64"} + macos_version = parse_macos_version(platform.mac_ver()[0]) if is_macos else None + nvidia_smi = shutil.which("nvidia-smi") driver_cuda_version = None compute_caps: list[str] = [] @@ -2925,6 +2947,7 @@ def detect_host() -> HostInfo: has_usable_nvidia = has_usable_nvidia, has_rocm = has_rocm, rocm_gfx_target = rocm_gfx_target, + macos_version = macos_version, ) @@ -4872,6 +4895,189 @@ def linux_runtime_dirs(binary_path: Path) -> list[str]: return linux_runtime_dirs_for_required_libraries(missing) +# macOS prebuilt compatibility. Upstream macos prebuilts built on a newer macOS +# (e.g. minos=26, referencing Metal-4 symbols) fail dyld load on macOS 14/15. We +# read the host macOS version and each binary's minimum-OS so selection can skip +# a too-new prebuilt and walk back to the newest release that runs on this host. +# Mach-O constants (Apple mach-o/fat.h, mach-o/loader.h, mach/machine.h). +_MACHO_FAT_MAGICS = {0xCAFEBABE, 0xCAFEBABF} # universal binary (32/64-bit fat) +_LC_VERSION_MIN_MACOSX = 0x24 # legacy min-macOS load command +_LC_BUILD_VERSION = 0x32 # modern platform+minos+sdk load command +_MACHO_PLATFORM_MACOS = 1 # LC_BUILD_VERSION platform id for macOS (iOS=2, ...) +# CPU types (base | ABI64); used to pick the host slice in a fat binary. +_CPU_TYPE_X86_64 = 0x01000007 +_CPU_TYPE_ARM64 = 0x0100000C + + +def parse_macos_version(value: str | None) -> tuple[int, int] | None: + """Parse a macOS product version string into (major, minor). + + Handles "14.7.1", "15.5", "26.0" and bare "26". Returns None when the + value is empty or cannot be parsed (callers then defer to runtime + validation rather than rejecting every prebuilt).""" + if not value: + return None + match = re.match(r"\s*(\d+)(?:\.(\d+))?", str(value)) + if not match: + return None + return int(match.group(1)), int(match.group(2) or 0) + + +def host_supports_macos_minos(host: HostInfo, minos: tuple[int, int] | None) -> bool: + """True if a prebuilt requiring `minos` can load on this host. Unknown host + version or unknown minos -> True: let runtime validation decide instead of + rejecting a binary we cannot reason about.""" + if minos is None or host.macos_version is None: + return True + return host.macos_version >= minos + + +def _macho_slice_minos(data: bytes, offset: int) -> tuple[int, int] | None: + """Minimum macOS for a single thin Mach-O at `offset`, via LC_BUILD_VERSION + (platform macOS) or the legacy LC_VERSION_MIN_MACOSX. None if absent.""" + if offset + 4 > len(data): + return None + magic = struct.unpack_from(">I", data, offset)[0] + if magic in (0xFEEDFACE, 0xFEEDFACF): + endian, is64 = ">", magic == 0xFEEDFACF + elif magic in (0xCEFAEDFE, 0xCFFAEDFE): + endian, is64 = "<", magic == 0xCFFAEDFE + else: + return None + header_size = 32 if is64 else 28 + if offset + header_size > len(data): + return None + ncmds = struct.unpack_from(endian + "I", data, offset + 16)[0] + cursor = offset + header_size + for _ in range(ncmds): + if cursor + 8 > len(data): + break + cmd, cmdsize = struct.unpack_from(endian + "II", data, cursor) + if cmdsize < 8: + break + if cmd == _LC_BUILD_VERSION and cursor + 16 <= len(data): + platform_id, minos = struct.unpack_from(endian + "II", data, cursor + 8) + if platform_id == _MACHO_PLATFORM_MACOS: + return (minos >> 16) & 0xFFFF, (minos >> 8) & 0xFF + elif cmd == _LC_VERSION_MIN_MACOSX and cursor + 12 <= len(data): + version = struct.unpack_from(endian + "I", data, cursor + 8)[0] + return (version >> 16) & 0xFFFF, (version >> 8) & 0xFF + cursor += cmdsize + return None + + +def macho_minimum_macos( + path: Path, host: HostInfo | None = None +) -> tuple[int, int] | None: + """Minimum macOS (major, minor) a Mach-O binary or dylib requires. + + Pure-Python so it works on consumer Macs without the Xcode command line + tools (otool/vtool). For universal binaries it prefers the host-arch slice, + else the highest minos found. Returns None for non-Mach-O files or when no + version load command is present.""" + try: + data = path.read_bytes() + except Exception: + return None + if len(data) < 8: + return None + magic = struct.unpack_from(">I", data, 0)[0] + if magic in _MACHO_FAT_MAGICS: + is64 = magic == 0xCAFEBABF + nfat = struct.unpack_from(">I", data, 4)[0] + entry = 8 + slices: list[tuple[int, tuple[int, int]]] = [] + for _ in range(nfat): + if is64: + if entry + 32 > len(data): + break + cputype = struct.unpack_from(">I", data, entry)[0] + slice_offset = struct.unpack_from(">Q", data, entry + 8)[0] + entry += 32 + else: + if entry + 20 > len(data): + break + cputype = struct.unpack_from(">I", data, entry)[0] + slice_offset = struct.unpack_from(">I", data, entry + 8)[0] + entry += 20 + minos = _macho_slice_minos(data, slice_offset) + if minos is not None: + slices.append((cputype, minos)) + if not slices: + return None + if host is not None: + want = ( + _CPU_TYPE_ARM64 + if host.is_arm64 + else (_CPU_TYPE_X86_64 if host.is_x86_64 else None) + ) + for cputype, minos in slices: + if cputype == want: + return minos + return max(minos for _cputype, minos in slices) + return _macho_slice_minos(data, 0) + + +def looks_like_macos_incompatibility(text: str) -> bool: + """True when dyld output means a prebuilt needs a newer macOS than the host + (the runtime backstop for cases the static minos scan cannot read).""" + if not text: + return False + if "built for macOS" in text and "newer than running OS" in text: + return True + return "Symbol not found" in text and "MTLResidency" in text + + +def macos_binary_minos_issues( + binaries: Iterable[Path], + install_dir: Path, + host: HostInfo, +) -> list[str]: + """Issue strings for every installed Mach-O whose minimum macOS exceeds the + host. Scans the given executables plus every bundled .dylib next to them -- + the dyld failure originates in libggml-metal.dylib, not the executable.""" + candidates: list[Path] = list(binaries) + bin_dir = install_dir / "build" / "bin" + if bin_dir.is_dir(): + candidates.extend(sorted(bin_dir.rglob("*.dylib"))) + + issues: list[str] = [] + seen: set[Path] = set() + for path in candidates: + try: + resolved = path.resolve() + except Exception: + resolved = path + if resolved in seen or not path.exists(): + continue + seen.add(resolved) + minos = macho_minimum_macos(path, host) + if minos is not None and not host_supports_macos_minos(host, minos): + issues.append( + f"{path.name}: built for macOS {minos[0]}.{minos[1]} > " + f"host macOS {host.macos_version[0]}.{host.macos_version[1]}" + ) + return issues + + +def preflight_macos_installed_binaries( + binaries: Iterable[Path], + install_dir: Path, + host: HostInfo, +) -> None: + """Reject a macos prebuilt whose minimum-OS is newer than the host so the + release walk-back advances to the newest compatible release. No-op when the + host macOS version is unknown (runtime validation remains the backstop).""" + if not host.is_macos or host.macos_version is None: + return + issues = macos_binary_minos_issues(binaries, install_dir, host) + if issues: + raise PrebuiltFallback( + "macos prebuilt requires a newer macOS than this host:\n" + + "\n".join(issues) + ) + + def preflight_linux_installed_binaries( binaries: Iterable[Path], install_dir: Path, @@ -5030,10 +5236,17 @@ def validate_quantize( or not quantized_path.exists() or quantized_path.stat().st_size == 0 ): + combined = result.stdout + ("\n" + result.stderr if result.stderr else "") + # Backstop for prebuilts the static minos scan could not read: a dyld + # "built for macOS N" / missing Metal symbol failure means this binary + # needs a newer macOS than the host, so fall back to an older release. + prefix = ( + "macos prebuilt requires a newer macOS than this host: " + if looks_like_macos_incompatibility(combined) + else "" + ) raise PrebuiltFallback( - "llama-quantize validation failed:\n" - + result.stdout - + ("\n" + result.stderr if result.stderr else "") + prefix + "llama-quantize validation failed:\n" + combined ) @@ -5451,6 +5664,14 @@ def resolve_install_release_plans( requested_tag == "latest" and not published_release_tag ) release_limit = max(1, max_release_fallbacks) + # macOS may need to walk past a run of too-new prebuilts. Only when the host + # version is known; otherwise keep the default (cannot tell up front). + if ( + host.is_macos + and allow_older_release_fallback + and host.macos_version is not None + ): + release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS) plans: list[InstallReleasePlan] = [] last_error: PrebuiltFallback | None = None @@ -5835,6 +6056,7 @@ def validate_prebuilt_choice( choice, host, install_dir, work_dir ) preflight_linux_installed_binaries((server_path, quantize_path), install_dir, host) + preflight_macos_installed_binaries((server_path, quantize_path), install_dir, host) ensure_repo_shape(install_dir) write_prebuilt_metadata( install_dir, diff --git a/studio/setup.sh b/studio/setup.sh index 89489f38b0..3a149d465f 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1084,6 +1084,15 @@ else _IS_MACOS_ARM64=true fi + # macOS: pin a low deployment target so the source build loads on + # older macOS too (else a macOS 26 host stamps minos=26). Set before + # CPU_FALLBACK_CMAKE_ARGS copies CMAKE_ARGS so both paths inherit it. + if [ "$_HOST_SYSTEM" = "Darwin" ]; then + _MACOS_DEPLOYMENT_TARGET="${UNSLOTH_MACOS_DEPLOYMENT_TARGET:-13.3}" + CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_OSX_DEPLOYMENT_TARGET=${_MACOS_DEPLOYMENT_TARGET}" + export MACOSX_DEPLOYMENT_TARGET="${_MACOS_DEPLOYMENT_TARGET}" + fi + if command -v ccache &>/dev/null; then CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache" fi diff --git a/tests/studio/install/test_macos_version_compat.py b/tests/studio/install/test_macos_version_compat.py new file mode 100644 index 0000000000..0adc91a622 --- /dev/null +++ b/tests/studio/install/test_macos_version_compat.py @@ -0,0 +1,322 @@ +"""Tests for the host-macOS-version-aware llama.cpp prebuilt selection added +for the Mac "Failing CI" fix. + +Covers: parse_macos_version, host_supports_macos_minos, the pure-Python Mach-O +minimum-OS parser (macho_minimum_macos), the dyld-incompatibility classifier, +the install preflight that rejects a too-new prebuilt, and the deeper macOS +release walk-back in resolve_simple_install_release_plans. + +No GPU, no network, no torch, no real Mach-O toolchain required -- the Mach-O +samples are synthesized in-process and all I/O is monkeypatched. +""" + +import importlib.util +import struct +import sys +from pathlib import Path + +import pytest + + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" +SPEC = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt_macos", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +ILP = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ILP +SPEC.loader.exec_module(ILP) + +HostInfo = ILP.HostInfo +PrebuiltFallback = ILP.PrebuiltFallback + +_CPU_TYPE_ARM64 = 0x0100000C +_CPU_TYPE_X86_64 = 0x01000007 + + +def make_macos_host(macos_version, *, arm64 = True): + return HostInfo( + system = "Darwin", + machine = "arm64" if arm64 else "x86_64", + is_windows = False, + is_linux = False, + is_macos = True, + is_x86_64 = not arm64, + is_arm64 = arm64, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + macos_version = macos_version, + ) + + +def thin_macho(minos = (14, 0), *, cputype = _CPU_TYPE_ARM64, build_version = True): + """Synthesize a minimal little-endian 64-bit Mach-O carrying a macOS + minimum-version load command.""" + encoded = (minos[0] << 16) | (minos[1] << 8) + if build_version: + # LC_BUILD_VERSION: cmd, cmdsize, platform(=1 macOS), minos, sdk, ntools + load_command = struct.pack("<6I", 0x32, 24, 1, encoded, encoded, 0) + else: + # LC_VERSION_MIN_MACOSX: cmd, cmdsize, version, sdk + load_command = struct.pack("<4I", 0x24, 16, encoded, encoded) + header = struct.pack("<8I", 0xFEEDFACF, cputype, 0, 0x2, 1, len(load_command), 0, 0) + return header + load_command + + +def fat_macho(slices): + """Synthesize a big-endian universal binary from (cputype, thin_bytes).""" + header = struct.pack(">2I", 0xCAFEBABE, len(slices)) + data_offset = 8 + 20 * len(slices) + arch_entries = b"" + body = b"" + for cputype, thin in slices: + offset = data_offset + len(body) + arch_entries += struct.pack(">5I", cputype, 0, offset, len(thin), 0) + body += thin + return header + arch_entries + body + + +class TestParseMacosVersion: + @pytest.mark.parametrize( + "value, expected", + [ + ("14.7.1", (14, 7)), + ("15.5", (15, 5)), + ("26.0", (26, 0)), + ("26", (26, 0)), + ("13", (13, 0)), + ("", None), + (None, None), + ("not-a-version", None), + ], + ) + def test_parse(self, value, expected): + assert ILP.parse_macos_version(value) == expected + + +class TestHostSupportsMacosMinos: + def test_older_host_rejects_newer_prebuilt(self): + assert not ILP.host_supports_macos_minos(make_macos_host((14, 0)), (26, 0)) + + def test_same_version_supported(self): + assert ILP.host_supports_macos_minos(make_macos_host((26, 0)), (26, 0)) + + def test_newer_host_supports_older_prebuilt(self): + assert ILP.host_supports_macos_minos(make_macos_host((15, 5)), (14, 0)) + + def test_unknown_host_defers_to_runtime(self): + assert ILP.host_supports_macos_minos(make_macos_host(None), (26, 0)) + + def test_unknown_minos_defers_to_runtime(self): + assert ILP.host_supports_macos_minos(make_macos_host((14, 0)), None) + + +class TestMachoMinimumMacos: + def test_build_version_thin(self, tmp_path): + path = tmp_path / "lib.dylib" + path.write_bytes(thin_macho((26, 0))) + assert ILP.macho_minimum_macos(path) == (26, 0) + + def test_legacy_version_min_thin(self, tmp_path): + path = tmp_path / "lib.dylib" + path.write_bytes(thin_macho((14, 0), build_version = False)) + assert ILP.macho_minimum_macos(path) == (14, 0) + + def test_universal_prefers_host_arch_slice(self, tmp_path): + # arm64 slice needs macOS 14, x86_64 slice needs macOS 26. + path = tmp_path / "fat" + path.write_bytes( + fat_macho( + [ + (_CPU_TYPE_ARM64, thin_macho((14, 0), cputype = _CPU_TYPE_ARM64)), + (_CPU_TYPE_X86_64, thin_macho((26, 0), cputype = _CPU_TYPE_X86_64)), + ] + ) + ) + assert ILP.macho_minimum_macos(path, make_macos_host((14, 0))) == (14, 0) + assert ILP.macho_minimum_macos(path, make_macos_host((26, 0), arm64 = False)) == ( + 26, + 0, + ) + + def test_non_macho_returns_none(self, tmp_path): + path = tmp_path / "script.sh" + path.write_bytes(b'#!/bin/sh\nexec real "$@"\n') + assert ILP.macho_minimum_macos(path) is None + + def test_missing_file_returns_none(self, tmp_path): + assert ILP.macho_minimum_macos(tmp_path / "nope") is None + + +class TestLooksLikeMacosIncompatibility: + def test_built_for_newer_os(self): + assert ILP.looks_like_macos_incompatibility( + "dyld: ... (built for macOS 26.0 which is newer than running OS)" + ) + + def test_metal_residency_symbol(self): + assert ILP.looks_like_macos_incompatibility( + "Symbol not found: _OBJC_CLASS_$_MTLResidencySetDescriptor" + ) + + def test_benign_error(self): + assert not ILP.looks_like_macos_incompatibility("some unrelated failure") + + def test_empty(self): + assert not ILP.looks_like_macos_incompatibility("") + + +class TestPreflightMacosInstalledBinaries: + def _install_dir(self, tmp_path, dylib_minos): + bin_dir = tmp_path / "build" / "bin" + bin_dir.mkdir(parents = True) + (bin_dir / "libggml-metal.dylib").write_bytes(thin_macho(dylib_minos)) + server = tmp_path / "llama-server" + server.write_bytes(thin_macho(dylib_minos)) + quantize = tmp_path / "llama-quantize" + quantize.write_bytes(thin_macho(dylib_minos)) + return tmp_path, (server, quantize) + + def test_rejects_too_new_dylib(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + with pytest.raises(PrebuiltFallback, match = "newer macOS"): + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host((14, 0)) + ) + + def test_accepts_compatible_prebuilt(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (14, 0)) + # Must not raise on a macOS 15 host. + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host((15, 5)) + ) + + def test_skips_when_host_version_unknown(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + # Unknown host version -> defer to runtime validation, do not raise. + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host(None) + ) + + def test_noop_on_non_macos_host(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + linux_host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + ILP.preflight_macos_installed_binaries(binaries, install_dir, linux_host) + + +def _fake_macos_releases(tags): + return [ + { + "tag_name": tag, + "assets": [ + { + "name": f"llama-{tag}-bin-macos-arm64.tar.gz", + "browser_download_url": f"https://example.com/{tag}.tar.gz", + } + ], + } + for tag in tags + ] + + +class TestMacosReleaseWalkback: + """A known-version macOS host must generate enough older-release plans to + walk back past a run of too-new prebuilts; unknown-version and non-macOS + hosts keep the conservative 2-release default.""" + + TAGS = [f"b{n}" for n in range(9437, 9400, -1)] # 37 newest-first releases + + def _patch_releases(self, monkeypatch): + monkeypatch.setattr( + ILP, + "iter_release_payloads_by_time", + lambda repo, published_release_tag, requested_tag: _fake_macos_releases( + self.TAGS + ), + ) + + def test_known_macos_host_walks_back_deeper(self, monkeypatch): + self._patch_releases(monkeypatch) + _tag, plans = ILP.resolve_simple_install_release_plans( + "latest", + make_macos_host((14, 0)), + "ggml-org/llama.cpp", + "", + ) + assert len(plans) == ILP.DEFAULT_MAX_MACOS_RELEASE_FALLBACKS + assert len(plans) > ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS + + def test_unknown_macos_host_uses_default(self, monkeypatch): + self._patch_releases(monkeypatch) + _tag, plans = ILP.resolve_simple_install_release_plans( + "latest", + make_macos_host(None), + "ggml-org/llama.cpp", + "", + ) + assert len(plans) == ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS + + +class TestForwardsBackwardsCompat: + """The gate is host >= prebuilt minos with no hardcoded version, so it holds + for older and future macOS alike. Emulate the walk-back over a release set + spanning several minos tiers and assert each host takes the newest release + it can load.""" + + # Newest first: future 27 builds, current 26 builds, an old 14 tier, a 13. + RELEASES = [ + ("b9600", (27, 0)), + ("b9450", (26, 0)), + ("b9415", (14, 0)), + ("b8300", (13, 0)), + ] + + def _select(self, tmp_path, host_version): + for tag, minos in self.RELEASES: + bin_dir = tmp_path / tag / "build" / "bin" + bin_dir.mkdir(parents = True) + (bin_dir / "libggml-metal.dylib").write_bytes(thin_macho(minos)) + try: + ILP.preflight_macos_installed_binaries( + (), tmp_path / tag, make_macos_host(host_version) + ) + return tag + except PrebuiltFallback: + continue + return None + + @pytest.mark.parametrize( + "host_version, expected", + [ + ((13, 0), "b8300"), # older host takes the older prebuilt + ((14, 7), "b9415"), # backwards: skip 26/27, take newest that loads + ((15, 5), "b9415"), + ((26, 0), "b9450"), # unchanged: newest <= host + ((27, 1), "b9600"), # forwards: future host takes the future build + ], + ) + def test_selects_newest_loadable(self, tmp_path, host_version, expected): + assert self._select(tmp_path, host_version) == expected + + def test_host_below_prebuilt_floor_falls_through(self, tmp_path): + # macOS 12 is below every prebuilt -> nothing matches -> source build. + assert self._select(tmp_path, (12, 0)) is None From 5076837cf04b13fc81fd08d134a1aea9be28b39a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 01:15:02 -0700 Subject: [PATCH 5/6] studio: make NVIDIA prebuilt selection track CUDA version bumps (Windows + Linux) (#5879) Make the NVIDIA prebuilt selection version-dynamic and driver-gated on both the Windows (ggml-org) and Linux (unslothai/llama.cpp) paths, so CUDA version bumps are handled with no code change while staying safe across driver versions. - Derive candidate CUDA runtime lines from the driver major; pick the highest upstream-published minor the driver can actually run, so a sub-13.3 driver is never handed an unguaranteed 13.3 build. - Pin b9360's cuda-13.1 build (immutable, hash-verified) as a GPU fallback for a Blackwell host on a 13.1/13.2 driver that the in-release 13.3 build gates off, on both the simple and published install paths. Dormant for every other host and self-disabling once upstream ships a driver-runnable build again. - Seed the published-path ordering from the release's real published minors so a future CUDA major is selectable with no code change. Refs #5861, #5817, #5807, #5858, #5854, #5826, #5887. --- studio/install_llama_prebuilt.py | 304 +++++++++-- tests/studio/install/test_selection_logic.py | 526 ++++++++++++++++++- 2 files changed, 795 insertions(+), 35 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 38fa31fff8..861a501570 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -222,6 +222,71 @@ DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { }, } +# Lowest CUDA major we ship prebuilts for, and the highest major we probe for +# installed runtime libraries. Detection and runtime-line derivation are +# generated per major so a new toolkit (cuda14, ...) needs no code change while +# llama.cpp keeps the cudart64_.dll / libcudart.so. naming. +_MIN_CUDA_MAJOR = 12 +_MAX_PROBE_CUDA_MAJOR = 19 + +# Last ggml-org release whose Windows win-cuda-13 build is still sub-13.3 +# (cuda-13.1, b9360, 2026-05-27). Upstream bumped win-cuda-13 to 13.3 at b9365 +# and now ships only cuda-12.4 + cuda-13.3. cuda-12.4 predates Blackwell (ggml +# compiles sm_120 only at toolkit >= 12.8), so a Blackwell host on a 13.1/13.2 +# driver is gated off 13.3 and would drop to a CPU-only 12.4 build. b9360 is +# immutable, so we pin its cuda-13.1 build (plus paired cudart) as a GPU +# fallback for exactly those hosts. See unslothai/unsloth#5887. +_PINNED_BLACKWELL_FALLBACK_TAG = "b9360" +_PINNED_BLACKWELL_FALLBACK_RUNTIME = "13.1" +_PINNED_BLACKWELL_DRIVER_FLOOR = (13, 1) +_BLACKWELL_MIN_SM = 120 +# ggml compiles Blackwell sm_120 only at toolkit >= 12.8, so an in-release +# windows-cuda build at or above this already covers Blackwell and makes the +# older pinned 13.1 fallback unnecessary (cuda-12.4 is below it). +_BLACKWELL_MIN_TOOLKIT = (12, 8) +_PINNED_BLACKWELL_LLAMA_SHA256 = ( + "31ddb8b42d7ab4a47cab8c48c397519f580ca502df7e73f3ab396eacc16c8e8d" +) +_PINNED_BLACKWELL_CUDART_SHA256 = ( + "f96935e7e385e3b2d0189239077c10fe8fd7e95690fea4afec455b1b6c7e3f18" +) + + +def _cuda_runtime_lines_for_major(major: int) -> list[str]: + """Runtime lines a driver of this CUDA major can use, newest major first + down to the minimum we ship. A driver runs its own major and any older one + (backward compatibility).""" + return [f"cuda{m}" for m in range(major, _MIN_CUDA_MAJOR - 1, -1)] + + +def _resolve_linux_bundle_profile(bundle_profile: str) -> "dict[str, Any] | None": + """Profile (runtime line + sm coverage) for a linux-x64-cuda- + bundle. Known majors use their published coverage; an unknown future major + reuses the newest known major's coverage for the same class as a forward + default, with the post-build GPU smoke test as the backstop.""" + known = DIRECT_LINUX_BUNDLE_PROFILES.get(bundle_profile) + if known is not None: + return known + m = re.fullmatch( + r"cuda(?P\d+)-(?Polder|newer|portable)", bundle_profile + ) + if not m: + return None + base_key = max( + ( + k + for k, v in DIRECT_LINUX_BUNDLE_PROFILES.items() + if v["coverage_class"] == m.group("klass") + ), + key = lambda k: int(re.match(r"cuda(\d+)-", k).group(1)), + default = None, + ) + if base_key is None: + return None + profile = dict(DIRECT_LINUX_BUNDLE_PROFILES[base_key]) + profile["runtime_line"] = f"cuda{m.group('major')}" + return profile + @dataclass class HostInfo: @@ -769,6 +834,26 @@ def windows_cuda_asset_aliases( return aliases +def _published_windows_cuda_runtime( + upstream_assets: dict[str, str], major: int, driver: tuple[int, int] | None +) -> str | None: + """Highest cuda-. published upstream that `driver` can run by + default CUDA compatibility, i.e. (major, minor) <= driver. None if nothing + qualifies. Gating on the driver (not just the major) keeps a 13.3 build off + a driver that only advertises 13.1, where it would otherwise rely on the + unguaranteed minor-version-compatibility path.""" + if driver is None: + return None + best: int | None = None + for name in upstream_assets: + m = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", name) + if m and int(m.group(1)) == major: + minor = int(m.group(2)) + if (major, minor) <= driver and (best is None or minor > best): + best = minor + return f"{major}.{best}" if best is not None else None + + def format_byte_count(num_bytes: float) -> str: units = ["B", "KiB", "MiB", "GiB", "TiB"] value = float(num_bytes) @@ -1225,7 +1310,7 @@ def parse_direct_linux_release_bundle( inferred_labels: list[str] = [] linux_asset_re = re.compile( - r"^app-(?P