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:
parent
ba512f69e4
commit
01c856c6c5
10 changed files with 915 additions and 79 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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())));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue