Surface actionable installer failures in Studio desktop (#7529)

* Studio: surface actionable installer failures

* Correct installer failure attribution

* Preserve desktop installer failure context

* Use explicit setup failure attribution

* Preserve package manager failure details
This commit is contained in:
oobabooga 2026-07-28 05:19:44 -03:00 committed by GitHub
commit 01c856c6c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 915 additions and 79 deletions

View file

@ -74,6 +74,20 @@ $script:NvccPath = $null
$script:CudaToolkitRoot = $null
$script:CudaArch = $null
function Exit-SetupFailure {
param(
[Parameter(Mandatory = $true)][string]$Message,
[int]$Code = 1
)
if ($Code -eq 0) { $Code = 1 }
if (@("1", "true") -contains $env:UNSLOTH_TAURI_MODE) {
$singleLine = ($Message -replace '[\r\n]+', ' ').Trim()
[Console]::Out.WriteLine("[TAURI:ERROR] $singleLine")
[Console]::Out.Flush()
}
exit $Code
}
# Detect if running from pip install (no frontend/ dir in studio)
$FrontendDir = Join-Path $ScriptDir "frontend"
$OxcValidatorDir = Join-Path $ScriptDir "backend\core\data_recipe\oxc-validator"
@ -851,7 +865,7 @@ function Ensure-BuildToolsForLlamaSourceBuild {
Write-Host " Manual install:" -ForegroundColor Red
Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow
Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow
exit 1
Exit-SetupFailure "Visual Studio Build Tools are required for the llama.cpp source build"
}
}
@ -1652,7 +1666,7 @@ if (-not $HasGit) {
if (-not $HasGit) {
Write-Host "[ERROR] Git is required but could not be installed automatically." -ForegroundColor Red
Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red
exit 1
Exit-SetupFailure "Git is required but could not be installed automatically"
}
step "git" "$(git --version)"
} else {
@ -1821,7 +1835,7 @@ if (-not $NvccPath -and $IncompatibleToolkit) {
Write-Host "========================================================================" -ForegroundColor Red
Write-Host "[ERROR] CUDA source build cannot use the installed toolkit with this driver." -ForegroundColor Red
Write-Host "========================================================================" -ForegroundColor Red
exit 1
Exit-SetupFailure "The installed CUDA toolkit is incompatible with the current driver"
}
# -- No toolkit at all: install via winget (only when a source build needs it) --
@ -1893,7 +1907,7 @@ if (-not $NvccPath) {
} else {
Write-Host " Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads" -ForegroundColor Yellow
}
exit 1
Exit-SetupFailure "A compatible CUDA Toolkit could not be found or installed"
}
# -- Set CUDA env vars so cmake AND MSBuild can find the toolkit --
@ -2043,7 +2057,7 @@ if (-not $IsPipInstall) {
if (-not (Test-Path -LiteralPath $NodeOverride -PathType Container)) {
Write-Host "ERROR: UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist." -ForegroundColor Red
Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
exit 1
Exit-SetupFailure "UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist"
}
$NodeParent = (Resolve-Path -LiteralPath $NodeOverride).Path
# An override pointing at the legacy default maps to the legacy sibling
@ -2227,7 +2241,7 @@ if ($PythonOk) {
if (-not $HasPython) {
Write-Host "[ERROR] Python could not be installed automatically." -ForegroundColor Red
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
exit 1
Exit-SetupFailure "Python could not be installed automatically"
}
step "python" "$(python --version 2>&1)"
$PythonOk = $true
@ -2237,7 +2251,7 @@ if ($PythonOk) {
Write-Host "[ERROR] No supported Python (3.11-3.13) found on this system." -ForegroundColor Red
Write-Host " py.exe could not locate -3.11/-3.12/-3.13 and `python` on PATH is unsupported." -ForegroundColor Yellow
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
exit 1
Exit-SetupFailure "No supported Python 3.11-3.13 was found"
}
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).
@ -2319,7 +2333,7 @@ if ($NeedNodeForSetup) {
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
Write-Host "[ERROR] $NodeDir already exists and is not an Unsloth-owned Node install." -ForegroundColor Red
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
exit 1
Exit-SetupFailure "$NodeDir is not an Unsloth-owned Node install"
}
}
substep "installing isolated Node (system Node/npm left untouched)..."
@ -2331,12 +2345,12 @@ if ($NeedNodeForSetup) {
if ($nodeExit -eq 3) {
Write-Host $nodeOut -ForegroundColor DarkGray
step "node" "install blocked by another active Unsloth install" "Red"
exit 3
Exit-SetupFailure "Node install is blocked by another active Unsloth install" 3
} elseif ($nodeExit -ne 0) {
Write-Host $nodeOut -ForegroundColor DarkGray
Write-Host "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red
Write-Host " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
exit 1
Exit-SetupFailure "Could not install an isolated Node runtime"
}
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
@ -2456,7 +2470,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
Write-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red
Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow
Show-NpmRegistryHint
exit 1
Exit-SetupFailure "Frontend dependency installation failed (exit code $npmExit)"
}
}
@ -2467,7 +2481,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
$ErrorActionPreference = $prevEAP_npm
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
Write-Host "[ERROR] npm run build failed (exit code $buildExit)" -ForegroundColor Red
exit 1
Exit-SetupFailure "Frontend build failed (exit code $buildExit)"
}
Pop-Location
$ErrorActionPreference = $prevEAP_npm
@ -2498,7 +2512,7 @@ if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command n
$ErrorActionPreference = $prevEAP_oxc
Write-Host "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red
Show-NpmRegistryHint
exit 1
Exit-SetupFailure "OXC validator dependency installation failed (exit code $oxcInstallExit)"
}
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
@ -2597,7 +2611,7 @@ if (-not $PythonCmd) {
Write-Host "[ERROR] No standalone Python 3.11-3.13 found (conda Python is not supported)." -ForegroundColor Red
Write-Host " Install Python from https://python.org/downloads/ or via:" -ForegroundColor Yellow
Write-Host " winget install -e --id Python.Python.3.12" -ForegroundColor Yellow
exit 1
Exit-SetupFailure "No standalone Python 3.11-3.13 was found"
}
substep "Python found: $PythonCmd"
@ -2630,12 +2644,12 @@ if ($_studioOverride) {
Remove-Item -LiteralPath $_setupWriteProbe -Force -ErrorAction SilentlyContinue
} catch {
Write-Host "ERROR: $_studioOverrideVar=$StudioHome is not writable." -ForegroundColor Red
exit 1
Exit-SetupFailure "$_studioOverrideVar=$StudioHome is not writable"
}
} else {
Write-Host "ERROR: $_studioOverrideVar=$_studioOverride does not exist." -ForegroundColor Red
Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
exit 1
Exit-SetupFailure "$_studioOverrideVar=$_studioOverride does not exist"
}
} else {
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
@ -2679,7 +2693,7 @@ function Assert-StudioOwnedOrAbsent {
}
Write-Host "[ERROR] $Path already exists and is not marked as an Unsloth-owned $Label." -ForegroundColor Red
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
exit 1
Exit-SetupFailure "$Label path is not an Unsloth-owned install: $Path"
}
}
function Mark-StudioOwned {
@ -2815,7 +2829,7 @@ if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode
substep "Stale venv detected ($reason)." "Yellow"
Write-Host " [ERROR] The existing Unsloth environment needs repair." -ForegroundColor Red
Write-Host " Re-run install.ps1 so it can replace the environment safely with rollback." -ForegroundColor Yellow
exit 1
Exit-SetupFailure "The existing Unsloth environment needs repair"
}
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
# why: mirror install.ps1 env-mode guard so an update against a custom
@ -2829,14 +2843,14 @@ if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode
) {
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
exit 1
Exit-SetupFailure "$VenvDir is not an Unsloth Studio environment"
}
try {
Remove-Item -LiteralPath $VenvDir -Recurse -Force -ErrorAction Stop
} catch {
Write-Host " [ERROR] Could not remove stale venv: $($_.Exception.Message)" -ForegroundColor Red
Write-Host " Close any running Unsloth/Python processes and re-run setup." -ForegroundColor Red
exit 1
Exit-SetupFailure "Could not remove the stale environment at $VenvDir"
}
}
}
@ -2845,7 +2859,7 @@ if (-not (Test-Path -LiteralPath $VenvDir)) {
Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red
Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow
Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
exit 1
Exit-SetupFailure "Virtual environment not found at $VenvDir"
} else {
substep "reusing existing virtual environment at $VenvDir"
$_venvPyExe = Join-Path $VenvDir "Scripts\python.exe"
@ -3216,7 +3230,7 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) {
if ($torchInstallExit -ne 0) {
Write-Host "[FAILED] PyTorch install failed (exit code $torchInstallExit)" -ForegroundColor Red
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
exit 1
Exit-SetupFailure "PyTorch installation failed (exit code $torchInstallExit)"
}
} elseif (-not $ROCmIndexUrl) {
substep "installing PyTorch with CUDA support ($CuTag)..."
@ -3248,7 +3262,7 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) {
if ($torchInstallExit -ne 0) {
Write-Host "[FAILED] PyTorch CUDA install failed (exit code $torchInstallExit)" -ForegroundColor Red
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
exit 1
Exit-SetupFailure "PyTorch CUDA installation failed (exit code $torchInstallExit)"
}
# Install Triton for Windows (enables torch.compile -- without it training can hang)
@ -3284,7 +3298,7 @@ $ErrorActionPreference = $prevEAP
if ($stackExit -ne 0) {
Write-Host "[FAILED] Python dependency installation failed (exit code $stackExit)" -ForegroundColor Red
Write-Host " Re-run the installer or check the error above for details." -ForegroundColor Red
exit 1
Exit-SetupFailure "Python dependency installation failed (exit code $stackExit)"
}
} else {
@ -3362,7 +3376,7 @@ foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.8.0", "hf_xet==1.4
Write-Host "[FAIL] Could not install $pkg into .venv_t5_530/" -ForegroundColor Red
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
exit 1
Exit-SetupFailure "Could not install $pkg into .venv_t5_530"
}
}
if ($script:UnslothVerbose) {
@ -3397,7 +3411,7 @@ foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4
Write-Host "[FAIL] Could not install $pkg into .venv_t5_550/" -ForegroundColor Red
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
exit 1
Exit-SetupFailure "Could not install $pkg into .venv_t5_550"
}
}
if ($script:UnslothVerbose) {
@ -3432,7 +3446,7 @@ foreach ($pkg in @("transformers==5.10.2", "huggingface_hub==1.8.0", "hf_xet==1.
Write-Host "[FAIL] Could not install $pkg into .venv_t5_510/" -ForegroundColor Red
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
$ErrorActionPreference = $prevEAP_t5
exit 1
Exit-SetupFailure "Could not install $pkg into .venv_t5_510"
}
}
if ($script:UnslothVerbose) {
@ -3547,7 +3561,7 @@ if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int
if ($LlamaPr) {
if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) {
Write-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red
exit 1
Exit-SetupFailure "UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number"
}
step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow"
$ResolvedLlamaTag = "pr-$LlamaPr"
@ -3563,7 +3577,7 @@ $LocalLlamaCppSrc = $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR
if ($LocalLlamaCppSrc) {
if (-not (Test-Path -LiteralPath $LocalLlamaCppSrc -PathType Container)) {
step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $LocalLlamaCppSrc" "Red"
exit 1
Exit-SetupFailure "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $LocalLlamaCppSrc"
}
$ResolvedLocal = (Resolve-Path -LiteralPath $LocalLlamaCppSrc).Path
# Reusing a local dir disables both the prebuilt download and the source
@ -3594,7 +3608,7 @@ if ($LocalLlamaCppSrc) {
# and leave Unsloth with no usable binary.
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
Exit-SetupFailure "No llama-server.exe was found under $ResolvedLocal"
}
# If the target is already a junction/symlink (e.g. a previous
# --with-llama-cpp-dir run), delete only the link via DirectoryInfo.Delete().
@ -3620,7 +3634,7 @@ if ($LocalLlamaCppSrc) {
if (Test-Path -LiteralPath $LlamaCppDir) {
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
substep "Close Unsloth or other llama.cpp users and retry" "Yellow"
exit 3
Exit-SetupFailure "llama.cpp install is blocked by an active llama.cpp process" 3
}
}
cmd /c "mklink /J `"$LlamaCppDir`" `"$ResolvedLocal`"" 2>&1 | Out-Null
@ -3762,7 +3776,7 @@ if ($LocalLlamaCppLinked) {
substep "Existing install was restored" "Yellow"
}
substep "Close Unsloth or other llama.cpp users and retry" "Yellow"
exit 3
Exit-SetupFailure "llama.cpp install is blocked by an active llama.cpp process" 3
} elseif ($prebuiltExit -eq 4) {
step "llama.cpp" "not enough disk space to install llama.cpp" "Yellow"
Write-LlamaFailureLog -Output $prebuiltOutput
@ -4025,7 +4039,7 @@ if ($LocalLlamaCppLinked) {
} else {
Write-Host "[ERROR] CMake 4.2+ is required to build llama.cpp with the Visual Studio 2026 generator, and no older Visual Studio toolchain was found to fall back to." -ForegroundColor Red
Write-Host " Upgrade CMake from https://cmake.org/download/ and re-run, or use a prebuilt llama.cpp bundle." -ForegroundColor Red
exit 1
Exit-SetupFailure "CMake cannot drive the Visual Studio 2026 generator"
}
}
}
@ -4532,5 +4546,5 @@ Write-Host ""
# failure. Direct 'unsloth studio update' does not set SKIP_STUDIO_BASE,
# so it keeps degraded installs successful.
if ($script:LlamaCppDegraded -and $env:SKIP_STUDIO_BASE -eq "1") {
exit 1
Exit-SetupFailure "llama.cpp setup did not produce a usable server"
}

View file

@ -67,6 +67,18 @@ fi
step() { printf " ${C_DIM}%-15.15s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
substep() { printf " %-15s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
setup_fail() {
local exit_code=$1
shift
[ "$exit_code" -ne 0 ] || exit_code=1
local message
message=$(printf '%s' "$*" | tr '\r\n' ' ')
case "${UNSLOTH_TAURI_MODE:-0}" in
1|true) printf '[TAURI:ERROR] %s\n' "$message" ;;
esac
exit "$exit_code"
}
# ── Helper: can the controlling terminal actually be opened for reading? ──
# `test -r` only checks permission bits, which look fine in containers and
# systemd units where open() then fails with ENXIO. Probe with a real open.
@ -173,7 +185,7 @@ _run_quiet() {
exit_code=$?
step "error" "$label failed (exit code $exit_code)" "$C_ERR" >&2
if [ "$on_fail" = "exit" ]; then
exit "$exit_code"
setup_fail "$exit_code" "$label failed (exit code $exit_code)"
else
return "$exit_code"
fi
@ -182,7 +194,10 @@ _run_quiet() {
local tmplog
tmplog=$(mktemp) || {
step "error" "Failed to create temporary file" "$C_ERR" >&2
[ "$on_fail" = "exit" ] && exit 1 || return 1
if [ "$on_fail" = "exit" ]; then
setup_fail 1 "Failed to create temporary file for $label"
fi
return 1
}
if "$@" >"$tmplog" 2>&1; then
@ -196,7 +211,7 @@ _run_quiet() {
rm -f "$tmplog"
if [ "$on_fail" = "exit" ]; then
exit "$exit_code"
setup_fail "$exit_code" "$label failed (exit code $exit_code)"
else
return "$exit_code"
fi
@ -549,10 +564,14 @@ if [ -n "$_studio_override" ]; then
if [ ! -d "$_studio_override" ]; then
echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2
echo " Run install.sh to create the install root before 'unsloth studio update'." >&2
exit 1
setup_fail 1 "$_studio_override_var=$_studio_override does not exist"
fi
[ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; }
STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1
if [ ! -w "$_studio_override" ]; then
echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2
setup_fail 1 "$_studio_override_var=$_studio_override is not writable"
fi
STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" ||
setup_fail 1 "Could not resolve $_studio_override_var=$_studio_override"
else
STUDIO_HOME="$HOME/.unsloth/studio"
fi
@ -598,7 +617,7 @@ _assert_studio_owned_or_absent() {
fi
echo "ERROR: $_aso_dir already exists and is not marked as an Unsloth-owned $_aso_label." >&2
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
exit 1
setup_fail 1 "$_aso_label path is not an Unsloth-owned install: $_aso_dir"
fi
}
@ -716,12 +735,12 @@ elif [ "$NODE_SOURCE" = bundled ]; then
step "node" "install blocked by another active Unsloth install" "$C_ERR"
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
substep "close other Unsloth installs and retry"
exit 3
setup_fail 3 "Node install is blocked by another active Unsloth install"
elif [ "$_NODE_STATUS" -ne 0 ]; then
step "node" "isolated Node install failed" "$C_ERR"
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
substep "install Node >= 20.19 (with npm >= 11) yourself and re-run, or check your network"
exit 1
setup_fail 1 "Could not install an isolated Node runtime"
fi
grep -Fq "already matches" "$_NODE_LOG" && verbose_substep "isolated Node already up to date"
rm -f "$_NODE_LOG"
@ -854,7 +873,7 @@ if [ "$_bun_install_ok" = false ]; then
if [ "$_npm_install_rc" -ne 0 ]; then
_suggest_npm_registry "$_FRONTEND_INSTALL_LOG"
rm -f "$_FRONTEND_INSTALL_LOG"
exit "$_npm_install_rc"
setup_fail "$_npm_install_rc" "Frontend dependency installation failed (exit code $_npm_install_rc)"
fi
fi
_CAPTURE_LOG=""
@ -894,7 +913,7 @@ if [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ] && command -v npm &>/dev
if [ "$_oxc_install_rc" -ne 0 ]; then
_suggest_npm_registry "$_OXC_INSTALL_LOG"
rm -f "$_OXC_INSTALL_LOG"
exit "$_oxc_install_rc"
setup_fail "$_oxc_install_rc" "OXC validator dependency installation failed (exit code $_oxc_install_rc)"
fi
rm -f "$_OXC_INSTALL_LOG"
cd "$SCRIPT_DIR"
@ -932,7 +951,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then
if ! run_quiet_no_exit "install Colab backend deps" pip install -q -r "$_COLAB_REQS_TMP"; then
rm -f "$_COLAB_REQS_TMP"
step "python" "Colab backend dependency install failed" "$C_ERR"
exit 1
setup_fail 1 "Colab backend dependency installation failed"
fi
else
step "python" "no Colab backend dependencies resolved from requirements file" "$C_WARN"
@ -943,7 +962,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then
step "python" "venv not found at $VENV_DIR" "$C_ERR"
substep "Run install.sh first to create the environment:"
substep "curl -fsSL https://unsloth.ai/install.sh | sh"
exit 1
setup_fail 1 "Virtual environment not found at $VENV_DIR"
fi
else
source "$VENV_DIR/bin/activate"
@ -1277,7 +1296,7 @@ fi
if [ -n "$_LLAMA_PR" ]; then
if ! [[ "$_LLAMA_PR" =~ ^[0-9]+$ ]] || [ "$_LLAMA_PR" -le 0 ]; then
step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR is not a valid PR number" "$C_ERR"
exit 1
setup_fail 1 "UNSLOTH_LLAMA_PR=$_LLAMA_PR is not a valid PR number"
fi
step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR -- will build from PR head" "$C_WARN"
_RESOLVED_LLAMA_TAG="pr-$_LLAMA_PR"
@ -1313,7 +1332,7 @@ _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
setup_fail 1 "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR"
fi
_RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd -P)"
# Canonicalize the install path the same way before comparing: _RESOLVED_LOCAL
@ -1351,7 +1370,7 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then
# 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
setup_fail 1 "No llama-server was found under $_RESOLVED_LOCAL"
fi
# A stale link from a previous --with-llama-cpp-dir run isn't Unsloth-owned
# content; drop it before the ownership check so re-runs stay idempotent
@ -1460,7 +1479,7 @@ else
substep "existing install was restored"
fi
substep "close Unsloth or other llama.cpp users and retry"
exit 3
setup_fail 3 "llama.cpp install is blocked by an active llama.cpp process"
elif [ "$_PREBUILT_STATUS" -eq 4 ]; then
step "llama.cpp" "not enough disk space to install llama.cpp" "$C_WARN"
print_llama_error_log "$_PREBUILT_LOG"
@ -2217,5 +2236,5 @@ echo ""
# successful -- the footer above already reports the limitation and Unsloth
# is still usable for non-GGUF workflows.
if [ "$_LLAMA_CPP_DEGRADED" = true ] && [ "${SKIP_STUDIO_BASE:-0}" = "1" ]; then
exit 1
setup_fail 1 "llama.cpp setup did not produce a usable server"
fi

View file

@ -31,6 +31,10 @@ pub const TAIL_MAX_LINES: usize = 1000;
pub const TAIL_MAX_BYTES: usize = 200 * 1024;
pub const REPORT_MAX_BYTES: usize = 1024 * 1024;
pub(crate) fn redact_for_display(text: &str) -> String {
redaction::redact_text(text, &mut redaction::RedactionReport::default())
}
pub(crate) const MAX_STATE_ITEMS: usize = 200;
pub(crate) const MAX_PHASE_LINE_BYTES: usize = 16 * 1024;
pub(crate) const FOOTER_BUDGET_BYTES: usize = 8 * 1024;

View file

@ -16,6 +16,8 @@ pub(crate) fn redact_text(text: &str, report: &mut RedactionReport) -> String {
}
out = replace_regex(private_key_re(), &out, "<redacted private key>", report);
out = replace_regex(url_credentials_re(), &out, "$1<redacted>@", report);
out = replace_regex(url_query_value_re(), &out, "$1=<redacted>", report);
out = replace_regex(url_fragment_re(), &out, "$1#<redacted>", report);
out = replace_regex(auth_header_re(), &out, "$1: <redacted>", report);
out = replace_regex(cookie_re(), &out, "$1: <redacted>", report);
out = replace_regex(token_re(), &out, "<redacted token>", report);
@ -110,6 +112,16 @@ fn url_credentials_re() -> &'static Regex {
RE.get_or_init(|| Regex::new(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+(:[^/\s@]*)?@").unwrap())
}
fn url_query_value_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"([?&][^=\s&`]+)=[^&#\s`]+").unwrap())
}
fn url_fragment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)(https?://[^\s`#]+)#[^\s`]+").unwrap())
}
fn auth_header_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(authorization|proxy-authorization)\s*[:=]\s*(bearer|basic)?\s*[A-Za-z0-9._~+/=-]+").unwrap())
@ -183,6 +195,7 @@ mod tests {
"API_KEY=secret123\n",
"native_path_lease=abc.DEF_123\n",
"url=https://user:pass@example.com/path\n",
"signed=https://example.com/object?X-Amz-Signature=presignedvalue987&version=1#fragmentsecret\n",
"email=alex@example.com\n",
"path=/Users/alex/.unsloth/studio/logs/install.log\n",
"win=C:\\Users\\Alex\\.unsloth\\studio\\logs\\install.log\n",
@ -198,6 +211,9 @@ mod tests {
assert!(!redacted.contains("abc.DEF_123"));
assert!(redacted.contains("native_path_lease=<redacted native path lease>"));
assert!(redacted.contains("https://<redacted>@example.com/path"));
assert!(!redacted.contains("presignedvalue987"));
assert!(!redacted.contains("fragmentsecret"));
assert!(redacted.contains("?X-Amz-Signature=<redacted>&version=<redacted>#<redacted>"));
assert!(!redacted.contains("alex@example.com"));
assert!(redacted.contains("<studio_home>"));
assert!(!redacted.contains("PRIVATE KEY-----\nabc"));

View file

@ -1,6 +1,7 @@
use crate::diagnostics::{self, AttemptLog, DiagnosticsState};
use log::{error, info, warn};
use process_wrap::std::*;
use std::collections::VecDeque;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
@ -38,6 +39,160 @@ pub fn new_install_state() -> InstallState {
use crate::process::trim_line_endings;
const FAILURE_CONTEXT_LINES: usize = 8;
const FAILURE_CONTEXT_LINE_BYTES: usize = 1_000;
fn generic_failure_message(code: i32) -> String {
format!(
"Installation failed with exit code {}. Open the installer logs for details.",
code
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InstallOutputStream {
Stdout,
Stderr,
}
struct InstallOutputLine {
stream: InstallOutputStream,
text: String,
}
#[derive(Default)]
struct InstallFailureContext {
explicit_error: Option<String>,
explicit_error_stream: Option<InstallOutputStream>,
default_error: Option<String>,
output_tail: VecDeque<InstallOutputLine>,
}
impl InstallFailureContext {
fn observe_stdout(&mut self, text: &str) -> bool {
if text.starts_with("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stdout);
return true;
}
if text.starts_with("[TAURI:OUTPUT_CLEAR] ") {
self.clear_stream(InstallOutputStream::Stdout);
return true;
}
if let Some(message) = text.strip_prefix("[TAURI:ERROR] ") {
let message = message.trim();
if !message.is_empty() {
self.explicit_error = Some(Self::bounded_line(message));
self.explicit_error_stream = Some(InstallOutputStream::Stdout);
}
return false;
}
if let Some(message) = text.strip_prefix("[TAURI:ERROR_OUTPUT] ") {
self.capture_output_error(InstallOutputStream::Stdout, message);
return true;
}
if let Some(message) = text.strip_prefix("[TAURI:ERROR_DEFAULT] ") {
let message = message.trim();
if !message.is_empty() {
self.default_error = Some(Self::bounded_line(message));
}
return true;
}
if !text.starts_with("[TAURI:") {
self.push_output(InstallOutputStream::Stdout, text);
}
false
}
fn observe_stderr(&mut self, text: &str) -> bool {
if text.starts_with("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stderr);
return true;
}
if text.starts_with("[TAURI:OUTPUT_CLEAR] ") {
self.clear_stream(InstallOutputStream::Stderr);
return true;
}
if let Some(message) = text.strip_prefix("[TAURI:ERROR_OUTPUT] ") {
self.capture_output_error(InstallOutputStream::Stderr, message);
return true;
}
self.push_output(InstallOutputStream::Stderr, text);
false
}
fn capture_output_error(&mut self, stream: InstallOutputStream, fallback: &str) {
let fallback = fallback.trim();
let detail = self
.output_tail
.iter()
.rev()
.find(|line| line.stream == stream)
.map(|line| line.text.as_str());
if let Some(error) = match (fallback.is_empty(), detail) {
(_, Some(detail)) if fallback == detail => Some(detail.to_owned()),
(false, Some(detail)) => Some(Self::bounded_line(&format!("{fallback}: {detail}"))),
(false, None) => Some(Self::bounded_line(fallback)),
(true, Some(detail)) => Some(detail.to_owned()),
(true, None) => None,
} {
self.explicit_error = Some(error);
self.explicit_error_stream = Some(stream);
}
}
fn clear_failure(&mut self, stream: InstallOutputStream) {
if self.explicit_error_stream == Some(stream) {
self.explicit_error = None;
self.explicit_error_stream = None;
}
if stream == InstallOutputStream::Stdout {
self.default_error = None;
}
self.clear_stream(stream);
}
fn clear_stream(&mut self, stream: InstallOutputStream) {
self.output_tail.retain(|line| line.stream != stream);
}
fn push_output(&mut self, stream: InstallOutputStream, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
let text = Self::bounded_line(text);
self.output_tail
.push_back(InstallOutputLine { stream, text });
while self.output_tail.len() > FAILURE_CONTEXT_LINES {
self.output_tail.pop_front();
}
}
fn bounded_line(text: &str) -> String {
let mut text = diagnostics::redact_for_display(text);
let boundary =
diagnostics::valid_utf8_boundary(&text, text.len().min(FAILURE_CONTEXT_LINE_BYTES));
text.truncate(boundary);
text
}
fn message(&self, code: i32) -> String {
let detail = self
.explicit_error
.as_deref()
.or(self.default_error.as_deref())
.or_else(|| self.output_tail.back().map(|line| line.text.as_str()));
match detail {
Some(detail) => format!("Installation failed: {}", detail),
None => generic_failure_message(code),
}
}
}
fn is_elevation_request(code: i32, packages: &[String]) -> bool {
code == 2 && !packages.is_empty()
}
// ── Script Resolution ──
/// Returns (script_path, args) depending on dev vs production mode.
@ -232,7 +387,7 @@ fn spawn_script(
// ── Stream ──
/// Spawns reader threads for stdout/stderr.
/// Parses [TAURI:*] lines from stdout for structured events.
/// Parses structured events from stdout and failure controls from both streams.
fn stream_output(
app: &AppHandle,
state: &InstallState,
@ -241,14 +396,19 @@ fn stream_output(
attempt: AttemptLog,
stdout: Option<std::process::ChildStdout>,
stderr: Option<std::process::ChildStderr>,
) -> Vec<std::thread::JoinHandle<()>> {
) -> (
Vec<std::thread::JoinHandle<()>>,
Arc<Mutex<InstallFailureContext>>,
) {
let mut threads = Vec::new();
let failure_context = Arc::new(Mutex::new(InstallFailureContext::default()));
if let Some(out) = stdout {
let app_clone = app.clone();
let state_clone = Arc::clone(state);
let diagnostics_clone = diagnostics.clone();
let attempt_clone = attempt.clone();
let failure_context_clone = Arc::clone(&failure_context);
threads.push(std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(out);
let mut buf = Vec::new();
@ -259,6 +419,14 @@ fn stream_output(
Ok(_) => {
let text = String::from_utf8_lossy(trim_line_endings(&buf)).into_owned();
diagnostics::append_phase_line(&attempt_clone.handle, "stdout", &text);
let is_failure_control = failure_context_clone
.lock()
.map(|mut context| context.observe_stdout(&text))
.unwrap_or(false);
if is_failure_control {
info!("[install][stdout] {}", text);
continue;
}
// Parse structured Tauri protocol lines
if let Some(packages) = text.strip_prefix("[TAURI:NEED_SUDO] ") {
let pkgs: Vec<String> =
@ -314,6 +482,7 @@ fn stream_output(
if let Some(err) = stderr {
let app_clone = app.clone();
let attempt_clone = attempt.clone();
let failure_context_clone = Arc::clone(&failure_context);
threads.push(std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(err);
let mut buf = Vec::new();
@ -324,6 +493,14 @@ fn stream_output(
Ok(_) => {
let text = String::from_utf8_lossy(trim_line_endings(&buf)).into_owned();
diagnostics::append_phase_line(&attempt_clone.handle, "stderr", &text);
let is_failure_control = failure_context_clone
.lock()
.map(|mut context| context.observe_stderr(&text))
.unwrap_or(false);
if is_failure_control {
info!("[install][stderr] {}", text);
continue;
}
warn!("[install][stderr] {}", text);
let _ = app_clone.emit(event_mode.progress_event(), &text);
}
@ -336,7 +513,7 @@ fn stream_output(
}));
}
threads
(threads, failure_context)
}
// ── Wait & Finalize ──
@ -457,7 +634,7 @@ fn run_install_with_event_mode(
return Err(msg);
}
};
let threads = stream_output(
let (threads, failure_context) = stream_output(
&app,
&state,
event_mode,
@ -490,12 +667,12 @@ fn run_install_with_event_mode(
}
Ok((status, intentional)) => {
let code = status.code().unwrap_or(-1);
if code == 2 {
let packages = state
.lock()
.map(|install| install.needed_packages.clone())
.unwrap_or_default();
if is_elevation_request(code, &packages) {
// Script needs elevated package install — report to frontend
let packages = state
.lock()
.map(|i| i.needed_packages.clone())
.unwrap_or_default();
diagnostics::record_elevation_packages(&diagnostics, &attempt, &packages);
diagnostics::finish_attempt(
&diagnostics,
@ -508,7 +685,10 @@ fn run_install_with_event_mode(
let _ = app.emit(event_mode.needs_elevation_event(), &packages);
Err("NEEDS_ELEVATION".to_string())
} else {
let msg = format!("Installer exited with code {}", code);
let msg = failure_context
.lock()
.map(|context| context.message(code))
.unwrap_or_else(|_| generic_failure_message(code));
diagnostics::finish_attempt(
&diagnostics,
&attempt,
@ -896,5 +1076,211 @@ mod tests {
"repair-needs-elevation"
);
assert!(!InstallEventMode::Repair.emit_terminal_events());
assert!(!is_elevation_request(2, &[]));
assert!(is_elevation_request(2, &["cmake".to_string()]));
assert!(!is_elevation_request(1, &["cmake".to_string()]));
}
#[test]
fn explicit_installer_error_beats_stderr_noise() {
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
context.observe_stderr("rollback cleanup failed");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
}
#[test]
fn command_error_includes_preceding_output_from_the_same_stream() {
let mut context = InstallFailureContext::default();
context.observe_stdout("unrelated stdout");
context.observe_stderr("resolver error: no space left on device");
assert!(context.observe_stderr("[TAURI:ERROR_OUTPUT] install unsloth failed (exit code 1)"));
context.observe_stdout("[TAURI:ERROR_DEFAULT] Failed to install unsloth");
assert_eq!(
context.message(1),
"Installation failed: install unsloth failed (exit code 1): resolver error: no space left on device"
);
}
#[test]
fn command_error_without_output_uses_its_fallback() {
let mut context = InstallFailureContext::default();
context.observe_stdout("unrelated output from an earlier step");
assert!(context.observe_stdout("[TAURI:OUTPUT_CLEAR] create venv"));
assert!(context.observe_stdout("[TAURI:ERROR_OUTPUT] create venv failed (exit code 2)"));
assert_eq!(
context.message(2),
"Installation failed: create venv failed (exit code 2)"
);
}
#[test]
fn recovered_retry_clears_stale_installer_error() {
let mut context = InstallFailureContext::default();
context.observe_stderr("ERROR: transient PyTorch download failure");
assert!(context.observe_stderr("[TAURI:ERROR_OUTPUT] install PyTorch failed (exit code 1)"));
assert!(context.observe_stdout("[TAURI:ERROR_CLEAR] install PyTorch recovered after retry"));
assert!(context.observe_stderr("[TAURI:ERROR_CLEAR] install PyTorch recovered after retry"));
context.observe_stderr("ERROR: studio setup failed");
let message = context.message(7);
assert!(message.contains("studio setup failed"));
assert!(!message.contains("install PyTorch"));
assert!(!message.contains("transient PyTorch"));
}
#[test]
fn recovery_clear_is_order_independent_across_streams() {
let mut context = InstallFailureContext::default();
assert!(context.observe_stdout("[TAURI:ERROR_CLEAR] install PyTorch recovered"));
context.observe_stderr("ERROR: transient PyTorch download failure");
assert!(context.observe_stderr("[TAURI:ERROR_OUTPUT] install PyTorch failed (exit code 1)"));
assert!(context.observe_stderr("[TAURI:ERROR_CLEAR] install PyTorch recovered"));
context.observe_stdout("[TAURI:ERROR] later setup failure");
assert!(context.observe_stderr("[TAURI:ERROR_CLEAR] delayed recovery clear"));
assert_eq!(
context.message(1),
"Installation failed: later setup failure"
);
}
#[test]
fn successful_fallback_clears_unstructured_stderr() {
let mut context = InstallFailureContext::default();
context.observe_stderr("bitsandbytes pre-release install failed");
assert!(context.observe_stderr("[TAURI:ERROR_CLEAR] bitsandbytes pypi fallback recovered"));
context.observe_stderr("mkdir: cannot create directory: Permission denied");
assert_eq!(
context.message(1),
"Installation failed: mkdir: cannot create directory: Permission denied"
);
}
#[test]
fn setup_failure_uses_explicit_producer_error_before_default() {
let mut context = InstallFailureContext::default();
context.observe_stdout("CMake not found -- installing via winget");
context.observe_stdout("[TAURI:ERROR] UNSLOTH_LLAMA_PR=invalid is not a valid PR number");
assert!(context.observe_stdout("[TAURI:ERROR_DEFAULT] studio setup failed (exit code 4)"));
assert_eq!(
context.message(4),
"Installation failed: UNSLOTH_LLAMA_PR=invalid is not a valid PR number"
);
}
#[test]
fn setup_failure_uses_default_without_specific_output() {
let mut context = InstallFailureContext::default();
context.observe_stdout("Finishing setup");
assert!(context.observe_stdout("[TAURI:ERROR_DEFAULT] studio setup failed (exit code 4)"));
context.observe_stderr("restored previous environment");
assert_eq!(
context.message(4),
"Installation failed: studio setup failed (exit code 4)"
);
}
#[test]
fn explicit_setup_error_survives_optional_output_and_footer() {
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:ERROR] llama.cpp setup did not produce a usable server");
context.observe_stderr("whisper.cpp source build failed (exit code 1)");
context.observe_stdout(
"whisper.cpp prebuilt install failed; browser and Transformers dictation remain available",
);
for index in 0..10 {
context.observe_stdout(&format!("setup footer line {index}"));
}
assert!(context.observe_stdout("[TAURI:ERROR_DEFAULT] studio setup failed (exit code 1)"));
assert_eq!(
context.message(1),
"Installation failed: llama.cpp setup did not produce a usable server"
);
}
#[test]
fn setup_default_outranks_nonfatal_failure_output() {
let mut context = InstallFailureContext::default();
context.observe_stdout("long paths failed to enable");
context.observe_stderr("Triton install failed; torch.compile may not work");
assert!(context.observe_stdout("[TAURI:ERROR_DEFAULT] studio setup failed (exit code 3)"));
assert_eq!(
context.message(3),
"Installation failed: studio setup failed (exit code 3)"
);
}
#[test]
fn latest_output_is_used_without_structured_context() {
let mut context = InstallFailureContext::default();
context.observe_stderr("first diagnostic");
context.observe_stderr("mv: cannot move build: Permission denied");
assert_eq!(
context.message(1),
"Installation failed: mv: cannot move build: Permission denied"
);
}
#[test]
fn structured_rollback_progress_does_not_replace_failure_output() {
let mut context = InstallFailureContext::default();
context.observe_stderr("ln: cannot create symbolic link: Permission denied");
context.observe_stdout(
"[TAURI:PROGRESS] restoring previous environment after failed install...",
);
context.observe_stdout("[TAURI:PROGRESS] restored previous environment");
assert_eq!(
context.message(1),
"Installation failed: ln: cannot create symbolic link: Permission denied"
);
}
#[test]
fn installer_exit_code_is_not_duplicated() {
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch (exit code 7)");
let message = context.message(7);
assert_eq!(
message,
"Installation failed: Failed to install PyTorch (exit code 7)"
);
assert_eq!(message.matches("exit code 7").count(), 1);
}
#[test]
fn stderr_fallback_redacts_secrets() {
let mut context = InstallFailureContext::default();
context.observe_stderr("ERROR: download failed for https://user:pass@example.com/package");
let message = context.message(1);
assert!(message.contains("ERROR: download failed"));
assert!(message.contains("https://<redacted>@example.com/package"));
assert!(!message.contains("user:pass"));
}
#[test]
fn failure_context_is_bounded_and_utf8_safe() {
let mut context = InstallFailureContext::default();
context.observe_stdout(&format!(
"[TAURI:ERROR] {}https://user:secret@example.com/package",
"é".repeat(500)
));
for index in 0..20 {
context.observe_stderr(&format!("{index}: {}", "é".repeat(1_000)));
}
let explicit_error = context.explicit_error.as_ref().unwrap();
assert!(explicit_error.len() <= FAILURE_CONTEXT_LINE_BYTES);
assert!(explicit_error.is_char_boundary(explicit_error.len()));
assert!(!explicit_error.contains("secret"));
assert_eq!(context.output_tail.len(), FAILURE_CONTEXT_LINES);
assert!(context
.output_tail
.iter()
.all(|line| line.text.len() <= FAILURE_CONTEXT_LINE_BYTES));
assert!(context
.output_tail
.iter()
.all(|line| line.text.is_char_boundary(line.text.len())));
}
}