From 22eb2a8a60c4a403aebb4d319f4558afbc789f9a Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Wed, 17 Jun 2026 15:46:24 -0500 Subject: [PATCH 01/10] Add --with-llama-cpp-dir flag to install.ps1 and install.sh Users can now pass --with-llama-cpp-dir /path/to/llama.cpp to the installer to skip downloading or building llama.cpp and use a local directory instead. A junction (Windows) or symlink (Linux/macOS) is created at the canonical install location, bypassing both the prebuilt download (Phase 3) and source build (Phase 4) steps in setup.ps1/setup.sh. The path is passed via UNSLOTH_LOCAL_LLAMA_CPP_DIR env var which setup.ps1 and setup.sh read directly. Ported from the idea in unslothai/unsloth#4384, reimplemented against current Studio architecture. --- install.ps1 | 17 +++++++++++++++++ install.sh | 17 +++++++++++++++++ studio/setup.ps1 | 35 +++++++++++++++++++++++++++++++++-- studio/setup.sh | 26 +++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/install.ps1 b/install.ps1 index c241a04166..4bbfbdbc5f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -99,6 +99,7 @@ function Install-UnslothStudio { $TauriMode = $false $SkipTorch = $false $ShortcutsOnly = $false + $WithLlamaCppDir = "" $argList = $args for ($i = 0; $i -lt $argList.Count; $i++) { switch ($argList[$i]) { @@ -116,6 +117,14 @@ function Install-UnslothStudio { } $PackageName = $argList[$i] } + "--with-llama-cpp-dir" { + $i++ + if ($i -ge $argList.Count) { + Write-Host "[ERROR] --with-llama-cpp-dir requires a path argument." -ForegroundColor Red + return (Exit-InstallFailure "--with-llama-cpp-dir requires a path argument.") + } + $WithLlamaCppDir = $argList[$i] + } } } @@ -2339,6 +2348,13 @@ exit 0 } $studioArgs = @('studio', 'setup') if ($script:UnslothVerbose) { $studioArgs += '--verbose' } + if ($WithLlamaCppDir) { + if (-not (Test-Path -LiteralPath $WithLlamaCppDir -PathType Container)) { + Write-Host "[ERROR] --with-llama-cpp-dir path does not exist: $WithLlamaCppDir" -ForegroundColor Red + return (Exit-InstallFailure "--with-llama-cpp-dir path does not exist.") + } + $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR = (Resolve-Path -LiteralPath $WithLlamaCppDir).Path + } $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1" # Hand the venv interpreter to setup.ps1 so it reuses the Python we already # resolved and built the venv with, instead of re-probing the system (which @@ -2354,6 +2370,7 @@ exit 0 } else { Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue } + Remove-Item Env:UNSLOTH_LOCAL_LLAMA_CPP_DIR -ErrorAction SilentlyContinue Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue Remove-Item Env:UNSLOTH_SETUP_PYTHON -ErrorAction SilentlyContinue } diff --git a/install.sh b/install.sh index 7e8df1bc64..ef368231f0 100755 --- a/install.sh +++ b/install.sh @@ -53,6 +53,8 @@ _VERBOSE=false _SHORTCUTS_ONLY=false _next_is_package=false _next_is_python=false +_next_is_llama_cpp_dir=false +_WITH_LLAMA_CPP_DIR="" for arg in "$@"; do if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg" @@ -64,6 +66,11 @@ for arg in "$@"; do _next_is_python=false continue fi + if [ "$_next_is_llama_cpp_dir" = true ]; then + _WITH_LLAMA_CPP_DIR="$arg" + _next_is_llama_cpp_dir=false + continue + fi case "$arg" in --local) STUDIO_LOCAL_INSTALL=true ;; --package) _next_is_package=true ;; @@ -72,6 +79,7 @@ for arg in "$@"; do --no-torch) _NO_TORCH_FLAG=true ;; --verbose|-v) _VERBOSE=true ;; --shortcuts-only) _SHORTCUTS_ONLY=true ;; + --with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;; esac done @@ -2894,6 +2902,13 @@ _run_setup_with_studio_home() { "$@" fi } +if [ -n "$_WITH_LLAMA_CPP_DIR" ]; then + if [ ! -d "$_WITH_LLAMA_CPP_DIR" ]; then + echo "[ERROR] --with-llama-cpp-dir path does not exist: $_WITH_LLAMA_CPP_DIR" >&2 + exit 1 + fi + _WITH_LLAMA_CPP_DIR="$(cd "$_WITH_LLAMA_CPP_DIR" && pwd)" +fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then _run_setup_with_studio_home env \ SKIP_STUDIO_BASE="$_SKIP_BASE" \ @@ -2902,6 +2917,7 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ + UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \ bash "$SETUP_SH" &1 | Out-Null + if ($LASTEXITCODE -ne 0) { + substep "Could not create directory junction; copying instead..." "Yellow" + Copy-Item -Recurse -Path $ResolvedLocal -Destination $LlamaCppDir + } + Write-Host "" + step "llama.cpp" "linked local directory: $ResolvedLocal" + $LocalLlamaCppLinked = $true + $NeedLlamaSourceBuild = $false + } +} + +if ($LocalLlamaCppLinked) { + # local directory linked above; skip prebuilt install +} elseif ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { Write-Host "" substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow" $NeedLlamaSourceBuild = $true @@ -2962,7 +2993,7 @@ if (Test-Path -LiteralPath $LlamaServerBin) { } } -if (-not $NeedLlamaSourceBuild) { +if (-not $NeedLlamaSourceBuild -and -not $LocalLlamaCppLinked) { Write-Host "" step "llama.cpp" "prebuilt (validated)" } elseif ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") { diff --git a/studio/setup.sh b/studio/setup.sh index b78029d6e2..2836ed580d 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1086,7 +1086,31 @@ fi verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)" -if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then +_LOCAL_LLAMA_CPP_LINKED=false +if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then + if [ ! -d "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" ]; then + step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR" "$C_ERR" + exit 1 + fi + _RESOLVED_LOCAL="$(cd "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd)" + if [ "$_RESOLVED_LOCAL" = "$LLAMA_CPP_DIR" ]; then + substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" + else + if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then + _assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" + fi + rm -rf "$LLAMA_CPP_DIR" + ln -sfn "$_RESOLVED_LOCAL" "$LLAMA_CPP_DIR" + step "llama.cpp" "linked local directory: $_RESOLVED_LOCAL" + _LOCAL_LLAMA_CPP_LINKED=true + _NEED_LLAMA_SOURCE_BUILD=false + _SKIP_PREBUILT_INSTALL=true + fi +fi + +if [ "$_LOCAL_LLAMA_CPP_LINKED" = true ]; then + : # local directory linked above; skip prebuilt install +elif [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then step "llama.cpp" "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt" "$C_WARN" _NEED_LLAMA_SOURCE_BUILD=true elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then From 35542c6237f993cc80221097f2cbf387c849550b Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Fri, 19 Jun 2026 08:35:45 -0500 Subject: [PATCH 02/10] test: add static wiring test for --with-llama-cpp-dir flag Cross-checks install.sh, install.ps1, studio/setup.sh and studio/setup.ps1 so the flag's contract (parse -> UNSLOTH_LOCAL_LLAMA_CPP_DIR env var -> link local dir, skip prebuilt download and source build) can't silently regress. Wired into studio-backend-ci.yml alongside the other tests/sh installer tests. --- .github/workflows/studio-backend-ci.yml | 3 +- tests/sh/test_with_llama_cpp_dir_flag.sh | 155 +++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/sh/test_with_llama_cpp_dir_flag.sh diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index b394b308e4..cfe4280d62 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -225,7 +225,8 @@ jobs: tests/sh/test_nvcc_meets_llama_minimum.sh \ tests/sh/test_tauri_install_exit_order.sh \ tests/sh/test_torch_constraint.sh \ - tests/sh/test_torch_flavor.sh; do + tests/sh/test_torch_flavor.sh \ + tests/sh/test_with_llama_cpp_dir_flag.sh; do echo "::group::$s" bash "$s" echo "::endgroup::" diff --git a/tests/sh/test_with_llama_cpp_dir_flag.sh b/tests/sh/test_with_llama_cpp_dir_flag.sh new file mode 100644 index 0000000000..28fd2c6038 --- /dev/null +++ b/tests/sh/test_with_llama_cpp_dir_flag.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Static analysis: the --with-llama-cpp-dir flag must be wired consistently +# across both installers (install.sh / install.ps1) and both setup scripts +# (studio/setup.sh / studio/setup.ps1). +# +# The flag lets a user point the installer at a local llama.cpp directory so it +# skips BOTH the prebuilt download (Phase 3) and the source build (Phase 4), +# linking the local dir into the canonical install location instead. The path +# crosses the installer->setup boundary via the UNSLOTH_LOCAL_LLAMA_CPP_DIR env +# var. These checks pin that contract so a future refactor of either side can't +# silently break it (e.g. installer parses the flag but setup never reads the +# env var, or setup links the dir but still runs the build). +# +# This is a shape/wiring test, not a behavioral one: it greps the committed +# scripts. It needs no Python, no GPU, no network. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +INSTALL_PS1="$SCRIPT_DIR/../../install.ps1" +SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh" +SETUP_PS1="$SCRIPT_DIR/../../studio/setup.ps1" +ENV_VAR="UNSLOTH_LOCAL_LLAMA_CPP_DIR" +PASS=0 +FAIL=0 + +assert_contains() { + _label="$1"; _file="$2"; _needle="$3" + if grep -qF -- "$_needle" "$_file"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle' in $(basename "$_file"))" + FAIL=$((FAIL + 1)) + fi +} + +# Count of distinct lines matching a regex, used to assert a guard appears +# in more than one place (e.g. env var forwarded on both setup invocations). +assert_min_count() { + _label="$1"; _file="$2"; _pattern="$3"; _min="$4" + _n=$(grep -cE -- "$_pattern" "$_file" || true) + if [ "$_n" -ge "$_min" ]; then + echo " PASS: $_label (found $_n, need >= $_min)" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (found $_n in $(basename "$_file"), need >= $_min)" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "=== install.sh: parses --with-llama-cpp-dir and forwards the env var ===" + +assert_contains \ + "install.sh: accepts --with-llama-cpp-dir flag" \ + "$INSTALL_SH" "--with-llama-cpp-dir" +assert_contains \ + "install.sh: validates the path exists before forwarding" \ + "$INSTALL_SH" 'if [ ! -d "$_WITH_LLAMA_CPP_DIR" ]; then' +# The path must be forwarded to setup.sh on BOTH the local and the +# non-local setup invocations, else --local users (the documented path) +# would silently lose the flag. +assert_min_count \ + "install.sh: forwards $ENV_VAR on both setup invocations" \ + "$INSTALL_SH" "$ENV_VAR=\"\\\$_WITH_LLAMA_CPP_DIR\"" 2 + +echo "" +echo "=== install.ps1: parses --with-llama-cpp-dir and forwards the env var ===" + +assert_contains \ + "install.ps1: accepts --with-llama-cpp-dir flag" \ + "$INSTALL_PS1" '"--with-llama-cpp-dir"' +assert_contains \ + "install.ps1: errors when flag is given with no path argument" \ + "$INSTALL_PS1" "--with-llama-cpp-dir requires a path argument" +assert_contains \ + "install.ps1: validates the path exists before forwarding" \ + "$INSTALL_PS1" "--with-llama-cpp-dir path does not exist" +assert_contains \ + "install.ps1: exports $ENV_VAR for setup.ps1" \ + "$INSTALL_PS1" "\$env:$ENV_VAR =" +# The exported env var must be cleaned up so a later setup invocation in the +# same shell session doesn't inherit a stale local-dir link. +assert_contains \ + "install.ps1: clears $ENV_VAR after the setup run" \ + "$INSTALL_PS1" "Remove-Item Env:$ENV_VAR" + +echo "" +echo "=== studio/setup.sh: reads the env var, links, and skips download+build ===" + +assert_contains \ + "setup.sh: reads $ENV_VAR" \ + "$SETUP_SH" "$ENV_VAR" +assert_contains \ + "setup.sh: symlinks the local dir into the canonical install location" \ + "$SETUP_SH" 'ln -sfn "$_RESOLVED_LOCAL" "$LLAMA_CPP_DIR"' +assert_contains \ + "setup.sh: disables the source build when the local dir is linked" \ + "$SETUP_SH" "_NEED_LLAMA_SOURCE_BUILD=false" +assert_contains \ + "setup.sh: skips the prebuilt download when the local dir is linked" \ + "$SETUP_SH" "_SKIP_PREBUILT_INSTALL=true" +# The link branch must short-circuit the FORCE_COMPILE / prebuilt chain rather +# than fall through into it. +assert_contains \ + "setup.sh: link branch gates the prebuilt/compile chain" \ + "$SETUP_SH" 'if [ "$_LOCAL_LLAMA_CPP_LINKED" = true ]; then' + +echo "" +echo "=== studio/setup.ps1: reads the env var, junctions, and skips download+build ===" + +assert_contains \ + "setup.ps1: reads $ENV_VAR" \ + "$SETUP_PS1" "\$env:$ENV_VAR" +assert_contains \ + "setup.ps1: creates a directory junction into the canonical location" \ + "$SETUP_PS1" "mklink /J" +assert_contains \ + "setup.ps1: falls back to a copy when the junction can't be created" \ + "$SETUP_PS1" "Copy-Item -Recurse -Path \$ResolvedLocal -Destination \$LlamaCppDir" +assert_contains \ + "setup.ps1: disables the source build when the local dir is linked" \ + "$SETUP_PS1" '$NeedLlamaSourceBuild = $false' +# The link branch must gate the prebuilt-install chain (the elseif on +# FORCE_COMPILE), and the 'prebuilt (validated)' banner must be suppressed +# when we linked a local dir instead of validating a real prebuilt. +assert_contains \ + "setup.ps1: link branch gates the prebuilt/compile chain" \ + "$SETUP_PS1" 'if ($LocalLlamaCppLinked) {' +assert_contains \ + "setup.ps1: 'prebuilt (validated)' banner excludes the linked-dir case" \ + "$SETUP_PS1" '-not $NeedLlamaSourceBuild -and -not $LocalLlamaCppLinked' + +echo "" +echo "=== both setup scripts: a local dir pointing at the canonical path is a no-op ===" + +# Guard against the self-link footgun: if the user passes the canonical install +# dir itself, neither script should delete-then-link it onto itself. +assert_contains \ + "setup.sh: ignores a local dir equal to the canonical install location" \ + "$SETUP_SH" 'if [ "$_RESOLVED_LOCAL" = "$LLAMA_CPP_DIR" ]; then' +assert_contains \ + "setup.ps1: ignores a local dir equal to the canonical install location" \ + "$SETUP_PS1" 'if ($ResolvedLocal -eq $LlamaCppDir) {' + +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" From fe4b43d7daa05df0890a7676fe065b5c34f2d5c7 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 23 Jun 2026 15:38:24 -0500 Subject: [PATCH 03/10] Address review feedback on --with-llama-cpp-dir flag - setup.ps1: delete an existing junction/symlink via DirectoryInfo.Delete() instead of a recursive remove, which can traverse the link and wipe the user's real llama.cpp directory on PowerShell 5.1. - setup.ps1: short-circuit the build chain when a local dir is linked so CMake never runs inside the user's checkout when it lacks a Windows-layout binary. - install.sh / setup.sh: resolve paths with CDPATH= cd -P so a set CDPATH cannot corrupt the resolved path. - install.sh: seed _WITH_LLAMA_CPP_DIR from UNSLOTH_LOCAL_LLAMA_CPP_DIR so an exported env var (piped-install style) is honored instead of being clobbered. - setup.sh: create the root llama-quantize shim when linking a local source build so GGUF export's check_llama_cpp() still finds it. - setup.sh / setup.ps1: drop a stale link before the custom-home ownership assert so re-runs with the flag stay idempotent. - test: pin the new linked-dir build short-circuit. --- install.sh | 7 +++++-- studio/setup.ps1 | 19 ++++++++++++++++++- studio/setup.sh | 16 +++++++++++++++- tests/sh/test_with_llama_cpp_dir_flag.sh | 8 ++++---- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/install.sh b/install.sh index ef368231f0..26d77d6d37 100755 --- a/install.sh +++ b/install.sh @@ -54,7 +54,10 @@ _SHORTCUTS_ONLY=false _next_is_package=false _next_is_python=false _next_is_llama_cpp_dir=false -_WITH_LLAMA_CPP_DIR="" +# Seed from the environment so a caller who exports UNSLOTH_LOCAL_LLAMA_CPP_DIR +# (the documented piped-install style) is honored; the --with-llama-cpp-dir +# flag below overrides it when given. +_WITH_LLAMA_CPP_DIR="${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" for arg in "$@"; do if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg" @@ -2907,7 +2910,7 @@ if [ -n "$_WITH_LLAMA_CPP_DIR" ]; then echo "[ERROR] --with-llama-cpp-dir path does not exist: $_WITH_LLAMA_CPP_DIR" >&2 exit 1 fi - _WITH_LLAMA_CPP_DIR="$(cd "$_WITH_LLAMA_CPP_DIR" && pwd)" + _WITH_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_WITH_LLAMA_CPP_DIR" && pwd -P)" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then _run_setup_with_studio_home env \ diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 834646f795..2b3cdd9bf3 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -2765,6 +2765,17 @@ if ($LocalLlamaCppSrc) { if ($ResolvedLocal -eq $LlamaCppDir) { substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" "Yellow" } else { + # If the target is already a junction/symlink (e.g. a previous + # --with-llama-cpp-dir run), delete only the link via DirectoryInfo.Delete(). + # Remove-Item -Recurse -Force on a reparse point can traverse the link and + # wipe the user's real llama.cpp directory on PowerShell 5.1. Dropping the + # stale link here also keeps the custom-home ownership check below idempotent. + if (Test-Path -LiteralPath $LlamaCppDir) { + $existing = Get-Item -LiteralPath $LlamaCppDir -Force + if ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + $existing.Delete() + } + } if ($StudioHomeIsCustom) { Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" } @@ -2993,7 +3004,13 @@ if (Test-Path -LiteralPath $LlamaServerBin) { } } -if (-not $NeedLlamaSourceBuild -and -not $LocalLlamaCppLinked) { +if ($LocalLlamaCppLinked) { + # Local dir linked above -- honor the flag's contract: skip BOTH the prebuilt + # download and the source build. Falling through here would run CMake inside + # the user's checkout (via the junction) when it lacks build\bin\Release\llama-server.exe. + Write-Host "" + step "llama.cpp" "linked (skipping build)" +} elseif (-not $NeedLlamaSourceBuild) { Write-Host "" step "llama.cpp" "prebuilt (validated)" } elseif ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") { diff --git a/studio/setup.sh b/studio/setup.sh index 2836ed580d..8c502f2536 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1092,15 +1092,29 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR" "$C_ERR" exit 1 fi - _RESOLVED_LOCAL="$(cd "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd)" + _RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd -P)" if [ "$_RESOLVED_LOCAL" = "$LLAMA_CPP_DIR" ]; then substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" else + # A stale link from a previous --with-llama-cpp-dir run isn't Studio-owned + # content; drop it before the ownership check so re-runs stay idempotent + # for a custom UNSLOTH_STUDIO_HOME (the assert would otherwise follow the + # link into the user's dir and reject it as unowned). + [ -L "$LLAMA_CPP_DIR" ] && rm -f "$LLAMA_CPP_DIR" if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then _assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" fi rm -rf "$LLAMA_CPP_DIR" ln -sfn "$_RESOLVED_LOCAL" "$LLAMA_CPP_DIR" + # GGUF export's check_llama_cpp() looks for a llama-quantize shim at the + # root of the install dir. A normal source build keeps the binary under + # build/bin/, so mirror the source-build-reuse step and create the shim + # when the linked checkout has one but no root shim yet. Inference + # (llama-server) works off the linked tree directly and needs nothing. + if [ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ] && \ + [ ! -e "$LLAMA_CPP_DIR/llama-quantize" ]; then + ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize" + fi step "llama.cpp" "linked local directory: $_RESOLVED_LOCAL" _LOCAL_LLAMA_CPP_LINKED=true _NEED_LLAMA_SOURCE_BUILD=false diff --git a/tests/sh/test_with_llama_cpp_dir_flag.sh b/tests/sh/test_with_llama_cpp_dir_flag.sh index 28fd2c6038..ebadffdb5c 100644 --- a/tests/sh/test_with_llama_cpp_dir_flag.sh +++ b/tests/sh/test_with_llama_cpp_dir_flag.sh @@ -123,14 +123,14 @@ assert_contains \ "setup.ps1: disables the source build when the local dir is linked" \ "$SETUP_PS1" '$NeedLlamaSourceBuild = $false' # The link branch must gate the prebuilt-install chain (the elseif on -# FORCE_COMPILE), and the 'prebuilt (validated)' banner must be suppressed -# when we linked a local dir instead of validating a real prebuilt. +# FORCE_COMPILE), and the linked-dir case must short-circuit the build chain +# so neither a prebuilt download nor a source build runs against it. assert_contains \ "setup.ps1: link branch gates the prebuilt/compile chain" \ "$SETUP_PS1" 'if ($LocalLlamaCppLinked) {' assert_contains \ - "setup.ps1: 'prebuilt (validated)' banner excludes the linked-dir case" \ - "$SETUP_PS1" '-not $NeedLlamaSourceBuild -and -not $LocalLlamaCppLinked' + "setup.ps1: linked-dir case short-circuits the build chain" \ + "$SETUP_PS1" 'step "llama.cpp" "linked (skipping build)"' echo "" echo "=== both setup scripts: a local dir pointing at the canonical path is a no-op ===" From 56b1e3c4a49297579befa5b9170f0af1196c8756 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Mon, 29 Jun 2026 13:56:36 -0500 Subject: [PATCH 04/10] Harden --with-llama-cpp-dir against Codex/Gemini review findings - install.sh: error when --with-llama-cpp-dir is the final arg with no path, matching the existing --package/--python post-loop guards (was a silent fallback to the normal prebuilt/source install). - studio/setup.sh: canonicalize LLAMA_CPP_DIR before the self-link no-op compare. _RESOLVED_LOCAL is fully resolved while LLAMA_CPP_DIR was textual, so a symlinked $HOME made the guard miss and the rm -rf could wipe the user's real llama.cpp tree. - studio/setup.sh: make the llama-quantize shim non-fatal; it writes through the link into the user's tree, which may be read-only (shared/CI cache), and under set -e a failed ln aborted an otherwise-good reuse. - studio/setup.ps1: detect a broken junction via Get-Item -Force instead of Test-Path so a dangling link from a prior run is removed and mklink can relink to a new valid directory. - studio/setup.ps1: use Copy-Item -LiteralPath so a source path containing [ ] isn't treated as a wildcard in the junction copy fallback. - tests: update the wiring assertions for the LiteralPath copy and the canonicalized compare. --- install.sh | 4 ++++ studio/setup.ps1 | 14 ++++++++------ studio/setup.sh | 19 +++++++++++++++++-- tests/sh/test_with_llama_cpp_dir_flag.sh | 4 ++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index d5374306a9..0370559540 100755 --- a/install.sh +++ b/install.sh @@ -266,6 +266,10 @@ if [ "$_next_is_python" = true ]; then echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2 exit 1 fi +if [ "$_next_is_llama_cpp_dir" = true ]; then + echo "❌ ERROR: --with-llama-cpp-dir requires a path argument." >&2 + exit 1 +fi # Validate --package to prevent injection into shell/Python commands. # Must start with a letter/digit (rejects leading dashes that uv would parse as flags). diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 77c5155747..dabc82437c 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3174,11 +3174,13 @@ if ($LocalLlamaCppSrc) { # Remove-Item -Recurse -Force on a reparse point can traverse the link and # wipe the user's real llama.cpp directory on PowerShell 5.1. Dropping the # stale link here also keeps the custom-home ownership check below idempotent. - if (Test-Path -LiteralPath $LlamaCppDir) { - $existing = Get-Item -LiteralPath $LlamaCppDir -Force - if ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { - $existing.Delete() - } + # Use Get-Item -Force (not Test-Path): a *broken* junction whose target was + # moved/deleted makes Test-Path return false, which would leave the dangling + # link in place and make mklink below fail; Get-Item still resolves it so we + # can remove it and relink to a new valid directory. + $existing = Get-Item -LiteralPath $LlamaCppDir -Force -ErrorAction SilentlyContinue + if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $existing.Delete() } if ($StudioHomeIsCustom) { Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" @@ -3189,7 +3191,7 @@ if ($LocalLlamaCppSrc) { cmd /c "mklink /J `"$LlamaCppDir`" `"$ResolvedLocal`"" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { substep "Could not create directory junction; copying instead..." "Yellow" - Copy-Item -Recurse -Path $ResolvedLocal -Destination $LlamaCppDir + Copy-Item -Recurse -LiteralPath $ResolvedLocal -Destination $LlamaCppDir } Write-Host "" step "llama.cpp" "linked local directory: $ResolvedLocal" diff --git a/studio/setup.sh b/studio/setup.sh index a95c43a864..9340aa9465 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1261,7 +1261,18 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then exit 1 fi _RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd -P)" - if [ "$_RESOLVED_LOCAL" = "$LLAMA_CPP_DIR" ]; then + # Canonicalize the install path the same way before comparing: _RESOLVED_LOCAL + # is fully resolved, but LLAMA_CPP_DIR is textual ($UNSLOTH_HOME/llama.cpp). If + # $HOME (or UNSLOTH_HOME) contains a symlink, the two never match even when the + # user pointed the flag at the canonical install itself -- and the rm -rf below + # would then wipe the very tree they asked to reuse. Resolve via the parent so + # this works whether or not the leaf currently exists. + _CANON_LLAMA_CPP_DIR="$LLAMA_CPP_DIR" + _LLAMA_CPP_PARENT="$(dirname "$LLAMA_CPP_DIR")" + if [ -d "$_LLAMA_CPP_PARENT" ]; then + _CANON_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" && pwd -P)/$(basename "$LLAMA_CPP_DIR")" + fi + if [ "$_RESOLVED_LOCAL" = "$_CANON_LLAMA_CPP_DIR" ]; then substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" else # A stale link from a previous --with-llama-cpp-dir run isn't Studio-owned @@ -1281,7 +1292,11 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then # (llama-server) works off the linked tree directly and needs nothing. if [ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ] && \ [ ! -e "$LLAMA_CPP_DIR/llama-quantize" ]; then - ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize" + # Best-effort: this writes through the link into the user's tree, which + # may be read-only (a shared/CI cache). Under `set -e` a failed ln would + # abort an otherwise-good reuse, so don't make the shim fatal. + ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize" 2>/dev/null || \ + substep "could not create llama-quantize shim in linked dir (read-only?); GGUF export may be unavailable" fi step "llama.cpp" "linked local directory: $_RESOLVED_LOCAL" _LOCAL_LLAMA_CPP_LINKED=true diff --git a/tests/sh/test_with_llama_cpp_dir_flag.sh b/tests/sh/test_with_llama_cpp_dir_flag.sh index ebadffdb5c..d008f82c79 100644 --- a/tests/sh/test_with_llama_cpp_dir_flag.sh +++ b/tests/sh/test_with_llama_cpp_dir_flag.sh @@ -118,7 +118,7 @@ assert_contains \ "$SETUP_PS1" "mklink /J" assert_contains \ "setup.ps1: falls back to a copy when the junction can't be created" \ - "$SETUP_PS1" "Copy-Item -Recurse -Path \$ResolvedLocal -Destination \$LlamaCppDir" + "$SETUP_PS1" "Copy-Item -Recurse -LiteralPath \$ResolvedLocal -Destination \$LlamaCppDir" assert_contains \ "setup.ps1: disables the source build when the local dir is linked" \ "$SETUP_PS1" '$NeedLlamaSourceBuild = $false' @@ -139,7 +139,7 @@ echo "=== both setup scripts: a local dir pointing at the canonical path is a no # dir itself, neither script should delete-then-link it onto itself. assert_contains \ "setup.sh: ignores a local dir equal to the canonical install location" \ - "$SETUP_SH" 'if [ "$_RESOLVED_LOCAL" = "$LLAMA_CPP_DIR" ]; then' + "$SETUP_SH" 'if [ "$_RESOLVED_LOCAL" = "$_CANON_LLAMA_CPP_DIR" ]; then' assert_contains \ "setup.ps1: ignores a local dir equal to the canonical install location" \ "$SETUP_PS1" 'if ($ResolvedLocal -eq $LlamaCppDir) {' From d5e9f5121b845edf8f41b4993ce6cf9623aaff4d Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Mon, 29 Jun 2026 17:02:07 -0500 Subject: [PATCH 05/10] Validate/reuse local llama.cpp tree and guard the in-use case Addresses the second Codex pass on the --with-llama-cpp-dir flag: - Validate the linked tree before disabling installs (setup.sh + setup.ps1): reusing a local dir skips BOTH the prebuilt download and the source build, so the dir must already contain a runnable llama-server (build/bin on Linux/macOS, build\bin\Release\llama-server.exe on Windows). Bail out with a clear message instead of linking an unbuilt/wrong-platform checkout and leaving Studio with no usable binary. - Treat a canonical-path target as already linked when it holds a build (setup.sh + setup.ps1): point the flag at ~/.unsloth/llama.cpp itself and an existing build is reused (skip prebuilt + source) rather than clobbered by the staged prebuilt installer (which uses os.replace()/replace). An empty canonical dir still falls through to the normal in-place install. - Abort when an in-use llama.cpp can't be removed on Windows (setup.ps1): Remove-Item -ErrorAction SilentlyContinue can silently leave a locked tree in place; detect that and stop with the same active-process message + exit 3 the prebuilt path uses, instead of junctioning over a half-present dir. Left as follow-up (already tracked by the PR author as a non-blocker): the in-app "Update llama.cpp" updater does not yet recognize a local-link install as externally managed; that fix belongs in studio/backend/utils/llama_cpp_update.py. --- studio/setup.ps1 | 30 ++++++++++++++++++++++++++++- studio/setup.sh | 50 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index dabc82437c..2d107f233b 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3166,9 +3166,29 @@ if ($LocalLlamaCppSrc) { exit 1 } $ResolvedLocal = (Resolve-Path -LiteralPath $LocalLlamaCppSrc).Path + # Studio loads the runtime from build\bin\Release\llama-server.exe; reusing a + # local dir disables both the prebuilt download and the source build, so that + # binary must already be present in the dir the user pointed at. + $LocalLlamaServerExe = Join-Path $ResolvedLocal "build\bin\Release\llama-server.exe" if ($ResolvedLocal -eq $LlamaCppDir) { - substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" "Yellow" + # Points at the canonical install location itself: never delete-then-link + # onto itself. Reuse an existing build here (skip prebuilt + source) so the + # staged prebuilt installer can't replace a build the user asked to reuse; + # if nothing is built yet, fall through to the normal install. + if (Test-Path -LiteralPath $LocalLlamaServerExe) { + substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR is the canonical install location and already holds a build; reusing it" "Yellow" + $LocalLlamaCppLinked = $true + $NeedLlamaSourceBuild = $false + } else { + substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location with nothing built there yet; running the normal install" "Yellow" + } } else { + # Fail clearly rather than junction an unbuilt or wrong-platform checkout + # and leave Studio with no usable binary. + if (-not (Test-Path -LiteralPath $LocalLlamaServerExe)) { + step "llama.cpp" "no llama-server.exe under $ResolvedLocal\build\bin\Release -- build llama.cpp there first, or drop --with-llama-cpp-dir" "Red" + exit 1 + } # If the target is already a junction/symlink (e.g. a previous # --with-llama-cpp-dir run), delete only the link via DirectoryInfo.Delete(). # Remove-Item -Recurse -Force on a reparse point can traverse the link and @@ -3187,6 +3207,14 @@ if ($LocalLlamaCppSrc) { } if (Test-Path -LiteralPath $LlamaCppDir) { Remove-Item -Recurse -Force -LiteralPath $LlamaCppDir -ErrorAction SilentlyContinue + # A locked/in-use tree can silently survive removal (SilentlyContinue + # masks it). Don't then junction/copy over a half-present dir; mirror the + # prebuilt path's active-process handling and stop with a clear message. + if (Test-Path -LiteralPath $LlamaCppDir) { + step "llama.cpp" "install blocked by active llama.cpp process" "Yellow" + substep "Close Studio or other llama.cpp users and retry" "Yellow" + exit 3 + } } cmd /c "mklink /J `"$LlamaCppDir`" `"$ResolvedLocal`"" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { diff --git a/studio/setup.sh b/studio/setup.sh index 9340aa9465..adf4b5bfa6 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1254,6 +1254,18 @@ fi verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)" +# GGUF export's check_llama_cpp() looks for a llama-quantize shim at the root of +# the install dir, but a source build keeps the binary under build/bin/. Mirror +# the source-build-reuse step and create the shim when the reused tree has one +# but no root shim yet. Best-effort: the tree may be read-only (shared/CI cache), +# and under `set -e` a failed ln would otherwise abort an good reuse. +_link_local_llama_quantize_shim() { + if [ -x "$1/build/bin/llama-quantize" ] && [ ! -e "$1/llama-quantize" ]; then + ln -sf build/bin/llama-quantize "$1/llama-quantize" 2>/dev/null || \ + substep "could not create llama-quantize shim in linked dir (read-only?); GGUF export may be unavailable" + fi +} + _LOCAL_LLAMA_CPP_LINKED=false if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then if [ ! -d "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" ]; then @@ -1273,8 +1285,30 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then _CANON_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" && pwd -P)/$(basename "$LLAMA_CPP_DIR")" fi if [ "$_RESOLVED_LOCAL" = "$_CANON_LLAMA_CPP_DIR" ]; then - substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location; ignoring" + # Points at the canonical install location itself: never delete-then-link + # it onto itself. If a usable build is already there, reuse it and skip + # both the prebuilt download and the source build -- the prebuilt installer + # uses os.replace() and would otherwise clobber an existing source build at + # this path. If nothing is built there yet, fall through to the normal + # install so it gets built in place exactly as it would without the flag. + if [ -x "$LLAMA_CPP_DIR/build/bin/llama-server" ]; then + substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR is the canonical install location and already holds a build; reusing it" + _link_local_llama_quantize_shim "$LLAMA_CPP_DIR" + _LOCAL_LLAMA_CPP_LINKED=true + _NEED_LLAMA_SOURCE_BUILD=false + _SKIP_PREBUILT_INSTALL=true + else + substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR points to the canonical install location with nothing built there yet; running the normal install" + fi else + # Reusing disables BOTH the prebuilt download and the source build, so the + # linked tree must already contain a runnable llama-server (Studio loads it + # from build/bin/). Fail clearly rather than link an unbuilt or + # wrong-platform checkout and leave Studio with no usable binary. + if [ ! -x "$_RESOLVED_LOCAL/build/bin/llama-server" ]; then + step "llama.cpp" "no llama-server under $_RESOLVED_LOCAL/build/bin -- build llama.cpp there first, or drop --with-llama-cpp-dir" "$C_ERR" + exit 1 + fi # A stale link from a previous --with-llama-cpp-dir run isn't Studio-owned # content; drop it before the ownership check so re-runs stay idempotent # for a custom UNSLOTH_STUDIO_HOME (the assert would otherwise follow the @@ -1285,19 +1319,7 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then fi rm -rf "$LLAMA_CPP_DIR" ln -sfn "$_RESOLVED_LOCAL" "$LLAMA_CPP_DIR" - # GGUF export's check_llama_cpp() looks for a llama-quantize shim at the - # root of the install dir. A normal source build keeps the binary under - # build/bin/, so mirror the source-build-reuse step and create the shim - # when the linked checkout has one but no root shim yet. Inference - # (llama-server) works off the linked tree directly and needs nothing. - if [ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ] && \ - [ ! -e "$LLAMA_CPP_DIR/llama-quantize" ]; then - # Best-effort: this writes through the link into the user's tree, which - # may be read-only (a shared/CI cache). Under `set -e` a failed ln would - # abort an otherwise-good reuse, so don't make the shim fatal. - ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize" 2>/dev/null || \ - substep "could not create llama-quantize shim in linked dir (read-only?); GGUF export may be unavailable" - fi + _link_local_llama_quantize_shim "$LLAMA_CPP_DIR" step "llama.cpp" "linked local directory: $_RESOLVED_LOCAL" _LOCAL_LLAMA_CPP_LINKED=true _NEED_LLAMA_SOURCE_BUILD=false From d254f7e0350fee067cdd7845e3339c783bba1a5b Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 30 Jun 2026 15:02:49 -0500 Subject: [PATCH 06/10] Accept all backend llama-server layouts in --with-llama-cpp-dir validation The linked-tree validation only accepted build/bin[/Release]/llama-server, but LlamaCppBackend._layout_candidates() resolves a root-level llama-server first, then build/bin, then build/bin/Release on Windows. A `make` build or a flat release extract (binary at the dir root) was therefore rejected with a hard installer failure even though Studio would have run it. Validate the same candidate set the backend uses in both setup scripts, and add wiring-test assertions so the check can't silently narrow again. --- studio/setup.ps1 | 21 ++++++++++++++------- studio/setup.sh | 20 ++++++++++++++------ tests/sh/test_with_llama_cpp_dir_flag.sh | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 2d107f233b..e05d411cbe 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3166,16 +3166,23 @@ if ($LocalLlamaCppSrc) { exit 1 } $ResolvedLocal = (Resolve-Path -LiteralPath $LocalLlamaCppSrc).Path - # Studio loads the runtime from build\bin\Release\llama-server.exe; reusing a - # local dir disables both the prebuilt download and the source build, so that - # binary must already be present in the dir the user pointed at. - $LocalLlamaServerExe = Join-Path $ResolvedLocal "build\bin\Release\llama-server.exe" + # Reusing a local dir disables both the prebuilt download and the source + # build, so a runnable llama-server.exe must already be present. Accept any + # layout LlamaCppBackend._layout_candidates() resolves (root-level, build\bin, + # or build\bin\Release) so the flag never rejects a tree Studio could run. + $LocalLlamaServerFound = $false + foreach ($_cand in @( + (Join-Path $ResolvedLocal "llama-server.exe"), + (Join-Path $ResolvedLocal "build\bin\llama-server.exe"), + (Join-Path $ResolvedLocal "build\bin\Release\llama-server.exe"))) { + if (Test-Path -LiteralPath $_cand) { $LocalLlamaServerFound = $true; break } + } if ($ResolvedLocal -eq $LlamaCppDir) { # Points at the canonical install location itself: never delete-then-link # onto itself. Reuse an existing build here (skip prebuilt + source) so the # staged prebuilt installer can't replace a build the user asked to reuse; # if nothing is built yet, fall through to the normal install. - if (Test-Path -LiteralPath $LocalLlamaServerExe) { + if ($LocalLlamaServerFound) { substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR is the canonical install location and already holds a build; reusing it" "Yellow" $LocalLlamaCppLinked = $true $NeedLlamaSourceBuild = $false @@ -3185,8 +3192,8 @@ if ($LocalLlamaCppSrc) { } else { # Fail clearly rather than junction an unbuilt or wrong-platform checkout # and leave Studio with no usable binary. - if (-not (Test-Path -LiteralPath $LocalLlamaServerExe)) { - step "llama.cpp" "no llama-server.exe under $ResolvedLocal\build\bin\Release -- build llama.cpp there first, or drop --with-llama-cpp-dir" "Red" + if (-not $LocalLlamaServerFound) { + step "llama.cpp" "no llama-server.exe under $ResolvedLocal (looked for .\llama-server.exe, .\build\bin and .\build\bin\Release) -- build llama.cpp there first, or drop --with-llama-cpp-dir" "Red" exit 1 } # If the target is already a junction/symlink (e.g. a previous diff --git a/studio/setup.sh b/studio/setup.sh index adf4b5bfa6..199ba2fd7f 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1266,6 +1266,13 @@ _link_local_llama_quantize_shim() { fi } +# Accept any layout LlamaCppBackend._layout_candidates() resolves so the flag +# never rejects a tree Studio could actually run: a root-level llama-server (a +# `make` build or a flat-extracted release) or the CMake build/bin/llama-server. +_has_local_llama_server() { + [ -x "$1/llama-server" ] || [ -x "$1/build/bin/llama-server" ] +} + _LOCAL_LLAMA_CPP_LINKED=false if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then if [ ! -d "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" ]; then @@ -1291,7 +1298,7 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then # uses os.replace() and would otherwise clobber an existing source build at # this path. If nothing is built there yet, fall through to the normal # install so it gets built in place exactly as it would without the flag. - if [ -x "$LLAMA_CPP_DIR/build/bin/llama-server" ]; then + if _has_local_llama_server "$LLAMA_CPP_DIR"; then substep "UNSLOTH_LOCAL_LLAMA_CPP_DIR is the canonical install location and already holds a build; reusing it" _link_local_llama_quantize_shim "$LLAMA_CPP_DIR" _LOCAL_LLAMA_CPP_LINKED=true @@ -1302,11 +1309,12 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then fi else # Reusing disables BOTH the prebuilt download and the source build, so the - # linked tree must already contain a runnable llama-server (Studio loads it - # from build/bin/). Fail clearly rather than link an unbuilt or - # wrong-platform checkout and leave Studio with no usable binary. - if [ ! -x "$_RESOLVED_LOCAL/build/bin/llama-server" ]; then - step "llama.cpp" "no llama-server under $_RESOLVED_LOCAL/build/bin -- build llama.cpp there first, or drop --with-llama-cpp-dir" "$C_ERR" + # linked tree must already contain a runnable llama-server in one of the + # layouts the backend resolves (root-level or build/bin/). Fail clearly + # rather than link an unbuilt or wrong-platform checkout and leave Studio + # with no usable binary. + if ! _has_local_llama_server "$_RESOLVED_LOCAL"; then + step "llama.cpp" "no llama-server under $_RESOLVED_LOCAL (looked for ./llama-server and ./build/bin/llama-server) -- build llama.cpp there first, or drop --with-llama-cpp-dir" "$C_ERR" exit 1 fi # A stale link from a previous --with-llama-cpp-dir run isn't Studio-owned diff --git a/tests/sh/test_with_llama_cpp_dir_flag.sh b/tests/sh/test_with_llama_cpp_dir_flag.sh index d008f82c79..cee158bf40 100644 --- a/tests/sh/test_with_llama_cpp_dir_flag.sh +++ b/tests/sh/test_with_llama_cpp_dir_flag.sh @@ -132,6 +132,23 @@ assert_contains \ "setup.ps1: linked-dir case short-circuits the build chain" \ "$SETUP_PS1" 'step "llama.cpp" "linked (skipping build)"' +echo "" +echo "=== both setup scripts: validate against every layout the backend resolves ===" + +# The linked tree is accepted only if it already holds a runnable llama-server, +# but the check must match LlamaCppBackend._layout_candidates() (root-level +# first, then build/bin, then build/bin/Release on Windows). A narrower check +# would reject a make/flat-release tree the backend could run. +assert_contains \ + "setup.sh: accepts root-level or build/bin llama-server layouts" \ + "$SETUP_SH" '[ -x "$1/llama-server" ] || [ -x "$1/build/bin/llama-server" ]' +assert_contains \ + "setup.ps1: accepts the build\\bin (non-Release) llama-server.exe layout" \ + "$SETUP_PS1" 'Join-Path $ResolvedLocal "build\bin\llama-server.exe"' +assert_contains \ + "setup.ps1: accepts the root-level llama-server.exe layout" \ + "$SETUP_PS1" 'Join-Path $ResolvedLocal "llama-server.exe"' + echo "" echo "=== both setup scripts: a local dir pointing at the canonical path is a no-op ===" From 5252163435f0d79fd0fe515ac87fa568a6dbb225 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 30 Jun 2026 16:21:27 -0500 Subject: [PATCH 07/10] Treat --with-llama-cpp-dir local links as externally managed A --with-llama-cpp-dir install junctions/symlinks the canonical llama.cpp dir to the user's own checkout, but two backend paths still treated it as a Studio-owned tree: - The in-app updater (llama_cpp_update) offered and could apply an official prebuilt over the link, writing through it into the user's checkout (or failing) and silently dropping the link the flag created. - Orphan cleanup (LlamaCppBackend._kill_orphaned_servers) resolved the linked root into its kill allowlist, so a llama-server the user launched from the same checkout was classified as ours and killed on startup. Detect the canonical dir being a symlink/junction (reparse point) and treat the install as unmanaged: get_update_status reports unsupported, start_update refuses with reason "local_link", and the linked root is left out of the orphan allowlist. Adds behavioral tests (link vs plain dir, updater refusal, and the spared-vs-killed orphan control). --- studio/backend/core/inference/llama_cpp.py | 26 ++++ .../tests/test_local_llama_cpp_link.py | 132 ++++++++++++++++++ studio/backend/utils/llama_cpp_update.py | 75 ++++++++++ 3 files changed, 233 insertions(+) create mode 100644 studio/backend/tests/test_local_llama_cpp_link.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 347f469d33..ed711df781 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1223,6 +1223,25 @@ def _backfill_usage_from_timings(usage, timings): return out +def _is_external_link(path: Path) -> bool: + """True when ``path`` is a --with-llama-cpp-dir local link: a POSIX symlink + or a Windows directory junction / reparse point. Such a link resolves into + the user's own llama.cpp checkout, which Studio does not own.""" + try: + if os.path.islink(path): + return True + except OSError: + return False + if os.name == "nt": + try: + import stat + attrs = os.lstat(path).st_file_attributes # type: ignore[attr-defined] + return bool(attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT) + except (OSError, AttributeError): + return False + return False + + class LlamaCppBackend: """Manages a llama-server subprocess for GGUF model inference. @@ -7140,6 +7159,13 @@ def _kill_orphaned_servers() -> int: resolved_roots: list[Path] = [] for root in install_roots: try: + # A --with-llama-cpp-dir local link (symlink/junction) + # resolves into the user's own checkout. Adding it would let + # us treat the user's externally-launched llama-server as our + # orphan and kill it, so leave such roots out of the + # allowlist (we forgo orphan-reaping for local-link installs). + if _is_external_link(root): + continue resolved_roots.append(root.resolve()) except OSError: pass diff --git a/studio/backend/tests/test_local_llama_cpp_link.py b/studio/backend/tests/test_local_llama_cpp_link.py new file mode 100644 index 0000000000..2557302fa1 --- /dev/null +++ b/studio/backend/tests/test_local_llama_cpp_link.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Behavioral tests for the --with-llama-cpp-dir 'unmanaged local link' contract. + +When the canonical llama.cpp dir is a symlink (POSIX) / junction (Windows) to a +user's own checkout, Studio must treat it as externally managed: + - the in-app updater must not offer or apply a prebuilt over the link + - orphan cleanup must not kill a llama-server the user launched from that tree + +These exercise real link behavior rather than grepping the scripts. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +from utils import llama_cpp_update as u +from core.inference.llama_cpp import LlamaCppBackend + + +def _make_link(link: Path, target: Path) -> None: + """Create a directory junction (Windows) / symlink (POSIX); neither needs + elevation.""" + target.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + check=True, capture_output=True, text=True, + ) + else: + link.symlink_to(target, target_is_directory=True) + + +def _server_subpath() -> Path: + return Path( + "build/bin/Release/llama-server.exe" if os.name == "nt" + else "build/bin/llama-server" + ) + + +class _FakeProc: + def __init__(self, pid: int, exe: str) -> None: + self.info = {"pid": pid, "name": "llama-server", "exe": exe} + self.killed = False + + def kill(self) -> None: + self.killed = True + + +def test_is_external_link_detects_link_vs_plain_dir(tmp_path: Path) -> None: + plain = tmp_path / "plain" + plain.mkdir() + assert u._is_external_link(plain) is False + + link = tmp_path / "link" + _make_link(link, tmp_path / "tgt") + assert u._is_external_link(link) is True + + +def test_active_install_is_local_link(tmp_path: Path) -> None: + link = tmp_path / "llama.cpp" + _make_link(link, tmp_path / "tgt") + binary = str(link / _server_subpath()) + assert u._active_install_is_local_link(binary) is True + + # A plain (non-link) llama.cpp dir is Studio-managed, not a local link. + plain = tmp_path / "plain" / "llama.cpp" + plain.mkdir(parents=True) + assert u._active_install_is_local_link(str(plain / _server_subpath())) is False + + +def test_get_update_status_reports_local_link(tmp_path: Path, monkeypatch) -> None: + link = tmp_path / "llama.cpp" + _make_link(link, tmp_path / "tgt") + monkeypatch.setattr(u, "_find_binary", lambda: str(link / _server_subpath())) + st = u.get_update_status() + assert st["supported"] is False + assert st["update_available"] is False + assert st["local_link"] is True + + +def test_start_update_refuses_local_link(tmp_path: Path, monkeypatch) -> None: + link = tmp_path / "llama.cpp" + _make_link(link, tmp_path / "tgt") + monkeypatch.setattr(u, "_find_binary", lambda: str(link / _server_subpath())) + res = u.start_update() + assert res["started"] is False + assert res["reason"] == "local_link" + + +def _run_orphan_scan(monkeypatch, studio_root: Path, fake: _FakeProc) -> int: + import psutil + monkeypatch.setattr( + LlamaCppBackend, "_resolved_studio_root_and_is_legacy", + staticmethod(lambda: (studio_root.resolve(), False)), + ) + monkeypatch.setattr(LlamaCppBackend, "_reap_recorded_pid", staticmethod(lambda: 0)) + monkeypatch.setattr(psutil, "process_iter", lambda attrs=None: iter([fake])) + return LlamaCppBackend._kill_orphaned_servers() + + +def test_orphan_cleanup_spares_local_link_tree(tmp_path: Path, monkeypatch) -> None: + studio_root = tmp_path / "studio-home" + studio_root.mkdir() + external = tmp_path / "external" + (external / _server_subpath().parent).mkdir(parents=True) + (external / _server_subpath()).write_text("x") + _make_link(studio_root / "llama.cpp", external) + + exe_under_link = str((external / _server_subpath()).resolve()) + fake = _FakeProc(os.getpid() + 777, exe_under_link) + killed = _run_orphan_scan(monkeypatch, studio_root, fake) + assert killed == 0 + assert fake.killed is False + + +def test_orphan_cleanup_kills_under_real_root(tmp_path: Path, monkeypatch) -> None: + # Control: a real (non-link) managed root still gets its orphan reaped, so + # the spare-the-link test above is meaningful (not a no-op). + studio_root = tmp_path / "studio-home" + bin_dir = studio_root / "llama.cpp" / _server_subpath().parent + bin_dir.mkdir(parents=True) + exe = studio_root / "llama.cpp" / _server_subpath() + exe.write_text("x") + + fake = _FakeProc(os.getpid() + 888, str(exe.resolve())) + killed = _run_orphan_scan(monkeypatch, studio_root, fake) + assert killed == 1 + assert fake.killed is True diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 8648b053d5..c16ae91467 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -324,12 +324,74 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]: } +def _is_external_link(path: Optional[Path]) -> bool: + """True when ``path`` is a --with-llama-cpp-dir local link: a POSIX symlink + or a Windows directory junction / reparse point. Such a link resolves into + the user's own llama.cpp checkout, so Studio must never auto-update it.""" + if path is None: + return False + try: + if os.path.islink(path): + return True + except OSError: + return False + if os.name == "nt": + try: + import stat + attrs = os.lstat(path).st_file_attributes # type: ignore[attr-defined] + return bool(attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT) + except (OSError, AttributeError): + return False + return False + + +def _active_install_is_local_link(binary: Optional[str]) -> bool: + """True when the active llama-server resolves through a --with-llama-cpp-dir + local link at the canonical llama.cpp directory. An update would write + through that link into the user's own checkout (or fail), so the install is + treated as externally managed: no update is offered or applied. Checks only + up to and including the ``llama.cpp`` dir so a symlinked HOME / studio root + above it can't trip a false positive.""" + if not binary: + return False + for parent in Path(binary).parents: + if _is_external_link(parent): + return True + if parent.name == "llama.cpp": + break + return False + + +def _local_link_status() -> dict: + """Status payload for a local-link install: unmanaged, no update offered.""" + with _job_lock: + job = dict(_job) + return { + "supported": False, + "update_available": False, + "stale": False, + "installed_tag": None, + "latest_tag": None, + "published_repo": None, + "installed_at_utc": None, + "age_days": None, + "source_build": False, + "local_link": True, + "update_size_bytes": None, + "job": job, + } + + def get_update_status(*, force_refresh: bool = False) -> dict: """Report whether a newer prebuilt exists plus the current job state. force_refresh bypasses the 24h release cache for an explicit "check now". """ binary = _find_binary() + # A --with-llama-cpp-dir local link is the user's own tree; never offer to + # replace it. Bail before any network/freshness work. + if _active_install_is_local_link(binary): + return _local_link_status() marker = read_install_marker(binary) with _job_lock: @@ -537,6 +599,19 @@ def start_update() -> dict: """Kick off a background update. Idempotent: a second call while one is running returns the in-flight job rather than starting another.""" binary = _find_binary() + # Refuse to update a --with-llama-cpp-dir local link: installing a prebuilt + # here would write through the link into the user's own checkout (or fail) + # and silently drop the link the flag created. + if _active_install_is_local_link(binary): + return { + "started": False, + "reason": "local_link", + "message": ( + "llama.cpp is a local directory linked with --with-llama-cpp-dir; " + "Studio won't replace it. Update your own llama.cpp checkout instead." + ), + "job": get_update_status()["job"], + } marker = read_install_marker(binary) script = _installer_script() if script is None: From 120694321d4ebdf0973990adddbc10e8446ac4c8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:22:07 +0000 Subject: [PATCH 08/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_local_llama_cpp_link.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/studio/backend/tests/test_local_llama_cpp_link.py b/studio/backend/tests/test_local_llama_cpp_link.py index 2557302fa1..f1fe36755a 100644 --- a/studio/backend/tests/test_local_llama_cpp_link.py +++ b/studio/backend/tests/test_local_llama_cpp_link.py @@ -24,20 +24,21 @@ def _make_link(link: Path, target: Path) -> None: """Create a directory junction (Windows) / symlink (POSIX); neither needs elevation.""" - target.mkdir(parents=True, exist_ok=True) + target.mkdir(parents = True, exist_ok = True) if os.name == "nt": subprocess.run( ["cmd", "/c", "mklink", "/J", str(link), str(target)], - check=True, capture_output=True, text=True, + check = True, + capture_output = True, + text = True, ) else: - link.symlink_to(target, target_is_directory=True) + link.symlink_to(target, target_is_directory = True) def _server_subpath() -> Path: return Path( - "build/bin/Release/llama-server.exe" if os.name == "nt" - else "build/bin/llama-server" + "build/bin/Release/llama-server.exe" if os.name == "nt" else "build/bin/llama-server" ) @@ -68,7 +69,7 @@ def test_active_install_is_local_link(tmp_path: Path) -> None: # A plain (non-link) llama.cpp dir is Studio-managed, not a local link. plain = tmp_path / "plain" / "llama.cpp" - plain.mkdir(parents=True) + plain.mkdir(parents = True) assert u._active_install_is_local_link(str(plain / _server_subpath())) is False @@ -93,12 +94,14 @@ def test_start_update_refuses_local_link(tmp_path: Path, monkeypatch) -> None: def _run_orphan_scan(monkeypatch, studio_root: Path, fake: _FakeProc) -> int: import psutil + monkeypatch.setattr( - LlamaCppBackend, "_resolved_studio_root_and_is_legacy", + LlamaCppBackend, + "_resolved_studio_root_and_is_legacy", staticmethod(lambda: (studio_root.resolve(), False)), ) monkeypatch.setattr(LlamaCppBackend, "_reap_recorded_pid", staticmethod(lambda: 0)) - monkeypatch.setattr(psutil, "process_iter", lambda attrs=None: iter([fake])) + monkeypatch.setattr(psutil, "process_iter", lambda attrs = None: iter([fake])) return LlamaCppBackend._kill_orphaned_servers() @@ -106,7 +109,7 @@ def test_orphan_cleanup_spares_local_link_tree(tmp_path: Path, monkeypatch) -> N studio_root = tmp_path / "studio-home" studio_root.mkdir() external = tmp_path / "external" - (external / _server_subpath().parent).mkdir(parents=True) + (external / _server_subpath().parent).mkdir(parents = True) (external / _server_subpath()).write_text("x") _make_link(studio_root / "llama.cpp", external) @@ -122,7 +125,7 @@ def test_orphan_cleanup_kills_under_real_root(tmp_path: Path, monkeypatch) -> No # the spare-the-link test above is meaningful (not a no-op). studio_root = tmp_path / "studio-home" bin_dir = studio_root / "llama.cpp" / _server_subpath().parent - bin_dir.mkdir(parents=True) + bin_dir.mkdir(parents = True) exe = studio_root / "llama.cpp" / _server_subpath() exe.write_text("x") From be8a3c38e1a5b2f7dd4de9bb4c7e0b979aaa6104 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 30 Jun 2026 16:27:04 -0500 Subject: [PATCH 09/10] Add behavioral shell test for --with-llama-cpp-dir linking The existing tests/sh/test_with_llama_cpp_dir_flag.sh is a static grep of the scripts. This adds a behavioral test that extracts the real link block from studio/setup.sh (by content anchors, with a self-validating extraction) and runs it against hermetic fake dirs, asserting the outcomes that matter: - an external CMake build links and arms neither the prebuilt download nor the source build - a flat / make tree (root-level llama-server, no build/bin) is accepted too - an unbuilt tree is rejected with a non-zero exit and no link left behind - relinking over a stale link preserves the target's contents (no data loss) - pointing at the canonical path is a no-op reuse, not a self-referential link Symlink-identity checks run only where real symlinks exist (skipped on Windows git-bash copy-mode); the link/skip/no-data-loss checks run everywhere. Wired into studio-backend-ci.yml next to the static test. --- .github/workflows/studio-backend-ci.yml | 3 +- .../test_with_llama_cpp_dir_link_behavior.sh | 132 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/sh/test_with_llama_cpp_dir_link_behavior.sh diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 6a05dd7d96..5eeb4d0783 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -230,7 +230,8 @@ jobs: tests/sh/test_tauri_install_exit_order.sh \ tests/sh/test_torch_constraint.sh \ tests/sh/test_torch_flavor.sh \ - tests/sh/test_with_llama_cpp_dir_flag.sh; do + tests/sh/test_with_llama_cpp_dir_flag.sh \ + tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do echo "::group::$s" bash "$s" echo "::endgroup::" diff --git a/tests/sh/test_with_llama_cpp_dir_link_behavior.sh b/tests/sh/test_with_llama_cpp_dir_link_behavior.sh new file mode 100644 index 0000000000..fb09e56c51 --- /dev/null +++ b/tests/sh/test_with_llama_cpp_dir_link_behavior.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Behavioral test for the --with-llama-cpp-dir linking block in studio/setup.sh. +# The companion test_with_llama_cpp_dir_flag.sh is a static wiring check; this one +# actually RUNS the real link logic (extracted from setup.sh by content anchors, +# not line numbers) against hermetic fake dirs and asserts the outcomes Lee asked +# for: an external built dir gets linked, neither the prebuilt download nor the +# source build is armed, an unbuilt dir is rejected, a relink doesn't destroy the +# target, and pointing at the canonical path is a no-op. POSIX symlinks here; +# the Windows junction path is covered by the backend test suite. +set -u +HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)" +SETUP="$HERE/../../studio/setup.sh" +fails=0 +check() { # name expected actual + if [ "$2" = "$3" ]; then printf ' PASS %s\n' "$1" + else printf ' FAIL %s : expected [%s] got [%s]\n' "$1" "$2" "$3"; fails=$((fails+1)); fi +} + +# Extract the two helpers + the whole `UNSLOTH_LOCAL_LLAMA_CPP_DIR` if-block. +# Starts at the quantize-shim helper, ends at the first column-0 `fi` after the +# `if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR..` guard (inner ifs are indented). +block="$(awk ' + /^_link_local_llama_quantize_shim\(\) \{/ {grab=1} + grab {print} + /^if \[ -n "\$\{UNSLOTH_LOCAL_LLAMA_CPP_DIR/ {inif=1} + inif && /^fi$/ {exit} +' "$SETUP")" + +# Self-validate the extraction so a future setup.sh refactor fails loudly here. +case "$block" in *'ln -sfn "$_RESOLVED_LOCAL" "$LLAMA_CPP_DIR"'*) : ;; + *) echo "FAIL: link block extraction broke (no ln -sfn)"; exit 1 ;; esac +case "$block" in *'_has_local_llama_server'*) : ;; + *) echo "FAIL: link block extraction broke (no _has_local_llama_server)"; exit 1 ;; esac + +# Stub setup.sh's logging + ownership helpers, seed the vars the block reads, +# then run the extracted block and print the resulting state. +PREAMBLE=' +set -u +step() { :; }; substep() { :; }; verbose_substep() { :; } +_assert_studio_owned_or_absent() { :; } +C_ERR="" +_STUDIO_HOME_IS_CUSTOM=false +_NEED_LLAMA_SOURCE_BUILD=UNSET +_SKIP_PREBUILT_INSTALL=UNSET +' +EPILOGUE=' +echo "LINKED=$_LOCAL_LLAMA_CPP_LINKED" +echo "NEED_BUILD=$_NEED_LLAMA_SOURCE_BUILD" +echo "SKIP_PREBUILT=$_SKIP_PREBUILT_INSTALL" +if [ -L "$LLAMA_CPP_DIR" ]; then echo "ISLINK=1"; echo "TARGET=$(readlink "$LLAMA_CPP_DIR")"; else echo "ISLINK=0"; fi +' +SNIP="$PREAMBLE"$'\n'"$block"$'\n'"$EPILOGUE" + +# run_link -> prints state lines; RC in $RC +run_link() { + OUT="$(env -i PATH="$PATH" HOME="$T" \ + UNSLOTH_LOCAL_LLAMA_CPP_DIR="$1" LLAMA_CPP_DIR="$2" \ + bash -c "$SNIP" 2>/dev/null)" + RC=$? +} +val() { printf '%s\n' "$OUT" | grep "^$1=" | head -1 | cut -d= -f2-; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +# Some environments (Windows git-bash without native symlinks) make `ln -s` copy +# instead of link. The symlink-identity assertions (ISLINK / readlink target) +# only run where real symlinks exist; the link/skip/no-data-loss assertions run +# everywhere, including CI (Linux), where the link path is the real one. +ln -s "$T" "$T/.symprobe" 2>/dev/null +if [ -L "$T/.symprobe" ]; then SYMLINKS=1; else SYMLINKS=0; fi +rm -rf "$T/.symprobe" + +# A built external tree (CMake layout) + a flat/`make` tree (root-level binary). +# The fake binary is a shebang script so the `-x` test in _has_local_llama_server +# holds on both Linux (chmod +x) and Windows git-bash (MSYS treats #!-files as +# executable), without needing a real platform binary. +mk_exe() { printf '#!/bin/sh\necho fake\n' > "$1"; chmod +x "$1"; } +mk_built() { mkdir -p "$1/build/bin"; mk_exe "$1/build/bin/llama-server"; } +mk_flat() { mkdir -p "$1"; mk_exe "$1/llama-server"; } + +# 1. External CMake build -> linked, and BOTH install paths disarmed. +EXT1="$T/ext_cmake"; mk_built "$EXT1"; : > "$EXT1/keep.txt" +CANON1="$T/home1/llama.cpp"; mkdir -p "$(dirname "$CANON1")" +run_link "$EXT1" "$CANON1" +check "cmake build: linked" "true" "$(val LINKED)" +check "cmake build: source build off" "false" "$(val NEED_BUILD)" +check "cmake build: prebuilt skipped" "true" "$(val SKIP_PREBUILT)" +if [ "$SYMLINKS" = 1 ]; then + check "cmake build: canonical is a symlink" "1" "$(val ISLINK)" + check "cmake build: link points at external" "$(CDPATH= cd -P -- "$EXT1" && pwd -P)" "$(val TARGET)" +else + printf ' SKIP cmake build: symlink-identity (no real symlinks here)\n' +fi + +# 2. Flat / make tree (root-level llama-server, no build/bin) -> still linked +# (the new layout-candidate acceptance; the old check rejected this). +EXT2="$T/ext_flat"; mk_flat "$EXT2" +CANON2="$T/home2/llama.cpp"; mkdir -p "$(dirname "$CANON2")" +run_link "$EXT2" "$CANON2" +check "flat build: linked (root-level llama-server accepted)" "true" "$(val LINKED)" + +# 3. Unbuilt tree -> rejected (non-zero exit, no link created). +EXT3="$T/ext_empty"; mkdir -p "$EXT3" +CANON3="$T/home3/llama.cpp"; mkdir -p "$(dirname "$CANON3")" +run_link "$EXT3" "$CANON3" +check "unbuilt tree: rejected (exit != 0)" "yes" "$([ "$RC" -ne 0 ] && echo yes || echo no)" +check "unbuilt tree: no link left behind" "no" "$([ -L "$CANON3" ] && echo yes || echo no)" + +# 4. Relink over a stale link must NOT destroy the (new) target's contents. +OLD="$T/ext_old"; mk_built "$OLD" +NEW="$T/ext_new"; mk_built "$NEW"; : > "$NEW/precious.txt" +CANON4="$T/home4/llama.cpp"; mkdir -p "$(dirname "$CANON4")" +ln -sfn "$OLD" "$CANON4" # simulate a prior --with-llama-cpp-dir run +run_link "$NEW" "$CANON4" +if [ "$SYMLINKS" = 1 ]; then + check "relink: now points at the new external" "$(CDPATH= cd -P -- "$NEW" && pwd -P)" "$(val TARGET)" +fi +check "relink: new target's contents preserved" "yes" "$([ -f "$NEW/precious.txt" ] && echo yes || echo no)" +check "relink: old target's contents preserved" "yes" "$([ -f "$OLD/build/bin/llama-server" ] && echo yes || echo no)" + +# 5. Pointing at the canonical path itself is a no-op reuse: linked, not turned +# into a self-referential symlink, contents untouched. +CANON5="$T/home5/llama.cpp"; mk_built "$CANON5"; : > "$CANON5/keep.txt" +run_link "$CANON5" "$CANON5" +check "canonical no-op: linked" "true" "$(val LINKED)" +check "canonical no-op: not made a symlink" "0" "$(val ISLINK)" +check "canonical no-op: contents preserved" "yes" "$([ -f "$CANON5/keep.txt" ] && echo yes || echo no)" + +echo "" +if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi +echo "All checks passed" From 48b37bbd93eea907f340951e4eafe1483a76ba9d Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 30 Jun 2026 17:04:00 -0500 Subject: [PATCH 10/10] Install psutil in backend CI so orphan-cleanup tests run The new orphan-cleanup tests import psutil for the process scan, but the Backend CI deps step installed studio.txt plus a fixed extras list that omits it, so the two tests failed with ModuleNotFoundError. Add psutil to both backend pytest dep steps (kept in shared shape), and guard the import with pytest.importorskip so a minimal env without psutil skips these tests instead of erroring. --- .github/workflows/studio-backend-ci.yml | 7 ++++--- studio/backend/tests/test_local_llama_cpp_link.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 5eeb4d0783..3022127a2b 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -68,9 +68,10 @@ jobs: pip install -r studio/backend/requirements/studio.txt # Extras that studio.txt does not list but the import chain needs # (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography - # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.): + # for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for + # the orphan-cleanup process scan, etc.): pip install \ - python-multipart aiofiles sqlalchemy cryptography \ + python-multipart aiofiles sqlalchemy cryptography psutil \ pyyaml jinja2 mammoth unpdf requests \ 'numpy<3' pytest pytest-asyncio httpx # Torch CPU + transformers are required by a chunk of the backend test @@ -133,7 +134,7 @@ jobs: python -m pip install --upgrade pip pip install -r studio/backend/requirements/studio.txt pip install \ - python-multipart aiofiles sqlalchemy cryptography \ + python-multipart aiofiles sqlalchemy cryptography psutil \ pyyaml jinja2 mammoth unpdf requests typer \ 'numpy<3' pytest pytest-asyncio httpx # torchvision: unsloth_zoo.vision_utils imports it at module scope. diff --git a/studio/backend/tests/test_local_llama_cpp_link.py b/studio/backend/tests/test_local_llama_cpp_link.py index f1fe36755a..c78c029d91 100644 --- a/studio/backend/tests/test_local_llama_cpp_link.py +++ b/studio/backend/tests/test_local_llama_cpp_link.py @@ -93,7 +93,9 @@ def test_start_update_refuses_local_link(tmp_path: Path, monkeypatch) -> None: def _run_orphan_scan(monkeypatch, studio_root: Path, fake: _FakeProc) -> int: - import psutil + # psutil drives the cross-platform process scan; skip (rather than error) if a + # minimal test env lacks it. CI installs it so these tests actually run. + psutil = pytest.importorskip("psutil") monkeypatch.setattr( LlamaCppBackend,