Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
22eb2a8
Add --with-llama-cpp-dir flag to install.ps1 and install.sh
LeoBorcherding Jun 17, 2026
35542c6
test: add static wiring test for --with-llama-cpp-dir flag
LeoBorcherding Jun 19, 2026
fe4b43d
Address review feedback on --with-llama-cpp-dir flag
LeoBorcherding Jun 23, 2026
b2587dc
Merge remote-tracking branch 'upstream/main' into with-llamacpp-dir-flag
LeoBorcherding Jun 23, 2026
0d16c6a
Merge branch 'main' into with-llamacpp-dir-flag
LeoBorcherding Jun 26, 2026
56b1e3c
Harden --with-llama-cpp-dir against Codex/Gemini review findings
LeoBorcherding Jun 29, 2026
d5e9f51
Validate/reuse local llama.cpp tree and guard the in-use case
LeoBorcherding Jun 29, 2026
43f2082
Merge branch 'main' into with-llamacpp-dir-flag
Imagineer99 Jun 30, 2026
d254f7e
Accept all backend llama-server layouts in --with-llama-cpp-dir valid…
LeoBorcherding Jun 30, 2026
5252163
Treat --with-llama-cpp-dir local links as externally managed
LeoBorcherding Jun 30, 2026
1206943
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 30, 2026
be8a3c3
Add behavioral shell test for --with-llama-cpp-dir linking
LeoBorcherding Jun 30, 2026
48b37bb
Install psutil in backend CI so orphan-cleanup tests run
LeoBorcherding Jun 30, 2026
829b3b1
Merge branch 'main' into with-llamacpp-dir-flag
Imagineer99 Jul 2, 2026
9a365dc
Merge branch 'main' into with-llamacpp-dir-flag
Imagineer99 Jul 2, 2026
3877bea
Merge branch 'main' into with-llamacpp-dir-flag
Imagineer99 Jul 2, 2026
8d398a2
Merge branch 'main' into with-llamacpp-dir-flag
Imagineer99 Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/studio-backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -229,7 +230,9 @@ jobs:
tests/sh/test_resolve_cuda_archs.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 \
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"
Expand Down
17 changes: 17 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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]
}
}
}

Expand Down Expand Up @@ -2430,6 +2439,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.")
Comment on lines +2443 to +2445

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the environment on invalid local llama.cpp paths

When --with-llama-cpp-dir is mistyped in a PowerShell session that already has UNSLOTH_STUDIO_HOME or the installer state env vars set, this validation returns after the code above has removed/overwritten those env vars but before the try/finally that restores them starts. That leaves the caller's session pointed back at the default Studio home (or with stale STUDIO_LOCAL_INSTALL/skip flags), so a subsequent setup in the same shell can install into the wrong root; move this validation before mutating env vars or include it inside the restore finally.

Useful? React with 👍 / 👎.

}
$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
Expand All @@ -2445,6 +2461,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
}
Expand Down
24 changes: 24 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ _VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
_next_is_python=false
_next_is_llama_cpp_dir=false
# 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"
Expand All @@ -64,6 +69,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 ;;
Expand All @@ -72,6 +82,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 ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject a missing llama.cpp path in the shell parser

If --with-llama-cpp-dir is the final argument, _next_is_llama_cpp_dir remains true after the loop and no error is raised, so _WITH_LLAMA_CPP_DIR stays empty and the installer silently falls back to the normal prebuilt/source install path. This makes a mistyped invocation behave as if the new flag was not supplied; add a post-loop check for the pending argument state, as the PowerShell installer does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Forward the local llama.cpp flag during WSL reroutes

This parser records --with-llama-cpp-dir, but the Strix Halo WSL reroute path later reconstructs _rr_args for the Ubuntu-24.04 child install without appending this option or exporting UNSLOTH_LOCAL_LLAMA_CPP_DIR. On those rerouted installs, a user-provided local llama.cpp directory is silently ignored and the child falls back to downloading/building instead of reusing the requested cache.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If --with-llama-cpp-dir is passed as the last argument to install.sh, the loop terminates with _next_is_llama_cpp_dir set to true but without assigning any value to _WITH_LLAMA_CPP_DIR. Unlike --package and --python, there is no post-loop validation to catch this, which can lead to silent failures or unexpected behavior.

Consider adding a validation check after the loop (around line 268) to ensure that _next_is_llama_cpp_dir is not left as true.

esac
done

Expand Down Expand Up @@ -255,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).
Expand Down Expand Up @@ -3023,6 +3038,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="$(CDPATH= cd -P -- "$_WITH_LLAMA_CPP_DIR" && pwd -P)"
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
_run_setup_with_studio_home env \
SKIP_STUDIO_BASE="$_SKIP_BASE" \
Expand All @@ -3031,6 +3053,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" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the env-provided local llama.cpp path

When the POSIX installer is used in the documented piped-install style with UNSLOTH_LOCAL_LLAMA_CPP_DIR=/path sh (or the variable is otherwise exported before invoking install.sh), _WITH_LLAMA_CPP_DIR remains empty unless the CLI flag is used, and this assignment shadows the caller's value with an empty string before setup.sh runs. That makes the env-var path that setup.sh now supports unreachable through install.sh, so web installs silently download/build llama.cpp instead of reusing the requested local directory.

Useful? React with 👍 / 👎.

bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
else
# Explicitly reset STUDIO_LOCAL_INSTALL / STUDIO_LOCAL_REPO so a stale
Expand All @@ -3045,6 +3068,7 @@ else
STUDIO_LOCAL_INSTALL=0 \
STUDIO_LOCAL_REPO= \
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
fi

Expand Down
26 changes: 26 additions & 0 deletions studio/backend/core/inference/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -7166,6 +7185,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
Comment on lines +7193 to +7194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude linked targets from orphan roots

When --with-llama-cpp-dir points at a checkout that is also one of the other known roots, e.g. a local/dev install reusing <repo>/llama.cpp, this guard only skips the canonical symlink/junction root. The same target is still added earlier as project_root / "llama.cpp", then resolved into resolved_roots, so an externally launched llama-server from the requested local checkout is still classified as Studio-owned and killed. Fresh evidence beyond the earlier local-link concern is that the new guard filters each root before resolving it but does not remove duplicate resolved targets already present in install_roots.

Useful? React with 👍 / 👎.

resolved_roots.append(root.resolve())
except OSError:
pass
Expand Down
137 changes: 137 additions & 0 deletions studio/backend/tests/test_local_llama_cpp_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 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:
# 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,
"_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
Loading
Loading