diff --git a/install.ps1 b/install.ps1 index a2aff0b69a..0b06cb3ea1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -28,6 +28,14 @@ function Install-UnslothStudio { } } + function Clear-TauriInstallError { + param([string]$Message) + if ($TauriMode) { + Write-TauriLog "ERROR_CLEAR" $Message + [Console]::Error.WriteLine("[TAURI:ERROR_CLEAR] $Message") + } + } + function Format-TauriDiagBool { param([bool]$Value) if ($Value) { return "true" } @@ -86,7 +94,7 @@ function Install-UnslothStudio { [int]$Code = 1 ) if ($Code -eq 0) { $Code = 1 } - Write-TauriLog "ERROR" $Message + Write-TauriLog "ERROR_DEFAULT" $Message if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) { Restore-StudioVenvRollback } @@ -485,7 +493,8 @@ function Install-UnslothStudio { # Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1. function Invoke-InstallCommand { param( - [Parameter(Mandatory = $true)][ScriptBlock]$Command + [Parameter(Mandatory = $true)][ScriptBlock]$Command, + [string]$Label = "install command" ) # Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898): # for --default-index, clear the uv index env vars (restore in finally) and set @@ -504,6 +513,7 @@ function Install-UnslothStudio { try { # Reset to avoid stale values from prior native commands. $global:LASTEXITCODE = 0 + Write-TauriLog "OUTPUT_CLEAR" $Label if ($script:UnslothVerbose) { # Merge stderr into stdout so progress/warning output stays visible # without flipping $? on successful native commands (PS 5.1 treats @@ -518,7 +528,13 @@ function Install-UnslothStudio { Write-Host (Redact-InstallOutput $output) -ForegroundColor Red } } - return [int]$LASTEXITCODE + $exitCode = [int]$LASTEXITCODE + if ($exitCode -eq 0) { + Clear-TauriInstallError "$Label recovered" + } else { + Write-TauriLog "ERROR_OUTPUT" "$Label failed (exit code $exitCode)" + } + return $exitCode } finally { $ErrorActionPreference = $prevEap if ($savedUvIndex) { @@ -549,7 +565,7 @@ function Install-UnslothStudio { } $attempt = 1 while ($true) { - $code = Invoke-InstallCommand $Command + $code = Invoke-InstallCommand -Command $Command -Label $Label if ($code -eq 0) { return 0 } if ($attempt -ge $maxAttempts) { return $code } substep ("retrying ""$Label"" after transient failure (attempt $($attempt + 1)/$maxAttempts, waiting ${delay}s)...") "Yellow" @@ -1603,7 +1619,7 @@ exit 0 if (-not (Test-Path -LiteralPath $VenvPython)) { step "venv" "creating Python $($DetectedPython.Version) virtual environment" substep "$VenvDir" - $venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" } + $venvExit = Invoke-InstallCommand -Label "create virtual environment" { uv venv $VenvDir --python "$($DetectedPython.Path)" } if ($venvExit -ne 0) { Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit) @@ -2375,7 +2391,7 @@ exit 0 } if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2464,7 +2480,7 @@ exit 0 if ($StudioLocalInstall) { substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2487,7 +2503,7 @@ exit 0 return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) } substep "overlaying local repo (editable)..." - $overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps } + $overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps } if ($overlayExit -ne 0) { Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit) @@ -2535,7 +2551,7 @@ exit 0 $visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } $audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" } substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow" - $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec } + $torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch (ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec } if ($torchFixExit -ne 0) { Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit) @@ -2544,7 +2560,7 @@ exit 0 } elseif ($expectedTorchTag -ne 'rocm') { # CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet. substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow" - $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio } + $torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch ($expectedTorchTag)" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio } if ($torchFixExit -ne 0) { Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit) @@ -2645,6 +2661,9 @@ exit 0 # an inherited value would put llama.cpp in the wrong place. $previousUnslothStudioHome = $env:UNSLOTH_STUDIO_HOME $hadPreviousUnslothStudioHome = ($null -ne $previousUnslothStudioHome) + $previousTauriMode = $env:UNSLOTH_TAURI_MODE + $hadPreviousTauriMode = ($null -ne $previousTauriMode) + $env:UNSLOTH_TAURI_MODE = if ($TauriMode) { "1" } else { "0" } if ($StudioRedirectMode -eq 'env') { $env:UNSLOTH_STUDIO_HOME = $StudioHome } else { @@ -2674,14 +2693,22 @@ exit 0 } else { Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue } + if ($hadPreviousTauriMode) { + $env:UNSLOTH_TAURI_MODE = $previousTauriMode + } else { + Remove-Item Env:UNSLOTH_TAURI_MODE -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 } if ($setupExit -ne 0) { - Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red + if (-not $TauriMode) { + Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red + } return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit) } + Clear-TauriInstallError "studio setup completed" # ── Expose `unsloth` via a shim dir containing only unsloth.exe ── # We do NOT add the venv Scripts dir to PATH (it also holds python.exe diff --git a/install.sh b/install.sh index fece7b173b..376daa8fab 100755 --- a/install.sh +++ b/install.sh @@ -207,18 +207,37 @@ run_install_cmd() { # command's exit code across the pipe without relying on pipefail # (this script runs under plain sh). _rcf=$(mktemp) - { "$@" 2>&1; printf '%s' "$?" > "$_rcf"; } | _redact_install_output + tauri_stream_log stdout "OUTPUT_CLEAR" "$_label" + { + if "$@" 2>&1; then + _cmd_rc=0 + else + _cmd_rc=$? + fi + printf '%s' "$_cmd_rc" > "$_rcf" + } | _redact_install_output _rc=$(cat "$_rcf" 2>/dev/null || echo 1) rm -f "$_rcf" - [ "${_rc:-1}" -eq 0 ] 2>/dev/null && return 0 + _rc=${_rc:-1} + if [ "$_rc" -eq 0 ] 2>/dev/null; then + tauri_clear_install_error "$_label recovered" + return 0 + fi + tauri_stream_log stdout "ERROR_OUTPUT" "$_label failed (exit code $_rc)" step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 return "$_rc" fi _log=$(mktemp) - "$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; } + tauri_stream_log stderr "OUTPUT_CLEAR" "$_label" + "$@" >"$_log" 2>&1 && { + rm -f "$_log" + tauri_clear_install_error "$_label recovered" + return 0 + } _rc=$? step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2 _redact_install_output "$_log" >&2 + tauri_stream_log stderr "ERROR_OUTPUT" "$_label failed (exit code $_rc)" rm -f "$_log" return $_rc } @@ -383,6 +402,34 @@ tauri_log() { fi } +tauri_stream_log() { + _tsl_stream="$1" + _tsl_tag="$2" + shift 2 + if [ "$TAURI_MODE" = true ]; then + if [ "$_tsl_stream" = stderr ]; then + printf '[TAURI:%s] %s\n' "$_tsl_tag" "$*" >&2 + else + printf '[TAURI:%s] %s\n' "$_tsl_tag" "$*" + fi + fi +} + +rollback_substep() { + if [ "$TAURI_MODE" = true ]; then + tauri_log "PROGRESS" "$1" + else + substep "$@" + fi +} + +tauri_clear_install_error() { + if [ "$TAURI_MODE" = true ]; then + tauri_log "ERROR_CLEAR" "$1" + printf '[TAURI:ERROR_CLEAR] %s\n' "$1" >&2 + fi +} + tauri_diag_marker() { _diag_gpu_branch="${1:-unknown}" _diag_torch_index_family="${2:-none}" @@ -543,10 +590,10 @@ _restore_studio_venv_replacement() { _VENV_ROLLBACK_ACTIVE=false return 0 } - substep "restoring previous environment after failed install..." "$C_WARN" + rollback_substep "restoring previous environment after failed install..." "$C_WARN" rm -rf "$_VENV_ROLLBACK_TARGET" if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then - substep "restored previous environment" + rollback_substep "restored previous environment" _VENV_ROLLBACK_ACTIVE=false _VENV_ROLLBACK_DIR="" else @@ -4055,6 +4102,7 @@ if [ -n "$VENV_ABS_BIN" ]; then fi if ! command -v bash >/dev/null 2>&1; then + tauri_log "ERROR" "bash is required to run studio setup" step "setup" "bash is required to run studio setup" "$C_ERR" substep "Please install bash and re-run install.sh" exit 1 @@ -4093,6 +4141,7 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then STUDIO_LOCAL_REPO="$_REPO_ROOT" \ UNSLOTH_NO_TORCH="$SKIP_TORCH" \ UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \ + UNSLOTH_TAURI_MODE="$TAURI_MODE" \ bash "$SETUP_SH" 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" } diff --git a/studio/setup.sh b/studio/setup.sh index b4088c15b6..1cad0e2dbe 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -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 diff --git a/studio/src-tauri/src/diagnostics/mod.rs b/studio/src-tauri/src/diagnostics/mod.rs index 998efc893e..3fdbac06dd 100644 --- a/studio/src-tauri/src/diagnostics/mod.rs +++ b/studio/src-tauri/src/diagnostics/mod.rs @@ -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; diff --git a/studio/src-tauri/src/diagnostics/redaction.rs b/studio/src-tauri/src/diagnostics/redaction.rs index 0c7a92944a..bd36f9254e 100644 --- a/studio/src-tauri/src/diagnostics/redaction.rs +++ b/studio/src-tauri/src/diagnostics/redaction.rs @@ -16,6 +16,8 @@ pub(crate) fn redact_text(text: &str, report: &mut RedactionReport) -> String { } out = replace_regex(private_key_re(), &out, "", report); out = replace_regex(url_credentials_re(), &out, "$1@", report); + out = replace_regex(url_query_value_re(), &out, "$1=", report); + out = replace_regex(url_fragment_re(), &out, "$1#", report); out = replace_regex(auth_header_re(), &out, "$1: ", report); out = replace_regex(cookie_re(), &out, "$1: ", report); out = replace_regex(token_re(), &out, "", 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 = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"([?&][^=\s&`]+)=[^&#\s`]+").unwrap()) +} + +fn url_fragment_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?i)(https?://[^\s`#]+)#[^\s`]+").unwrap()) +} + fn auth_header_re() -> &'static Regex { static RE: OnceLock = 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=")); assert!(redacted.contains("https://@example.com/path")); + assert!(!redacted.contains("presignedvalue987")); + assert!(!redacted.contains("fragmentsecret")); + assert!(redacted.contains("?X-Amz-Signature=&version=#")); assert!(!redacted.contains("alex@example.com")); assert!(redacted.contains("")); assert!(!redacted.contains("PRIVATE KEY-----\nabc")); diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs index 024b730735..d7226bf901 100644 --- a/studio/src-tauri/src/install.rs +++ b/studio/src-tauri/src/install.rs @@ -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, + explicit_error_stream: Option, + default_error: Option, + output_tail: VecDeque, +} + +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, stderr: Option, -) -> Vec> { +) -> ( + Vec>, + Arc>, +) { 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 = @@ -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://@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()))); } } diff --git a/tests/sh/test_install_rollback_lifecycle.sh b/tests/sh/test_install_rollback_lifecycle.sh index d1ccae8e19..b0e6183fb4 100644 --- a/tests/sh/test_install_rollback_lifecycle.sh +++ b/tests/sh/test_install_rollback_lifecycle.sh @@ -32,6 +32,7 @@ run_signal_case() { { printf '%s\n' 'set -e' printf '%s\n' 'substep() { :; }' + printf '%s\n' 'rollback_substep() { substep "$@"; }' printf '%s\n' 'C_WARN=""' printf "STUDIO_HOME='%s'\n" "$_case_dir" printf "VENV_DIR='%s/unsloth_studio'\n" "$_case_dir" @@ -77,6 +78,7 @@ START_BOUNDARY_HARNESS="$START_BOUNDARY_DIR/harness.sh" { printf '%s\n' 'set -e' printf '%s\n' 'substep() { :; }' + printf '%s\n' 'rollback_substep() { substep "$@"; }' printf '%s\n' 'C_WARN=""' printf "STUDIO_HOME='%s'\n" "$START_BOUNDARY_DIR" printf "VENV_DIR='%s/unsloth_studio'\n" "$START_BOUNDARY_DIR" @@ -102,6 +104,7 @@ COMMIT_BOUNDARY_HARNESS="$COMMIT_BOUNDARY_DIR/harness.sh" { printf '%s\n' 'set -e' printf '%s\n' 'substep() { :; }' + printf '%s\n' 'rollback_substep() { substep "$@"; }' printf '%s\n' 'C_WARN=""' printf "STUDIO_HOME='%s'\n" "$COMMIT_BOUNDARY_DIR" printf "VENV_DIR='%s/unsloth_studio'\n" "$COMMIT_BOUNDARY_DIR" @@ -131,6 +134,7 @@ PRUNE_HARNESS="$PRUNE_DIR/harness.sh" { printf '%s\n' 'set -e' printf '%s\n' 'substep() { :; }' + printf '%s\n' 'rollback_substep() { substep "$@"; }' printf '%s\n' 'C_WARN=""' printf "STUDIO_HOME='%s'\n" "$PRUNE_DIR" printf "VENV_DIR='%s/unsloth_studio'\n" "$PRUNE_DIR" diff --git a/tests/sh/test_tauri_retry_failure_context.sh b/tests/sh/test_tauri_retry_failure_context.sh new file mode 100755 index 0000000000..e9034e8e54 --- /dev/null +++ b/tests/sh/test_tauri_retry_failure_context.sh @@ -0,0 +1,307 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +INSTALL_PS1="$SCRIPT_DIR/../../install.ps1" +SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh" +SETUP_PS1="$SCRIPT_DIR/../../studio/setup.ps1" + +_FUNC_FILE=$(mktemp) +{ + sed -n '/^run_install_cmd()/,/^}/p' "$INSTALL_SH" + sed -n '/^run_install_cmd_retry()/,/^}/p' "$INSTALL_SH" + sed -n '/^tauri_log()/,/^}/p' "$INSTALL_SH" + sed -n '/^tauri_stream_log()/,/^}/p' "$INSTALL_SH" + sed -n '/^tauri_clear_install_error()/,/^}/p' "$INSTALL_SH" +} > "$_FUNC_FILE" +# shellcheck disable=SC1090 +. "$_FUNC_FILE" +rm -f "$_FUNC_FILE" + +substep() { + : +} + +step() { + : +} + +sleep() { + : +} + +_is_verbose() { + return 1 +} + +_redact_install_output() { + cat "$@" +} + +echo "=== run_install_cmd_retry Tauri failure context ===" + +TAURI_MODE=true +_test_attempt=0 +_test_command() { + _test_attempt=$((_test_attempt + 1)) + [ "$_test_attempt" -eq 2 ] +} + +UNSLOTH_INSTALL_RETRIES=3 +UNSLOTH_INSTALL_RETRY_DELAY=0 +_stdout_file=$(mktemp) +_stderr_file=$(mktemp) +trap 'rm -f "$_stdout_file" "$_stderr_file"' EXIT +run_install_cmd_retry "install PyTorch" _test_command >"$_stdout_file" 2>"$_stderr_file" +_stdout_clear_count=$(grep -c '^\[TAURI:ERROR_CLEAR\] install PyTorch recovered$' "$_stdout_file") +_stderr_clear_count=$(grep -c '^\[TAURI:ERROR_CLEAR\] install PyTorch recovered$' "$_stderr_file") +if [ "$_stdout_clear_count" -ne 1 ] || [ "$_stderr_clear_count" -ne 1 ]; then + echo " FAIL: recovered retry emitted $_stdout_clear_count stdout and $_stderr_clear_count stderr clear markers" + exit 1 +fi +echo " PASS: recovered retry clears stale context on both streams" + +_test_command() { + printf '%s\n' "resolver error: no space left on device" + return 9 +} + +if run_install_cmd_retry "install PyTorch" _test_command >"$_stdout_file" 2>"$_stderr_file"; then + echo " FAIL: permanent failure returned success" + exit 1 +else + _exit_code=$? +fi +if [ "$_exit_code" -ne 9 ]; then + echo " FAIL: permanent failure returned exit code $_exit_code" + exit 1 +fi +if grep -q '^\[TAURI:ERROR_CLEAR\]' "$_stdout_file" || + grep -q '^\[TAURI:ERROR_CLEAR\]' "$_stderr_file"; then + echo " FAIL: permanent failure cleared its failure context" + exit 1 +fi +if ! grep -qxF '[TAURI:OUTPUT_CLEAR] install PyTorch' "$_stderr_file" || + ! grep -qxF 'resolver error: no space left on device' "$_stderr_file" || + ! tail -n 1 "$_stderr_file" | + grep -qxF '[TAURI:ERROR_OUTPUT] install PyTorch failed (exit code 9)'; then + echo " FAIL: quiet failure did not bind its command output to the structured error" + exit 1 +fi +echo " PASS: permanent failure retains its command output and exit code" + +UNSLOTH_INSTALL_RETRIES=1 +if run_install_cmd_retry "preferred PyTorch build" _test_command >"$_stdout_file" 2>"$_stderr_file"; then + echo " FAIL: failed preferred build returned success" + exit 1 +fi +_test_command() { + return 0 +} +run_install_cmd_retry "fallback PyTorch build" _test_command >"$_stdout_file" 2>"$_stderr_file" +if ! grep -q '^\[TAURI:ERROR_CLEAR\] fallback PyTorch build recovered$' "$_stdout_file" || + ! grep -q '^\[TAURI:ERROR_CLEAR\] fallback PyTorch build recovered$' "$_stderr_file"; then + echo " FAIL: successful fallback retained the preferred build failure" + exit 1 +fi +echo " PASS: successful fallback clears an exhausted preferred failure" + +_test_command() { + return 0 +} +run_install_cmd "successful unstructured fallback" _test_command >"$_stdout_file" 2>"$_stderr_file" +if ! grep -q '^\[TAURI:ERROR_CLEAR\] successful unstructured fallback recovered$' "$_stdout_file" || + ! grep -q '^\[TAURI:ERROR_CLEAR\] successful unstructured fallback recovered$' "$_stderr_file"; then + echo " FAIL: initial success did not clear unstructured failure context" + exit 1 +fi +echo " PASS: every successful wrapped command clears unstructured failure context" + +_is_verbose() { + return 0 +} +_test_command() { + printf '%s\n' "resolver error: proxy authentication required" + return 7 +} +set +e +( + set -e + run_install_cmd "verbose failure" _test_command +) >"$_stdout_file" 2>"$_stderr_file" +_exit_code=$? +set -e +if [ "$_exit_code" -ne 7 ]; then + echo " FAIL: verbose failure returned exit code $_exit_code instead of 7" + exit 1 +fi +if ! grep -qxF '[TAURI:OUTPUT_CLEAR] verbose failure' "$_stdout_file" || + ! grep -qxF 'resolver error: proxy authentication required' "$_stdout_file" || + ! tail -n 1 "$_stdout_file" | + grep -qxF '[TAURI:ERROR_OUTPUT] verbose failure failed (exit code 7)'; then + echo " FAIL: verbose failure did not bind its command output and exit code" + exit 1 +fi +echo " PASS: verbose failure retains its command output and exit code under set -e" + +_test_command() { + _cmd_rc=9 + return 0 +} +run_install_cmd "verbose clobbering success" _test_command >"$_stdout_file" 2>"$_stderr_file" +if ! grep -q '^\[TAURI:ERROR_CLEAR\] verbose clobbering success recovered$' "$_stdout_file"; then + echo " FAIL: verbose success inherited a status variable written by the wrapped function" + exit 1 +fi +echo " PASS: verbose success records its status after the wrapped function returns" + +_test_command() { + return 7 +} +_missing_status_parent=$(mktemp -d) +rmdir "$_missing_status_parent" +_missing_status_path="$_missing_status_parent/status" +set +e +( + set -e + mktemp() { + printf '%s\n' "$_missing_status_path" + } + run_install_cmd "missing status file" _test_command +) >"$_stdout_file" 2>"$_stderr_file" +_exit_code=$? +set -e +if [ "$_exit_code" -ne 1 ] || + ! grep -q '^\[TAURI:ERROR_OUTPUT\] missing status file failed (exit code 1)$' "$_stdout_file"; then + echo " FAIL: missing verbose status defaulted to an empty or invalid exit code" + exit 1 +fi +echo " PASS: missing verbose status defaults before reporting the failure" + +_SETUP_FUNC_FILE=$(mktemp) +sed -n '/^setup_fail()/,/^}/p' "$SETUP_SH" > "$_SETUP_FUNC_FILE" +# shellcheck disable=SC1090 +. "$_SETUP_FUNC_FILE" +rm -f "$_SETUP_FUNC_FILE" + +set +e +( + UNSLOTH_TAURI_MODE=1 + setup_fail 7 "specific setup failure" +) >"$_stdout_file" 2>"$_stderr_file" +_exit_code=$? +set -e +if [ "$_exit_code" -ne 7 ] || + ! grep -qxF '[TAURI:ERROR] specific setup failure' "$_stdout_file"; then + echo " FAIL: Tauri setup failure did not emit its explicit error and exit code" + exit 1 +fi + +set +e +( + UNSLOTH_TAURI_MODE=0 + setup_fail 7 "specific setup failure" +) >"$_stdout_file" 2>"$_stderr_file" +_exit_code=$? +set -e +if [ "$_exit_code" -ne 7 ] || [ -s "$_stdout_file" ] || [ -s "$_stderr_file" ]; then + echo " FAIL: non-Tauri setup failure emitted desktop protocol output" + exit 1 +fi +echo " PASS: setup failures emit explicit context only in Tauri mode" + +_setup_mode_count=$(grep -c 'UNSLOTH_TAURI_MODE="$TAURI_MODE"' "$INSTALL_SH") +if [ "$_setup_mode_count" -ne 2 ]; then + echo " FAIL: Unix installer does not pass Tauri mode to both setup invocations" + exit 1 +fi + +_setup_exit_count=$(grep -Ec '^[[:space:]]*exit[[:space:]]+' "$SETUP_SH") +if [ "$_setup_exit_count" -ne 1 ] || + ! grep -q '^[[:space:]]*exit "\$exit_code"$' "$SETUP_SH"; then + echo " FAIL: Unix setup has explicit exits outside setup_fail" + exit 1 +fi +echo " PASS: Unix setup routes explicit exits through setup_fail" + +_rollback_block=$(sed -n \ + '/^_restore_studio_venv_replacement()/,/^}/p' \ + "$INSTALL_SH") +_rollback_progress_count=$(printf '%s\n' "$_rollback_block" | + grep -c 'rollback_substep') +if [ "$_rollback_progress_count" -ne 2 ]; then + echo " FAIL: successful Unix rollback output can replace failure context" + exit 1 +fi +echo " PASS: successful Unix rollback remains structured progress" + +_setup_success_block=$(sed -n \ + '/^if \[ "$_SETUP_EXIT" -eq 0 \]; then$/,/^mkdir -p "\$_LOCAL_BIN"$/p' \ + "$INSTALL_SH") +if ! printf '%s\n' "$_setup_success_block" | + grep -q 'tauri_clear_install_error "studio setup completed"'; then + echo " FAIL: successful studio setup does not clear recovered setup errors before post-setup work" + exit 1 +fi + +_setup_failure_block=$(sed -n \ + '/^# If setup.sh failed, report and exit now\.$/,/^fi$/p' \ + "$INSTALL_SH") +if ! printf '%s\n' "$_setup_failure_block" | + grep -q 'tauri_log "ERROR_DEFAULT" "studio setup failed'; then + echo " FAIL: failed studio setup does not preserve output before its generic fallback" + exit 1 +fi +echo " PASS: studio setup success clears recovered errors and failure preserves specific output" + +_ps_setup_block=$(sed -n \ + '/if (\$setupExit -ne 0) {/,/# ── Expose `unsloth` via a shim dir/p' \ + "$INSTALL_PS1") +if ! printf '%s\n' "$_ps_setup_block" | grep -q 'Exit-InstallFailure' || + ! printf '%s\n' "$_ps_setup_block" | + grep -q 'Clear-TauriInstallError "studio setup completed"'; then + echo " FAIL: Windows setup does not preserve failed output and clear successful output" + exit 1 +fi +echo " PASS: Windows setup uses the same failure-context boundaries" + +if ! grep -q '\$env:UNSLOTH_TAURI_MODE = if (\$TauriMode)' "$INSTALL_PS1"; then + echo " FAIL: Windows installer does not pass Tauri mode to setup" + exit 1 +fi + +_ps_setup_exit_count=$(grep -Ec '^[[:space:]]*exit[[:space:]]+' "$SETUP_PS1") +if [ "$_ps_setup_exit_count" -ne 1 ] || + ! grep -q '^[[:space:]]*exit \$Code$' "$SETUP_PS1"; then + echo " FAIL: Windows setup has explicit exits outside Exit-SetupFailure" + exit 1 +fi +echo " PASS: Windows setup routes explicit exits through Exit-SetupFailure" + +_ps_command_block=$(sed -n \ + '/function Invoke-InstallCommand {/,/function New-StudioShortcuts {/p' \ + "$INSTALL_PS1") +if ! printf '%s\n' "$_ps_command_block" | + grep -q 'Write-TauriLog "ERROR_OUTPUT" "$Label failed' || + ! printf '%s\n' "$_ps_command_block" | + grep -q 'Write-TauriLog "OUTPUT_CLEAR" \$Label' || + ! printf '%s\n' "$_ps_command_block" | + grep -q 'Clear-TauriInstallError "$Label recovered"' || + ! printf '%s\n' "$_ps_command_block" | + grep -q 'Invoke-InstallCommand -Command \$Command -Label \$Label'; then + echo " FAIL: Windows command output is not attributed and cleared at the command boundary" + exit 1 +fi + +_ps_exit_block=$(sed -n \ + '/function Exit-InstallFailure {/,/# ── Parse flags/p' \ + "$INSTALL_PS1") +if ! printf '%s\n' "$_ps_exit_block" | + grep -q 'Write-TauriLog "ERROR_DEFAULT" \$Message'; then + echo " FAIL: Windows finalization can overwrite producer-owned failure context" + exit 1 +fi +echo " PASS: Windows command failures preserve output through retries and finalization" diff --git a/tests/sh/test_with_llama_cpp_dir_link_behavior.sh b/tests/sh/test_with_llama_cpp_dir_link_behavior.sh index fb09e56c51..2d87b7f541 100644 --- a/tests/sh/test_with_llama_cpp_dir_link_behavior.sh +++ b/tests/sh/test_with_llama_cpp_dir_link_behavior.sh @@ -37,6 +37,7 @@ case "$block" in *'_has_local_llama_server'*) : ;; PREAMBLE=' set -u step() { :; }; substep() { :; }; verbose_substep() { :; } +setup_fail() { exit "$1"; } _assert_studio_owned_or_absent() { :; } C_ERR="" _STUDIO_HOME_IS_CUSTOM=false