diff --git a/.github/scripts/hf-download-with-retry.sh b/.github/scripts/hf-download-with-retry.sh index c5ee013c80..013a459f46 100755 --- a/.github/scripts/hf-download-with-retry.sh +++ b/.github/scripts/hf-download-with-retry.sh @@ -1,4 +1,6 @@ #!/usr/bin/env 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 # # Download a single file from a Hugging Face repo with a stall-retry # watchdog. Used by the Studio CI workflows so a hung hf-xet transfer diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index 15a1affbc5..a997c89b67 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -83,6 +83,18 @@ jobs: pwsh -NoProfile -File tests/studio/test_node_decision.ps1 pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1 + # uninstall.ps1: native uninstall must keep the shared unsloth.ico while a + # WSL shortcut still references it (dual install), else that shortcut blanks. + - name: uninstall.ps1 unit test (dual-install icon preserve) + shell: pwsh + run: | + $errs = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path scripts/uninstall.ps1).Path, [ref]$null, [ref]$errs) + if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 } + Write-Host "uninstall.ps1 parsed with no errors" + pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' diff --git a/build.sh b/build.sh index 1558dca240..286664b5e8 100644 --- a/build.sh +++ b/build.sh @@ -1,4 +1,6 @@ #!/usr/bin/env 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 -euo pipefail diff --git a/install.ps1 b/install.ps1 index d48c9fa4f3..765f33b1ff 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1623,22 +1623,78 @@ exit 0 if (-not $HasNvidiaSmi) { # hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution). # AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type. - $hipinfoExe = Get-Command hipinfo -ErrorAction SilentlyContinue - if (-not $hipinfoExe) { - $hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null } - $hipEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" } - if ($hipRoot) { - $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" - if (Test-Path $hipinfoCandidate) { - Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow - Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow - Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow - $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } - } else { - Write-Host " [WARN] ${hipEnvLabel}=$hipRoot is set but hipinfo.exe not found at $hipinfoCandidate" -ForegroundColor Yellow - Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow - Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow + # Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so + # amd-smi would still auto-elevate. Cf. _path_inside_venv(). + function Test-HipinfoIsVenvInternal { + param([AllowNull()][string]$HipinfoPath) + if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false } + # Also derive the venv from the setup python + default Studio home, so + # the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset. + $venvRoots = @() + if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV } + $vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue + if ($vd) { $venvRoots += $vd } + if ($env:UNSLOTH_SETUP_PYTHON) { + try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {} + } + if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") } + # A custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the + # venv off the default path; seed it too or its hipInfo escapes the filter. + $studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null } + if ($studioHomeEnv) { + # Expand a leading ~ like the canonical resolver; else GetFullPath + # keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter. + if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + # A bare "~" leaves an empty child path; Join-Path rejects that on + # PS 5.1, so use USERPROFILE directly and only join a real remainder. + $studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\') + $studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE } } + $venvRoots += (Join-Path $studioHomeEnv "unsloth_studio") + } + try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false } + foreach ($root in $venvRoots) { + if ([string]::IsNullOrWhiteSpace($root)) { continue } + try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue } + # Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like + # C:\Python311\python.exe yields C:) -- it would match every path on that drive. + if ($r -match '^[a-zA-Z]:$') { continue } + if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or + $hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + # Scan all hipinfo and keep the first non-venv one (the venv copy from the + # bnb fix could shadow a real HIP SDK's). -CommandType Application matches + # only real executables, not a user alias/function named hipinfo. + $hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue | + Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } | + Select-Object -First 1 + if (-not $hipinfoExe) { + # Iterate the env roots (mirrors the Python list) and take the first non-venv + # bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH. + $hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null + foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) { + $hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel) + if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue } + $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" + if (-not (Test-Path $hipinfoCandidate)) { + if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate } + continue + } + if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK + Write-Host " [WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" -ForegroundColor Yellow + Write-Host " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" -ForegroundColor Yellow + Write-Host " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" -ForegroundColor Yellow + $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } + break + } + if ((-not $hipinfoExe) -and $hipMissingLabel) { + Write-Host " [WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" -ForegroundColor Yellow + Write-Host " HIP SDK install may be incomplete -- re-install from:" -ForegroundColor Yellow + Write-Host " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" -ForegroundColor Yellow } } if ($hipinfoExe) { @@ -1724,11 +1780,10 @@ exit 0 } catch {} } # ── Arch resolution: env-var override → name inference ────────────── - # Runs even when the hipinfo/amd-smi probe could NOT confirm a runtime - # ($HasROCm false): the gfx arch inferred from the WMI GPU name lets the - # studio setup forward --rocm-gfx and pull a GPU-accelerated ROCm - # llama.cpp, which bundles its own ROCm runtime. PyTorch's ROCm wheels - # still require a confirmed HIP SDK -- they stay gated on $HasROCm below. + # Runs even when the probe can't confirm a runtime ($HasROCm false): the + # WMI-name gfx arch drives both ROCm llama.cpp and torch. repo.amd.com + # wheels bundle their own runtime (no HIP SDK), so a mapped arch installs + # ROCm torch directly below -- no wasted CPU base. if (-not $ROCmGfxArch) { # 1. Manual override: set UNSLOTH_ROCM_GFX_ARCH=gfx1151 before running. if ($env:UNSLOTH_ROCM_GFX_ARCH) { @@ -1972,7 +2027,7 @@ exit 0 # Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs. $ROCmIndexUrl = $null $ROCmTorchFloor = $null - if ($HasROCm -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) { + if (($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) { $amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" } $archFamilyMap = @{ "gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4 @@ -1995,6 +2050,17 @@ exit 0 "gfx1201" = "torch>=2.11.0,<2.12.0"; "gfx1200" = "torch>=2.11.0,<2.12.0" "gfx1151" = "torch>=2.11.0,<2.12.0"; "gfx1150" = "torch>=2.11.0,<2.12.0" } + # Companion ranges track the torch ceiling so pip resolves a consistent + # trio on AMD's per-arch index (each published independently). Mirrors + # setup.ps1 / install_python_stack.py; bump all three together for 2.12.x. + $torchvisionFloorMap = @{ + "gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0" + "gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0" + } + $torchaudioFloorMap = @{ + "gfx1201" = "torchaudio>=2.11.0,<2.12.0"; "gfx1200" = "torchaudio>=2.11.0,<2.12.0" + "gfx1151" = "torchaudio>=2.11.0,<2.12.0"; "gfx1150" = "torchaudio>=2.11.0,<2.12.0" + } $archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null } if ($archFamily) { $ROCmIndexUrl = "$amdIndexBase/$archFamily/" @@ -2023,10 +2089,10 @@ exit 0 if (-not $SkipTorch -and -not $ROCmIndexUrl -and $TorchIndexUrl -like "*/cpu") { Write-Host "" if ($ROCmGfxArch) { - # Known AMD arch: install.ps1 lays down CPU PyTorch as a base, then - # setup.ps1 swaps in AMD's bundled-runtime GPU ROCm wheels (no HIP SDK). - substep "Installing CPU PyTorch as a base -- Studio setup installs GPU ROCm" "Cyan" - substep "wheels for $ROCmGfxArch next (bundled runtime; HIP SDK not required)." "Cyan" + # Only an unmapped arch reaches here (a mapped one set $ROCmIndexUrl + # above). No ROCm torch wheels for this arch (e.g. RDNA2 gfx103X) -> CPU. + substep "Installing CPU PyTorch -- no ROCm PyTorch wheels are available for $ROCmGfxArch." "Yellow" + substep "PyTorch (training and Transformers inference) runs on CPU on this GPU." "Yellow" } else { if ($HipSdkInstalled -and -not $HasROCm) { substep "Installing CPU-only PyTorch (HIP SDK found but GPU not ROCm-accessible)." "Yellow" @@ -2121,10 +2187,29 @@ exit 0 Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)" substep "installing PyTorch from $ROCmIndexUrl..." $torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" } - $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec torchvision torchaudio } + # Pin the companions to match $torchSpec; bare names can resolve an + # ABI-incompatible torchvision/torchaudio on AMD's per-arch index. + $visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" } + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec $visionSpec $audioSpec } if ($torchInstallExit -ne 0) { - Write-Host "[ERROR] Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" -ForegroundColor Red - return (Exit-InstallFailure "Failed to install AMD ROCm PyTorch (exit code $torchInstallExit)" $torchInstallExit) + # Transient AMD-index failure: fall back to a CPU base so the install + # still completes; Studio setup retries ROCm afterwards. + substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Studio setup retries ROCm." "Yellow" + # --force-reinstall: a failed ROCm install can leave an unpinned ROCm + # torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU + # torch>= range, so without it uv would keep the ROCm build and only swap + # the companions -- a mismatched venv the flavor-repair block won't fix. + $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl } + if ($torchInstallExit -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red + return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit) + } + # CPU base is in; drop the ROCm expectation so the flavor-repair + # block below won't retry the just-failed index and abort. setup.ps1 + # reinstalls ROCm afterwards (recomputes its own index URL). + $ROCmIndexUrl = $null + $ROCmTorchFloor = $null } } else { Write-TauriLog "STEP" "Installing PyTorch" @@ -2223,8 +2308,12 @@ exit 0 # AMD: a migrated venv can keep a stale CPU torch the fresh ROCm path # would have force-reinstalled. Repair from the same repo.amd.com index. $rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" } + # Pin companions like the fresh ROCm path (bare names can pull an + # ABI-incompatible torchvision/torchaudio from the per-arch index). + $visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" } + $audioSpec = if ($ROCmGfxArch -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 --index-url $ROCmIndexUrl $rocmSpec torchvision torchaudio } + $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $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) diff --git a/install.sh b/install.sh index 18aa922f05..7a3c490368 100755 --- a/install.sh +++ b/install.sh @@ -1439,6 +1439,100 @@ elif [ "$OS" = "macos" ]; then fi tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none" +# Strix Halo ROCm-on-WSL only targets Ubuntu 24.04. On a newer distro (e.g. 26.04) +# with a 24.04 distro present, re-run the install there and stop; else fall through +# to CPU + the `wsl --install` hint below (never auto-create a distro). Runs before +# the STUDIO_HOME mkdir/venv so the origin distro is untouched. +_maybe_reroute_strixhalo_to_2404() { + [ "${OS:-}" = "wsl" ] || return 0 + [ "${SKIP_TORCH:-false}" = "false" ] || return 0 + [ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0 + [ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0 + [ -e /dev/dxg ] || return 0 + grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0 + # Already ROCm-on-WSL? leave a working GPU alone, whatever the version. + if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then + return 0 + fi + _rr_ver="" + [ -r /etc/os-release ] && _rr_ver=$(. /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}") + # The bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any VERSION_ID but + # 24.04 and pins the noble repo, so 24.04 is the sole GPU-supported target; leave a + # 24.04 user alone. (Working ROCm on other versions was caught by librocdxg above.) + case "$_rr_ver" in 24.04) return 0 ;; esac + # Distro is now unsupported. If we can't reroute to a 24.04 target, stay CPU-only + # AND skip the later origin-distro ROCm bootstrap (it ignores distro version, so it + # would otherwise install ROCm into 26.04 etc.). + command -v wsl.exe >/dev/null 2>&1 || { UNSLOTH_SKIP_ROCM_WSL_SETUP=1; return 0; } + # Route only to an installed Ubuntu-24.04 (bootstrap's only target). Match the whole + # line (one distro per line from wsl.exe -l -q), not a substring, so "Ubuntu-24.04-test" + # can't masquerade as it and then fail `wsl -d`. + # || true: no match is expected, not an error (script runs under set -e). + _rr_distros=$(wsl.exe -l -q 2>/dev/null | tr -d '\000\r') + _rr_target=$(printf '%s\n' "$_rr_distros" | grep -ixF "Ubuntu-24.04" | head -n1) || true + [ -n "$_rr_target" ] || { + substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN" + substep "No Ubuntu-24.04 WSL distro found; staying CPU-only. Install Ubuntu-24.04 and re-run there for GPU." "$C_WARN" + UNSLOTH_SKIP_ROCM_WSL_SETUP=1 + return 0 + } + + echo "" + substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN" + substep "Found an existing $_rr_target distro -- continuing the GPU install there." "$C_OK" + # A --local checkout can't be replayed via curl|sh (the repo isn't in the target + # distro), so tell the user to re-run there rather than silently run a different install. + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + substep "This is a --local install; re-run it from $_rr_target instead:" "$C_WARN" + substep " wsl -d $_rr_target -- bash -lc 'cd && ./install.sh --local'" "$C_WARN" + substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN" + # Unsupported distro, can't reroute a --local checkout: skip the origin ROCm bootstrap. + UNSLOTH_SKIP_ROCM_WSL_SETUP=1 + return 0 + fi + # Forward the caller's options/env (custom package/python/home) so the rerouted + # install matches what was asked for, not a default install. + _rr_q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + _rr_exports="set -o pipefail; export UNSLOTH_WSL_REROUTED=1" + [ "$_STUDIO_HOME_REDIRECT" = "env" ] && _rr_exports="$_rr_exports; export UNSLOTH_STUDIO_HOME=$(_rr_q "$STUDIO_HOME")" + # Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the + # GPU instead of falling back to the desktop-app prompt path. + [ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1" + _rr_args="" + [ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")" + [ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")" + [ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose" + [ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri" + if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then + _rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control + elif [ -n "$_rr_args" ]; then + _rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh -s --$_rr_args" + else + _rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh" + fi + # pipefail so a failed curl in `curl | sh` isn't masked by sh exiting 0 on empty + # input (which would wrongly report success and exit 0 the parent installer). + _rr_rc=0 + wsl.exe -d "$_rr_target" -- bash -lc "$_rr_exports; $_rr_cmd" || _rr_rc=$? + if [ "$_rr_rc" -eq 0 ]; then + exit 0 + fi + # In Tauri mode the child uses exit 2 ([TAURI:NEED_SUDO]) to ask the desktop app to + # elevate for the target distro; the child already printed the NEED_SUDO line, so + # propagate the code instead of masking it as a reroute failure and dropping to CPU. + if [ "$TAURI_MODE" = true ] && [ "$_rr_rc" -eq 2 ]; then + exit 2 + fi + substep "Could not auto-continue in $_rr_target; run it yourself:" "$C_WARN" + substep " wsl -d $_rr_target -- bash -lc 'curl -fsSL https://unsloth.ai/install.sh | sh'" + substep "Continuing CPU-only in Ubuntu ${_rr_ver:-this distro} for now." "$C_WARN" + # Reroute failed; don't let the later bootstrap install ROCm into this unsupported + # distro -- stay CPU-only. + UNSLOTH_SKIP_ROCM_WSL_SETUP=1 + return 0 +} +_maybe_reroute_strixhalo_to_2404 || true + # ── Check system dependencies ── # cmake and git are needed by unsloth studio setup to build the GGUF inference # engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux. diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index f0f6e4fddc..88defb9ea0 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -22,15 +22,64 @@ function Uninstall-UnslothStudio { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } if (-not (Test-Path -LiteralPath $Path)) { return } - for ($attempt = 1; $attempt -le 3; $attempt++) { + for ($attempt = 1; $attempt -le 4; $attempt++) { try { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } catch { + if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } + _Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow" + return + } + # Remove-Item -Recurse can report success yet leave a transiently-locked + # child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we + # never falsely claim "removed" or orphan the dir. + if (-not (Test-Path -LiteralPath $Path)) { _Substep "removed: $Path" "Green" return - } catch { - if ($attempt -lt 3) { Start-Sleep -Milliseconds 700; continue } - _Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow" } + if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue } + _Substep "still present (files held open): $Path" "Yellow" + } + } + + # Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points + # at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed. + function _RemoveDataDirKeepingWslIcon { + param( + [string]$DataDir, + # WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests. + [string[]]$ShortcutDirs = $null + ) + if ([string]::IsNullOrWhiteSpace($DataDir)) { return } + if (-not (Test-Path -LiteralPath $DataDir)) { return } + # $null = not passed (use defaults); test $null not truthiness so an explicit + # @() is honored (-not @() is $true). + if ($null -eq $ShortcutDirs) { + # Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where + # an unguarded Join-Path emits a noisy parameter-binding error. + $ShortcutDirs = @() + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" + } + try { + $desktop = [Environment]::GetFolderPath("Desktop") + if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop } + } catch {} + } + $wslShortcuts = @() + foreach ($d in $ShortcutDirs) { + if ($d -and (Test-Path -LiteralPath $d)) { + $wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue + } + } + if (@($wslShortcuts).Count -eq 0) { + _RemovePath $DataDir + return + } + # A WSL shortcut survives: drop everything except its shared icon. + _Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray" + Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName } } } @@ -287,6 +336,9 @@ function Uninstall-UnslothStudio { $defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null } $defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null } $defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null } + # Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in + # default mode. No-op in env/custom mode (nested under the custom root) and absent. + $defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null } # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging, # sibling of the install dir). Usually pruned after activate, but an interrupted # build can leave a ".staging-XXXX" tree; removing it lets the empty-dir @@ -310,7 +362,7 @@ function Uninstall-UnslothStudio { _StopStudioProcesses -KnownRoots $knownRoots # Also stop anything holding a handle on the exact paths we delete (llama-server, # the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused. - _StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache)) + _StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode)) # ── Remove custom-root install trees ── _Step "Removing data and install directories..." @@ -328,12 +380,18 @@ function Uninstall-UnslothStudio { # Default install dir (always at %USERPROFILE%\.unsloth\studio when present). if ($defaultStudioHome) { _RemovePath $defaultStudioHome } # Default data dir. - if ($defaultDataDir) { _RemovePath $defaultDataDir } + if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir } # Default-mode shared llama.cpp build + cache (siblings of studio under # ~/.unsloth). No-op in env/custom mode and when absent. if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp } if ($defaultCache) { _RemovePath $defaultCache } + # Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/ + # custom mode (nested under the custom root, removed with it) and when absent. + if ($defaultNode) { _RemovePath $defaultNode } if ($defaultStaging) { _RemovePath $defaultStaging } + # llama.cpp install lock (serializes the shared build); a stray lock keeps + # ~/.unsloth from being pruned below. No-op in env/custom mode and when absent. + if ($defaultUnslothHome) { _RemovePath (Join-Path $defaultUnslothHome ".llama.cpp.install.lock") } # Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content. if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and -not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) { @@ -362,6 +420,11 @@ function Uninstall-UnslothStudio { } } catch { } + # Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for + # the native shortcut; that handle is now freed. (A surviving WSL shortcut still + # keeps the icon -- see the helper.) + if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir } + # ── Clean user PATH and registry backup ── _Step "Cleaning user PATH and registry..." try { diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index e97b28799b..31e851fcbb 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -217,10 +217,16 @@ _remove_path "$HOME/.unsloth/studio" # when absent. A user-set UNSLOTH_LLAMA_CPP_PATH is intentionally kept. _remove_path "$HOME/.unsloth/llama.cpp" _remove_path "$HOME/.unsloth/.cache" +# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in +# default mode. No-op in env/custom mode (nested under the custom root) and absent. +_remove_path "$HOME/.unsloth/node" # llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging). # Normally pruned after activate, but an interrupted build can leave it behind; # removing it lets the rmdir below succeed. No-op in env/custom mode and absent. _remove_path "$HOME/.unsloth/.staging" +# llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth +# from being pruned below. No-op in env/custom mode and when absent. +_remove_path "$HOME/.unsloth/.llama.cpp.install.lock" # ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op # where they don't exist; removing them lets the rmdir below succeed. _remove_path "$HOME/.unsloth/librocdxg" @@ -298,11 +304,50 @@ case "$_os" in Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } catch { } } + } + # Keep the shared icon while any Unsloth shortcut still uses it (native + # install or another WSL distro); drop it only with the last one. + $iconInUse = $false; + foreach ($d in $dirs) { + if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue } + if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break } + } + # Guard LOCALAPPDATA: empty on a service/SYSTEM account makes + # Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1). + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio"; + $ico = Join-Path $iconDir "unsloth.ico"; + if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue } + if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue } }' >/dev/null 2>&1 || true fi - # Fallback when powershell.exe can't run (interop disabled): remove the - # WSL .lnk files via drvfs. The "Unsloth Studio (WSL..." name is - # WSL-specific, so a native install's "Unsloth Studio.lnk" never matches. + # Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install + # or another WSL distro) still uses it, then drop the dir if empty. Reciprocal + # of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a + # surviving WSL shortcut when the native side is removed). + _drop_shared_icon_if_unused() { + _du="$1" + _icodir="$_du/AppData/Local/Unsloth Studio" + _icon_in_use=0 + for _sd in \ + "$_du/Desktop" \ + "$_du/OneDrive/Desktop" \ + "$_du"/OneDrive*/Desktop \ + "$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do + [ -d "$_sd" ] || continue + for _any in "$_sd"/"Unsloth Studio"*.lnk; do + [ -e "$_any" ] && { _icon_in_use=1; break; } + done + [ "$_icon_in_use" = "1" ] && break + done + if [ "$_icon_in_use" = "0" ]; then + [ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true + fi + [ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true + } + # Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk + # files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a + # native install's "Unsloth Studio.lnk" never matches. if [ "$_ps_ran" = "0" ]; then for _drive in /mnt/c /mnt/d /mnt/e; do [ -d "$_drive/Users" ] || continue @@ -325,6 +370,8 @@ case "$_os" in done fi done + # Drop the shared icon only when no shortcut still needs it. + _drop_shared_icon_if_unused "$_udir" done done fi diff --git a/studio/backend/utils/hardware/amd.py b/studio/backend/utils/hardware/amd.py index 27dfe187cc..f5b64c45d0 100644 --- a/studio/backend/utils/hardware/amd.py +++ b/studio/backend/utils/hardware/amd.py @@ -34,14 +34,53 @@ _amd_smi_consecutive_failures = 0 _amd_smi_disabled = False +def _path_inside_venv(path: str) -> bool: + """True if ``path`` is inside the active venv (sys.prefix). + + The venv hipInfo.exe (AMD wheel, put on PATH by main.py/worker.py for + bitsandbytes) is NOT a HIP SDK (see _hip_sdk_present).""" + try: + # realpath (not abspath): resolve symlinks/8.3 names so an aliased venv matches. + root = os.path.normcase(os.path.realpath(sys.prefix)) + # Guard a root-dir prefix (C:\ or /): commonpath would match every path on + # it. A venv is never at root, so treat that as outside. + if os.path.dirname(root) == root: + return False + return os.path.normcase(os.path.commonpath([os.path.realpath(path), root])) == root + except (ValueError, OSError): + # Different drive / unresolvable -> treat as outside the venv. + return False + + +def _external_hipinfo_on_path() -> bool: + """True if a hipinfo OUTSIDE the venv is on PATH. + + shutil.which returns only the first hit, so the venv hipInfo could shadow a + real HIP SDK's; scan every PATH entry and skip the venv copy.""" + for directory in os.environ.get("PATH", "").split(os.pathsep): + directory = directory.strip('"') # PATH entries can be quoted on Windows + if not directory: + continue + candidate = os.path.join(directory, "hipinfo.exe") + if os.path.isfile(candidate) and not _path_inside_venv(candidate): + return True + return False + + def _hip_sdk_present() -> bool: """True if a HIP SDK is detectable (hipinfo on PATH or under HIP_PATH/ - ROCM_PATH), meaning amd-smi has a working runtime and runs un-elevated.""" - if shutil.which("hipinfo"): + ROCM_PATH), so amd-smi has a runtime and runs un-elevated. + + Ignores the venv hipInfo.exe (AMD wheel via the bnb fix): not a HIP SDK, and + doesn't stop amd-smi's DiskPart UAC.""" + if _external_hipinfo_on_path(): return True for var in ("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"): root = os.environ.get(var) - if root and os.path.exists(os.path.join(root, "bin", "hipinfo.exe")): + if not root: + continue + candidate = os.path.join(root, "bin", "hipinfo.exe") + if os.path.exists(candidate) and not _path_inside_venv(candidate): return True return False diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 569431b71e..1823a0f67e 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -54,6 +54,39 @@ if platform.system() == "Windows": os.environ.setdefault("__COMPAT_LAYER", "RunAsInvoker") +def _path_inside_venv(path: str) -> bool: + """True if ``path`` is inside the active venv (sys.prefix). + + The venv hipInfo.exe (AMD wheel, put on PATH by the bnb fix) is NOT a HIP SDK + (_amd_smi_allowed).""" + try: + # realpath (not abspath): resolve symlinks/8.3 names so an aliased venv matches. + _root = os.path.normcase(os.path.realpath(sys.prefix)) + # Guard a root-dir prefix (C:\ or /): commonpath would match every path on + # it. A venv is never at root, so treat that as outside. + if os.path.dirname(_root) == _root: + return False + return os.path.normcase(os.path.commonpath([os.path.realpath(path), _root])) == _root + except (ValueError, OSError): + # Different drive / unresolvable -> treat as outside the venv. + return False + + +def _external_hipinfo_on_path() -> bool: + """True if a hipinfo OUTSIDE the venv is on PATH. + + shutil.which returns only the first hit, so the venv hipInfo could shadow a + real HIP SDK's; scan every PATH entry and skip the venv copy.""" + for _dir in os.environ.get("PATH", "").split(os.pathsep): + _dir = _dir.strip('"') # PATH entries can be quoted on Windows + if not _dir: + continue + _candidate = os.path.join(_dir, "hipinfo.exe") + if os.path.isfile(_candidate) and not _path_inside_venv(_candidate): + return True + return False + + def _amd_smi_allowed() -> bool: """Whether it is safe to spawn amd-smi here. @@ -70,11 +103,17 @@ def _amd_smi_allowed() -> bool: return True if flag in ("0", "false", "no", "off"): return False - if shutil.which("hipinfo"): + # A real HIP SDK lets amd-smi run un-elevated; hipinfo-on-PATH is the proxy. + # Ignore the venv hipInfo.exe (AMD wheel via bnb fix): not a HIP SDK, doesn't + # stop amd-smi's DiskPart UAC. + if _external_hipinfo_on_path(): return True for _var in ("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"): _root = os.environ.get(_var) - if _root and os.path.isfile(os.path.join(_root, "bin", "hipinfo.exe")): + if not _root: + continue + _candidate = os.path.join(_root, "bin", "hipinfo.exe") + if os.path.isfile(_candidate) and not _path_inside_venv(_candidate): return True return False diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index b2de6592c8..1c3918f900 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -87,6 +87,17 @@ _ROCM_TORCH_PKG_SPECS: dict[str, tuple[str, str, str]] = { "torchaudio>=2.4,<2.11.0", ), } +# Windows AMD per-arch companion pins for the repo.amd.com index, mirroring the +# install.ps1 / setup.ps1 floor maps (gfx120X and Strix Halo/Point use the rocm7.2 +# torch 2.11 trio). Pinning the companions keeps AMD's per-arch index -- which +# publishes each independently -- from resolving an ABI-mismatched one. Unlisted +# arches have no published floor, so stay bare. Bump with the PS maps at 2.12.x. +_WINDOWS_ROCM_TORCH_PKG_SPECS: dict[str, tuple[str, str, str]] = { + "gfx1201": _ROCM_TORCH_PKG_SPECS["rocm7.2"], + "gfx1200": _ROCM_TORCH_PKG_SPECS["rocm7.2"], + "gfx1151": _ROCM_TORCH_PKG_SPECS["rocm7.2"], + "gfx1150": _ROCM_TORCH_PKG_SPECS["rocm7.2"], +} _PYTORCH_WHL_BASE = ( os.environ.get("UNSLOTH_PYTORCH_MIRROR") or "https://download.pytorch.org/whl" ).rstrip("/") @@ -235,6 +246,39 @@ def _amd_smi_env() -> dict[str, str] | None: return {**os.environ, "__COMPAT_LAYER": "RunAsInvoker"} +def _path_inside_venv(path: str) -> bool: + """True if ``path`` is inside the active venv (sys.prefix). + + The venv hipInfo.exe (AMD wheel, put on PATH by the bnb fix) is NOT a HIP SDK + (_amd_smi_allowed).""" + try: + # realpath (not abspath): resolve symlinks/8.3 names so an aliased venv matches. + _root = os.path.normcase(os.path.realpath(sys.prefix)) + # Guard a root-dir prefix (C:\ or /): commonpath would match every path on + # it. A venv is never at root, so treat that as outside. + if os.path.dirname(_root) == _root: + return False + return os.path.normcase(os.path.commonpath([os.path.realpath(path), _root])) == _root + except (ValueError, OSError): + # Different drive / unresolvable -> treat as outside the venv. + return False + + +def _external_hipinfo_on_path() -> bool: + """True if a hipinfo OUTSIDE the venv is on PATH. + + shutil.which returns only the first hit, so the venv hipInfo could shadow a + real HIP SDK's; scan every PATH entry and skip the venv copy.""" + for _dir in os.environ.get("PATH", "").split(os.pathsep): + _dir = _dir.strip('"') # PATH entries can be quoted on Windows + if not _dir: + continue + _candidate = os.path.join(_dir, "hipinfo.exe") + if os.path.isfile(_candidate) and not _path_inside_venv(_candidate): + return True + return False + + def _amd_smi_allowed() -> bool: """Whether it is safe to spawn amd-smi here. @@ -249,11 +293,17 @@ def _amd_smi_allowed() -> bool: return True if flag in ("0", "false", "no", "off"): return False - if shutil.which("hipinfo"): + # A real HIP SDK lets amd-smi run un-elevated; hipinfo-on-PATH is the proxy. + # Ignore the venv hipInfo.exe (AMD wheel via bnb fix): not a HIP SDK, doesn't + # stop amd-smi's DiskPart UAC. + if _external_hipinfo_on_path(): return True for _var in ("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"): _root = os.environ.get(_var) - if _root and os.path.isfile(os.path.join(_root, "bin", "hipinfo.exe")): + if not _root: + continue + _candidate = os.path.join(_root, "bin", "hipinfo.exe") + if os.path.isfile(_candidate) and not _path_inside_venv(_candidate): return True return False @@ -1096,16 +1146,32 @@ def _ensure_rocm_torch() -> None: print(f" No AMD Windows torch index for GPU arch {gfx_arch} -- skipping") return print(f" {gfx_arch} (Windows) -- installing torch from {index_url}") - pip_install( + # Pin companions for the arches install.ps1/setup.ps1 pin (gfx120X / + # Strix) so the per-arch index resolves an ABI-consistent trio; other + # arches stay bare (no published floor), matching the PowerShell side. + _torch_pkg, _vision_pkg, _audio_pkg = _WINDOWS_ROCM_TORCH_PKG_SPECS.get( + gfx_arch, ("torch", "torchvision", "torchaudio") + ) + # Nonfatal: a transient AMD-index failure must not abort the whole + # install once the PowerShell side has fallen back to CPU torch. + # --force-reinstall resolves before uninstalling, so a failed index + # leaves the existing build intact; keep it and let the user retry. + if not pip_install_try( f"ROCm torch (Windows, {gfx_arch})", "--force-reinstall", "--index-url", index_url, - "torch", - "torchvision", - "torchaudio", + _torch_pkg, + _vision_pkg, + _audio_pkg, constrain = False, - ) + ): + print( + f" Warning: AMD Windows ROCm torch install failed for {gfx_arch}; " + "keeping the existing torch build. Re-run 'unsloth studio update' " + "later to retry ROCm." + ) + return # ROCm torch is installed (or already was); flag it so later phases # do not overwrite it with the generic CPU torch wheel. BNB is a # separate dependency -- a BNB install failure must NOT roll back the diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 20d53734a1..20f5bd1ada 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1053,22 +1053,79 @@ $script:ROCmGfxArch = $null if (-not $HasNvidiaSmi) { # hipinfo: PATH first, then HIP_PATH/ROCM_PATH bin fallback (mirrors NVIDIA smi path resolution). # AMD HIP SDK sets HIP_PATH but may not add the bin dir to PATH depending on install type. - $hipinfoExe = Get-Command hipinfo -ErrorAction SilentlyContinue - if (-not $hipinfoExe) { - $hipRoot = if ($env:HIP_PATH) { $env:HIP_PATH } elseif ($env:ROCM_PATH) { $env:ROCM_PATH } else { $null } - $hipEnvLabel = if ($env:HIP_PATH) { "HIP_PATH" } else { "ROCM_PATH" } - if ($hipRoot) { - $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" - if (Test-Path $hipinfoCandidate) { - substep "[WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" "Yellow" - substep " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" "Yellow" - substep " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" "Yellow" - $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } - } else { - substep "[WARN] ${hipEnvLabel}=$hipRoot is set but hipinfo.exe not found at $hipinfoCandidate" "Yellow" - substep " HIP SDK install may be incomplete -- re-install from:" "Yellow" - substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow" + # Ignore the venv hipInfo.exe (AMD wheel, on PATH): not a HIP SDK, so amd-smi + # would still auto-elevate. Cf. _path_inside_venv(). + function Test-HipinfoIsVenvInternal { + param([AllowNull()][string]$HipinfoPath) + if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false } + # VenvDir/VIRTUAL_ENV can be unset this early (the update flow probes before + # VenvDir is set), so also derive the venv from the setup python + default + # Studio home, else the venv hipInfo isn't caught. + $venvRoots = @() + if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV } + $vd = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue + if ($vd) { $venvRoots += $vd } + if ($env:UNSLOTH_SETUP_PYTHON) { + try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {} + } + if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") } + # A custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the + # venv off the default path; seed it too or its hipInfo escapes the filter. + $studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null } + if ($studioHomeEnv) { + # Expand a leading ~ like the canonical resolver below; else GetFullPath + # keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter. + if (($studioHomeEnv -eq "~" -or $studioHomeEnv -like "~/*" -or $studioHomeEnv -like "~\*") -and -not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + # A bare "~" leaves an empty child path; Join-Path rejects that on + # PS 5.1, so use USERPROFILE directly and only join a real remainder. + $studioHomeRest = $studioHomeEnv.Substring(1).TrimStart('/', '\') + $studioHomeEnv = if ($studioHomeRest) { Join-Path $env:USERPROFILE $studioHomeRest } else { $env:USERPROFILE } } + $venvRoots += (Join-Path $studioHomeEnv "unsloth_studio") + } + try { $hip = [System.IO.Path]::GetFullPath($HipinfoPath).TrimEnd('\', '/') } catch { return $false } + foreach ($root in $venvRoots) { + if ([string]::IsNullOrWhiteSpace($root)) { continue } + try { $r = [System.IO.Path]::GetFullPath($root).TrimEnd('\', '/') } catch { continue } + # Skip a bare drive root (e.g. a non-venv UNSLOTH_SETUP_PYTHON like + # C:\Python311\python.exe yields C:) -- it would match every path on that drive. + if ($r -match '^[a-zA-Z]:$') { continue } + if ($hip.Equals($r, [System.StringComparison]::OrdinalIgnoreCase) -or + $hip.StartsWith($r + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false + } + # Scan all hipinfo and keep the first non-venv one (the venv copy from the + # bnb fix could shadow a real HIP SDK's). -CommandType Application matches + # only real executables, not a user alias/function named hipinfo. + $hipinfoExe = Get-Command hipinfo -CommandType Application -All -ErrorAction SilentlyContinue | + Where-Object { -not (Test-HipinfoIsVenvInternal $_.Source) } | + Select-Object -First 1 + if (-not $hipinfoExe) { + # Iterate the env roots (mirrors the Python list) and take the first non-venv + # bin\hipinfo.exe, so a venv-internal HIP_PATH can't mask a real SDK in ROCM_PATH. + $hipMissingLabel = $null; $hipMissingRoot = $null; $hipMissingCandidate = $null + foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH")) { + $hipRoot = [Environment]::GetEnvironmentVariable($hipEnvLabel) + if ([string]::IsNullOrWhiteSpace($hipRoot)) { continue } + $hipinfoCandidate = Join-Path $hipRoot "bin\hipinfo.exe" + if (-not (Test-Path $hipinfoCandidate)) { + if (-not $hipMissingLabel) { $hipMissingLabel = $hipEnvLabel; $hipMissingRoot = $hipRoot; $hipMissingCandidate = $hipinfoCandidate } + continue + } + if (Test-HipinfoIsVenvInternal $hipinfoCandidate) { continue } # venv copy (AMD wheel): not a HIP SDK + substep "[WARN] hipinfo not on PATH -- located via ${hipEnvLabel}: $hipinfoCandidate" "Yellow" + substep " Add '$(Join-Path $hipRoot 'bin')' to your PATH to suppress this warning" "Yellow" + substep " Quick fix: [Environment]::SetEnvironmentVariable('PATH',`$env:PATH+';$(Join-Path $hipRoot 'bin')','User')" "Yellow" + $hipinfoExe = [PSCustomObject]@{ Source = $hipinfoCandidate } + break + } + if ((-not $hipinfoExe) -and $hipMissingLabel) { + substep "[WARN] ${hipMissingLabel}=$hipMissingRoot is set but hipinfo.exe not found at $hipMissingCandidate" "Yellow" + substep " HIP SDK install may be incomplete -- re-install from:" "Yellow" + substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow" } } if ($hipinfoExe) { @@ -2594,10 +2651,14 @@ if ($script:UnslothVerbose) { # of whether the CUDA Toolkit is installed yet. # The CUDA tag is chosen based on the driver's max supported CUDA version. -# Windows MAX_PATH (260 chars) causes Triton kernel compilation to fail because -# the auto-generated filenames are extremely long. Use a short cache directory. -$TorchCacheDir = "C:\tc" -if (-not (Test-Path $TorchCacheDir)) { New-Item -ItemType Directory -Path $TorchCacheDir -Force | Out-Null } +# Triton/inductor filenames are long and can hit Windows MAX_PATH (260). With long +# paths on, cache under Studio home; else use a short drive-root dir for headroom. +if ($LongPathsEnabled) { + $TorchCacheDir = Join-Path $StudioHome "TORCHINDUCTOR_CACHE_DIR" +} else { + $TorchCacheDir = "C:\tc" +} +if (-not (Test-Path -LiteralPath $TorchCacheDir)) { [System.IO.Directory]::CreateDirectory($TorchCacheDir) | Out-Null } $env:TORCHINDUCTOR_CACHE_DIR = $TorchCacheDir [Environment]::SetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', $TorchCacheDir, 'User') substep "TORCHINDUCTOR_CACHE_DIR set to $TorchCacheDir (avoids MAX_PATH issues)" @@ -2675,6 +2736,7 @@ if (($HasROCm -or $ROCmGfxArch) -and $CuTag -eq "cpu") { $PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" } +$ROCmCpuFallback = $false if ($ROCmIndexUrl) { substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..." if ($ROCmTorchSpec -ne "torch") { @@ -2692,20 +2754,29 @@ if ($ROCmIndexUrl) { Write-Host "[WARN] AMD ROCm PyTorch install failed -- falling back to CPU" -ForegroundColor Yellow Write-Host $output -ForegroundColor Yellow $ROCmIndexUrl = $null + $ROCmCpuFallback = $true } else { # Tell install_python_stack.py to skip probe + suppress manual-install warning. $env:UNSLOTH_ROCM_TORCH_INSTALLED = "1" + substep "GPU ROCm PyTorch installed ($ROCmGfxArch) -- training and GPU inference will use the GPU" "Cyan" } } if (-not $ROCmIndexUrl -and $CuTag -eq "cpu") { substep "installing PyTorch (CPU-only)..." + # After an AMD ROCm fallback, force-reinstall so a partially-installed ROCm torch + # (which still satisfies the CPU torch>= range) is replaced by the CPU build. Skip + # the forced reinstall on a genuine CPU-only host so the common path stays fast. + # Build the array directly: an if-expression collapses @("x") to a scalar string, + # which @splat would then enumerate char-by-char into broken single-letter args. + $cpuForce = @() + if ($ROCmCpuFallback) { $cpuForce = @("--force-reinstall") } if ($script:UnslothVerbose) { - Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/cpu" + Fast-Install torch torchvision torchaudio @cpuForce --index-url "$PyTorchWhlBase/cpu" $torchInstallExit = $LASTEXITCODE $output = "" } else { - $output = Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/cpu" | Out-String + $output = Fast-Install torch torchvision torchaudio @cpuForce --index-url "$PyTorchWhlBase/cpu" | Out-String $torchInstallExit = $LASTEXITCODE } if ($torchInstallExit -ne 0) { @@ -2748,20 +2819,11 @@ if (-not $ROCmIndexUrl -and $CuTag -eq "cpu") { } } -# Rename running unsloth.exe so pip can replace it (Windows refuses to delete a mapped .exe). -$VenvScriptsDir = Join-Path $VenvDir "Scripts" -$RunningUnslothExe = Join-Path $VenvScriptsDir "unsloth.exe" -if (Test-Path -LiteralPath $RunningUnslothExe -PathType Leaf) { - $StaleUnslothExe = "$RunningUnslothExe.deleteme" - if (Test-Path -LiteralPath $StaleUnslothExe) { - Remove-Item -LiteralPath $StaleUnslothExe -Force -ErrorAction SilentlyContinue - } - try { - Rename-Item -LiteralPath $RunningUnslothExe -NewName "unsloth.exe.deleteme" -Force -ErrorAction Stop - } catch { - substep "could not rename unsloth.exe ($($_.Exception.Message)); pip may fail with WinError 32" "Yellow" - } -} +# No unsloth.exe rename needed. setup.ps1 runs *via* unsloth.exe, so renaming the +# running launcher only ever failed (WinError 32) and printed a scary warning. It's +# also unnecessary: install.ps1 sets SKIP_STUDIO_BASE=1 (base never reinstalled) and +# 'studio update' goes through uv (--upgrade-package), whose pip fallback no-ops on +# the already-satisfied bare unsloth/unsloth-zoo. Either way unsloth.exe stays. # Ordered heavy dependency installation -- shared cross-platform script substep "running ordered dependency installation..." @@ -2772,28 +2834,6 @@ $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 - # Restore the pre-rename unsloth.exe so the user keeps a working CLI. - # Treat a zero-byte exe as "pip half-wrote a broken binary" -- prefer the - # stale-but-working copy in .deleteme. - if (Test-Path -LiteralPath "$RunningUnslothExe.deleteme") { - $needRestore = -not (Test-Path -LiteralPath $RunningUnslothExe) - if (-not $needRestore) { - try { - $needRestore = (Get-Item -LiteralPath $RunningUnslothExe -ErrorAction Stop).Length -eq 0 - } catch { $needRestore = $true } - } - if ($needRestore) { - try { - if (Test-Path -LiteralPath $RunningUnslothExe) { - Remove-Item -LiteralPath $RunningUnslothExe -Force -ErrorAction SilentlyContinue - } - Rename-Item -LiteralPath "$RunningUnslothExe.deleteme" -NewName "unsloth.exe" -Force -ErrorAction Stop - substep "restored unsloth.exe after failed install" - } catch { - substep "could not restore unsloth.exe ($($_.Exception.Message))" "Yellow" - } - } - } exit 1 } diff --git a/studio/src-tauri/linux/postremove.sh b/studio/src-tauri/linux/postremove.sh index e721c39830..745f96f103 100755 --- a/studio/src-tauri/linux/postremove.sh +++ b/studio/src-tauri/linux/postremove.sh @@ -1,4 +1,6 @@ #!/bin/sh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Post-removal script for the Unsloth Studio Debian package # Runs non-interactively; never deletes user data or touches other users' homes. diff --git a/studio/src-tauri/windows/sign-with-trusted-signing.ps1 b/studio/src-tauri/windows/sign-with-trusted-signing.ps1 index 405caefc50..c0d7fd517a 100644 --- a/studio/src-tauri/windows/sign-with-trusted-signing.ps1 +++ b/studio/src-tauri/windows/sign-with-trusted-signing.ps1 @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 param( [Parameter(Mandatory = $true)] [string] $Path diff --git a/tests/run_all.sh b/tests/run_all.sh index 4311aa4276..2c495f5561 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -1,4 +1,6 @@ #!/bin/sh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Run all installer tests. set -e @@ -9,6 +11,8 @@ sh "$TESTS_DIR/sh/test_get_torch_index_url.sh" sh "$TESTS_DIR/sh/test_mac_intel_compat.sh" sh "$TESTS_DIR/sh/test_torch_constraint.sh" sh "$TESTS_DIR/sh/test_nvcc_meets_llama_minimum.sh" +sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh" +sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh" sh "$TESTS_DIR/sh/test_torch_flavor.sh" echo "" diff --git a/tests/saving/gpt-oss-merge/run_test.sh b/tests/saving/gpt-oss-merge/run_test.sh index 5a91b31358..28e9c49793 100755 --- a/tests/saving/gpt-oss-merge/run_test.sh +++ b/tests/saving/gpt-oss-merge/run_test.sh @@ -1,4 +1,6 @@ #!/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 echo "================================================================" diff --git a/tests/sh/test_get_torch_index_url.sh b/tests/sh/test_get_torch_index_url.sh index 3dece1f69a..6656142625 100755 --- a/tests/sh/test_get_torch_index_url.sh +++ b/tests/sh/test_get_torch_index_url.sh @@ -1,4 +1,6 @@ #!/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 # Unit tests for get_torch_index_url() from install.sh set -e diff --git a/tests/sh/test_install_host_defaults.sh b/tests/sh/test_install_host_defaults.sh index 7b01eaa716..8e1278b6e2 100755 --- a/tests/sh/test_install_host_defaults.sh +++ b/tests/sh/test_install_host_defaults.sh @@ -1,4 +1,6 @@ #!/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 # Static analysis: installer scripts and README must not hard-code 0.0.0.0 # in any user-visible default launch command. The dynamic-port launcher # templates and post-install hints should rely on the new loopback default. diff --git a/tests/sh/test_mac_intel_compat.sh b/tests/sh/test_mac_intel_compat.sh index 673fd58cd6..3c3bbfaa5f 100644 --- a/tests/sh/test_mac_intel_compat.sh +++ b/tests/sh/test_mac_intel_compat.sh @@ -1,4 +1,6 @@ #!/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 # End-to-end sandbox tests for Mac Intel compatibility and UNSLOTH_NO_TORCH propagation. # Tests version_ge, arch detection (existing), plus E2E venv creation, torch skip # via a mock uv shim, and UNSLOTH_NO_TORCH env propagation in install.sh. diff --git a/tests/sh/test_nvcc_meets_llama_minimum.sh b/tests/sh/test_nvcc_meets_llama_minimum.sh index f614c0403a..2a2ac99664 100755 --- a/tests/sh/test_nvcc_meets_llama_minimum.sh +++ b/tests/sh/test_nvcc_meets_llama_minimum.sh @@ -1,4 +1,6 @@ #!/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 # Unit tests for _nvcc_meets_llama_minimum() from studio/setup.sh. # llama.cpp needs CUDA toolkit >= 12.4 (#4437); setup.ps1 aborts via #4517, # the Linux side was silent until this fix. diff --git a/tests/sh/test_strixhalo_wsl_reroute.sh b/tests/sh/test_strixhalo_wsl_reroute.sh new file mode 100644 index 0000000000..7edf475ccb --- /dev/null +++ b/tests/sh/test_strixhalo_wsl_reroute.sh @@ -0,0 +1,323 @@ +#!/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 +# Unit tests for _maybe_reroute_strixhalo_to_2404() from install.sh. +# +# ROCm-on-WSL only targets Ubuntu 24.04: from a newer distro (e.g. 26.04) with a +# 24.04 distro present, re-run the install there and stop; otherwise leave the distro +# alone and let CPU-fallback print the `wsl --install` hint. Tested hermetically: the +# function is extracted from install.sh, its absolute paths rewritten to per-test +# fixtures, with a mock wsl.exe (no real WSL). +# +# Follows the extract-via-sed pattern of test_get_torch_index_url.sh. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +# All fixtures/temp files live under one root removed on exit, so a set -e abort +# can't leak dirs into $TMPDIR. +_TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$_TMP_ROOT"' EXIT + +assert_contains() { + _label="$1"; _hay="$2"; _needle="$3" + case "$_hay" in + *"$_needle"*) echo " PASS: $_label"; PASS=$((PASS + 1)) ;; + *) echo " FAIL: $_label (missing '$_needle' in: $_hay)"; FAIL=$((FAIL + 1)) ;; + esac +} + +assert_absent() { + _label="$1"; _hay="$2"; _needle="$3" + case "$_hay" in + *"$_needle"*) echo " FAIL: $_label (unexpected '$_needle' in: $_hay)"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: $_label"; PASS=$((PASS + 1)) ;; + esac +} + +# Extract the function and rewrite its hardcoded absolute paths to point at the +# fixture dir $1, plus stub substep()/colors. The mock wsl.exe lives in $1/bin. +build_func() { + _fix="$1" + _f=$(mktemp -p "$_TMP_ROOT") + { + echo 'substep() { printf " %s\n" "$1"; }' + echo 'C_WARN=""; C_OK=""' + sed -n '/^_maybe_reroute_strixhalo_to_2404()/,/^}/p' "$INSTALL_SH" + } | sed \ + -e "s#/dev/dxg#$_fix/dxg#g" \ + -e "s#/proc/cpuinfo#$_fix/cpuinfo#g" \ + -e "s#/opt/rocm/lib/librocdxg.so#$_fix/rocm-lib/librocdxg.so#g" \ + -e "s#/opt/rocm/lib64/librocdxg.so#$_fix/rocm-lib64/librocdxg.so#g" \ + -e "s#/etc/os-release#$_fix/os-release#g" \ + > "$_f" + echo "$_f" +} + +# make_fixture DXG CPU LIBROCDXG VER HAS2404 +# DXG/LIBROCDXG/HAS2404 = 1|0 ; CPU = strix|other ; VER = e.g. 26.04|24.04 +make_fixture() { + _d=$(mktemp -d -p "$_TMP_ROOT") + mkdir -p "$_d/bin" "$_d/rocm-lib" "$_d/rocm-lib64" + [ "$1" = 1 ] && : > "$_d/dxg" + if [ "$2" = strix ]; then + echo "model name : AMD Ryzen AI Max+ 395 w/ Radeon 8060S" > "$_d/cpuinfo" + else + echo "model name : Generic x86 CPU" > "$_d/cpuinfo" + fi + [ "$3" = 1 ] && : > "$_d/rocm-lib/librocdxg.so" + printf 'VERSION_ID="%s"\n' "$4" > "$_d/os-release" + if [ "$5" = 1 ]; then + printf 'Ubuntu\nUbuntu-24.04\n' > "$_d/distros" + else + printf 'Ubuntu\n' > "$_d/distros" + fi + # Mock wsl.exe: `-l [-q]` lists distros; `-d -- ` runs + # locally so the reroute command (echo __ROUTED__) is observable. + cat > "$_d/bin/wsl.exe" < "$_d/bin/curl" + chmod +x "$_d/bin/curl" + echo "$_d" +} + +# run_func FIXTURE_DIR [extra VAR=val ...] +# Prints the function's stdout. "__ROUTED__" => the reroute exec ran; +# "__NOROUTE__" => the function returned without rerouting (exit 0 not hit). +run_func() { + _fix="$1"; shift + _func=$(build_func "$_fix") + env PATH="$_fix/bin:$PATH" \ + OS=wsl SKIP_TORCH=false \ + UNSLOTH_SKIP_ROCM_WSL_SETUP=0 UNSLOTH_WSL_REROUTED=0 \ + UNSLOTH_WSL_REROUTE_CMD='echo __ROUTED__' \ + "$@" \ + bash -c ". '$_func'; _maybe_reroute_strixhalo_to_2404; echo SKIP_ROCM=\$UNSLOTH_SKIP_ROCM_WSL_SETUP; echo __NOROUTE__" 2>&1 + _rc=$? + rm -f "$_func" + return "$_rc" +} + +echo "=== test_strixhalo_wsl_reroute ===" + +# 1) 26.04, Strix Halo, no librocdxg, 24.04 exists -> ROUTE into Ubuntu-24.04 +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d") +assert_contains "26.04 + existing 24.04 -> routes" "$_out" "__ROUTED__" +assert_absent "26.04 route stops current distro" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 2) Already on 24.04 -> NO route (correct distro, leave it alone) +_d=$(make_fixture 1 strix 0 24.04 1) +_out=$(run_func "$_d") +assert_contains "on 24.04 -> no route" "$_out" "__NOROUTE__" +assert_absent "on 24.04 -> reroute not attempted" "$_out" "__ROUTED__" +rm -rf "$_d" + +# 3) librocdxg already present (working ROCm-on-WSL) -> NO route even on 26.04 +_d=$(make_fixture 1 strix 1 26.04 1) +_out=$(run_func "$_d") +assert_contains "librocdxg present -> no route" "$_out" "__NOROUTE__" +assert_absent "librocdxg present -> not rerouted" "$_out" "__ROUTED__" +rm -rf "$_d" + +# 4) 26.04 but no Ubuntu-24.04 distro -> NO route (install-only-on-prompt path). +# Unsupported distro with no reroute target must also skip the origin ROCm bootstrap. +_d=$(make_fixture 1 strix 0 26.04 0) +_out=$(run_func "$_d") +assert_contains "26.04 + no 24.04 distro -> no route" "$_out" "__NOROUTE__" +assert_absent "26.04 + no 24.04 -> not rerouted" "$_out" "__ROUTED__" +assert_contains "26.04 + no 24.04 -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 5) No /dev/dxg (no WSL GPU passthrough) -> NO route +_d=$(make_fixture 0 strix 0 26.04 1) +_out=$(run_func "$_d") +assert_contains "no /dev/dxg -> no route" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 6) Non-Strix CPU -> NO route (don't reroute generic AMD/Intel WSL) +_d=$(make_fixture 1 other 0 26.04 1) +_out=$(run_func "$_d") +assert_contains "non-Strix CPU -> no route" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 7) Loop guard: UNSLOTH_WSL_REROUTED=1 -> NO route (the rerouted run must not re-route) +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" UNSLOTH_WSL_REROUTED=1) +assert_contains "REROUTED=1 loop guard -> no route" "$_out" "__NOROUTE__" +assert_absent "REROUTED=1 -> not rerouted again" "$_out" "__ROUTED__" +rm -rf "$_d" + +# 8) Opt-out: UNSLOTH_SKIP_ROCM_WSL_SETUP=1 -> NO route +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" UNSLOTH_SKIP_ROCM_WSL_SETUP=1) +assert_contains "SKIP_ROCM_WSL_SETUP=1 -> no route" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 9) Not WSL (OS=linux) -> NO route +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" OS=linux) +assert_contains "OS=linux -> no route" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 10) SKIP_TORCH=true (GGUF-only) -> NO route (no GPU torch needed) +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" SKIP_TORCH=true) +assert_contains "SKIP_TORCH=true -> no route" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 11) Loop-guard payload: the reroute exports UNSLOTH_WSL_REROUTED=1 into the child +# (so the nested install short-circuits gate 7). Verify it reaches wsl.exe -d. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" UNSLOTH_WSL_REROUTE_CMD='echo flag=[$UNSLOTH_WSL_REROUTED]') +assert_contains "reroute exports loop-guard flag" "$_out" "flag=[1]" +rm -rf "$_d" + +# 12) On Ubuntu 22.04 -> unsupported by the ROCm-on-WSL bootstrap (helper targets +# 24.04 only), so with a 24.04 distro present reroute the GPU install there. +_d=$(make_fixture 1 strix 0 22.04 1) +_out=$(run_func "$_d") +assert_contains "22.04 + existing 24.04 -> routes" "$_out" "__ROUTED__" +assert_absent "22.04 route stops current distro" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 13) --local install -> NO auto-reroute (a local checkout can't be replayed via +# curl|sh); prints guidance and continues locally instead of a different install. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" STUDIO_LOCAL_INSTALL=true) +assert_contains "--local -> no auto-reroute" "$_out" "__NOROUTE__" +assert_contains "--local -> prints re-run guidance" "$_out" "re-run it from Ubuntu-24.04" +assert_absent "--local -> reroute command not run" "$_out" "__ROUTED__" +assert_contains "--local -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 14) Custom UNSLOTH_STUDIO_HOME (env mode) is forwarded as an export into the reroute. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" _STUDIO_HOME_REDIRECT=env STUDIO_HOME=/custom/studio \ + UNSLOTH_WSL_REROUTE_CMD='echo home=[$UNSLOTH_STUDIO_HOME]') +assert_contains "custom STUDIO_HOME forwarded to reroute" "$_out" "home=[/custom/studio]" +rm -rf "$_d" + +# 15) --package / --python are forwarded onto the default reroute command (curl|sh -s --). +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" PACKAGE_NAME=unsloth-zoo _USER_PYTHON=3.13 UNSLOTH_WSL_REROUTE_CMD=) +assert_contains "forwards --package to reroute" "$_out" "--package 'unsloth-zoo'" +assert_contains "forwards --python to reroute" "$_out" "--python '3.13'" +rm -rf "$_d" + +# 16) --tauri is forwarded onto the default reroute command. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" PACKAGE_NAME=unsloth TAURI_MODE=true UNSLOTH_WSL_REROUTE_CMD=) +assert_contains "forwards --tauri to reroute" "$_out" "--tauri" +rm -rf "$_d" + +# 17) A FAILED reroute sets the ROCm-bootstrap skip guard so _maybe_bootstrap_rocm_wsl +# does not later install ROCm into the unsupported origin distro. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" UNSLOTH_WSL_REROUTE_CMD='exit 1') +assert_contains "failed reroute -> sets ROCm-bootstrap skip guard" "$_out" "SKIP_ROCM=1" +assert_contains "failed reroute -> continues locally" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 18) A successful reroute (no failure) does NOT set the skip guard prematurely. +_d=$(make_fixture 1 strix 0 24.04 1) +_out=$(run_func "$_d") +assert_absent "supported 24.04 -> skip guard not forced" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 19) No wsl.exe on an unsupported distro -> can't reach a 24.04 target, so stay +# CPU-only AND set the skip guard (don't bootstrap ROCm into 26.04 etc.). +# Drop the stub AND pin PATH to coreutils so a real host wsl.exe can't leak in. +_d=$(make_fixture 1 strix 0 26.04 1) +rm -f "$_d/bin/wsl.exe" +_out=$(run_func "$_d" PATH="$_d/bin:/usr/bin:/bin") +assert_contains "no wsl.exe -> no route" "$_out" "__NOROUTE__" +assert_absent "no wsl.exe -> not rerouted" "$_out" "__ROUTED__" +assert_contains "no wsl.exe -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 20) UNSLOTH_ROCM_WSL_AUTO=1 consent is forwarded as an export into the reroute so +# the child auto-enables the GPU bootstrap instead of the desktop-app prompt. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" UNSLOTH_ROCM_WSL_AUTO=1 \ + UNSLOTH_WSL_REROUTE_CMD='echo auto=[$UNSLOTH_ROCM_WSL_AUTO]') +assert_contains "UNSLOTH_ROCM_WSL_AUTO forwarded to reroute" "$_out" "auto=[1]" +rm -rf "$_d" + +# 21) Both 24.04 and 22.04 installed -> target 24.04 (the only bootstrap-supported one). +_d=$(make_fixture 1 strix 0 26.04 1) +printf 'Ubuntu\nUbuntu-22.04\nUbuntu-24.04\n' > "$_d/distros" +_out=$(run_func "$_d") +assert_contains "both present -> routes" "$_out" "__ROUTED__" +assert_contains "both present -> targets 24.04" "$_out" "-d Ubuntu-24.04" +assert_absent "both present -> does not target 22.04" "$_out" "-d Ubuntu-22.04" +rm -rf "$_d" + +# 22) Only 22.04 installed (no 24.04) -> no route. 22.04 isn't a bootstrap target, +# so stay CPU-only and skip the origin ROCm bootstrap. +_d=$(make_fixture 1 strix 0 26.04 0) +printf 'Ubuntu\nUbuntu-22.04\n' > "$_d/distros" +_out=$(run_func "$_d") +assert_contains "only 22.04 -> no route" "$_out" "__NOROUTE__" +assert_absent "only 22.04 -> not rerouted" "$_out" "__ROUTED__" +assert_contains "only 22.04 -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 23) Neither 24.04 nor 22.04 present -> stay CPU-only and skip the ROCm bootstrap. +_d=$(make_fixture 1 strix 0 26.04 0) +printf 'Ubuntu\nUbuntu-20.04\n' > "$_d/distros" +_out=$(run_func "$_d") +assert_contains "no supported target -> no route" "$_out" "__NOROUTE__" +assert_contains "no supported target -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 24) A custom distro that merely CONTAINS the name (Ubuntu-24.04-test) but has no +# exact Ubuntu-24.04 must NOT be picked (substring match would fail wsl -d). +_d=$(make_fixture 1 strix 0 26.04 0) +printf 'Ubuntu\nUbuntu-24.04-test\n' > "$_d/distros" +_out=$(run_func "$_d") +assert_contains "substring-only distro -> no route" "$_out" "__NOROUTE__" +assert_absent "substring-only distro -> not rerouted" "$_out" "__ROUTED__" +assert_contains "substring-only distro -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +# 25) Exact Ubuntu-24.04 alongside a custom Ubuntu-24.04-test -> route to the exact one. +_d=$(make_fixture 1 strix 0 26.04 0) +printf 'Ubuntu\nUbuntu-24.04-test\nUbuntu-24.04\n' > "$_d/distros" +_out=$(run_func "$_d") +assert_contains "exact + custom -> routes" "$_out" "__ROUTED__" +assert_contains "exact + custom -> targets the exact 24.04" "$_out" "-d Ubuntu-24.04 --" +rm -rf "$_d" + +# 26) Tauri mode: a child exit 2 ([TAURI:NEED_SUDO]) must propagate so the desktop app +# drives elevation for the target distro, not get masked as a CPU fallback here. +_d=$(make_fixture 1 strix 0 26.04 1) +_rc=0 +_out=$(run_func "$_d" TAURI_MODE=true UNSLOTH_WSL_REROUTE_CMD='exit 2') || _rc=$? +if [ "$_rc" = "2" ]; then echo " PASS: tauri child exit 2 -> reroute propagates exit 2"; PASS=$((PASS+1)); else echo " FAIL: tauri exit 2 not propagated (rc=$_rc)"; FAIL=$((FAIL+1)); fi +assert_absent "tauri exit 2 -> not a CPU fallback" "$_out" "__NOROUTE__" +rm -rf "$_d" + +# 27) Non-tauri mode: a child exit 2 is just a failure -> CPU fallback, not propagated. +_d=$(make_fixture 1 strix 0 26.04 1) +_rc=0 +_out=$(run_func "$_d" UNSLOTH_WSL_REROUTE_CMD='exit 2') || _rc=$? +if [ "$_rc" = "0" ]; then echo " PASS: non-tauri exit 2 -> not propagated"; PASS=$((PASS+1)); else echo " FAIL: non-tauri exit 2 wrongly propagated (rc=$_rc)"; FAIL=$((FAIL+1)); fi +assert_contains "non-tauri child fail -> CPU fallback" "$_out" "__NOROUTE__" +assert_contains "non-tauri child fail -> skip ROCm bootstrap" "$_out" "SKIP_ROCM=1" +rm -rf "$_d" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/sh/test_tauri_install_exit_order.sh b/tests/sh/test_tauri_install_exit_order.sh index 49d7c247c8..20df918e70 100644 --- a/tests/sh/test_tauri_install_exit_order.sh +++ b/tests/sh/test_tauri_install_exit_order.sh @@ -1,4 +1,6 @@ #!/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)" @@ -34,7 +36,10 @@ shortcut_guard_end_line=$( early_tauri_exit_line=$( awk -v setup="$first_setup_check_line" ' NR >= setup { exit } - /\[ "\$TAURI_MODE" = true \]/ { + # Only a real "if ...; then" opener starts a Tauri block. Ignore && one-liners + # like the reroute `[ "$TAURI_MODE" = true ] && _rr_args=... --tauri`, whose + # reroute `exit 0` is not an end-of-install success exit. + /^[[:space:]]*if \[ "\$TAURI_MODE" = true \][[:space:]]*;[[:space:]]*then[[:space:]]*$/ { in_tauri = 1 depth = 1 next diff --git a/tests/sh/test_torch_constraint.sh b/tests/sh/test_torch_constraint.sh index 4dc8fe661d..293a709360 100644 --- a/tests/sh/test_torch_constraint.sh +++ b/tests/sh/test_torch_constraint.sh @@ -1,4 +1,6 @@ #!/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 # Tests for TORCH_CONSTRAINT variable in install.sh and tokenizers in no-torch-runtime.txt. # Follows the same assertion pattern as test_mac_intel_compat.sh. set -e diff --git a/tests/sh/test_torch_flavor.sh b/tests/sh/test_torch_flavor.sh index 1f25ad807c..ead2c4164f 100755 --- a/tests/sh/test_torch_flavor.sh +++ b/tests/sh/test_torch_flavor.sh @@ -1,4 +1,6 @@ #!/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 # Unit tests for install.sh's torch-flavor helpers (_torch_flavor_tag, # _expected_torch_flavor_tag, _torch_index_repairable) that drive the # stale-CPU-PyTorch repair. Helpers are extracted from install.sh and sourced. diff --git a/tests/sh/test_uninstall_shared_icon.sh b/tests/sh/test_uninstall_shared_icon.sh new file mode 100644 index 0000000000..e9dca15d5e --- /dev/null +++ b/tests/sh/test_uninstall_shared_icon.sh @@ -0,0 +1,102 @@ +#!/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 +# Unit tests for _drop_shared_icon_if_unused() from scripts/uninstall.sh. +# +# The WSL uninstall shares %LOCALAPPDATA%\Unsloth Studio\unsloth.ico with the native +# install and every other WSL distro's shortcut. Removing one side must KEEP the icon +# while any "Unsloth Studio*.lnk" still references it, and drop it (plus the dir, if +# empty) only once the last shortcut is gone. Reciprocal of uninstall.ps1's +# _RemoveDataDirKeepingWslIcon. Tested hermetically: the function is extracted from +# uninstall.sh and run against per-test fixture dirs. +# +# Follows the extract-via-sed pattern of test_strixhalo_wsl_reroute.sh. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +UNINSTALL_SH="$SCRIPT_DIR/../../scripts/uninstall.sh" +PASS=0 +FAIL=0 + +# All fixtures/temp files live under one root removed on exit, so a set -e abort +# can't leak dirs into $TMPDIR. +_TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$_TMP_ROOT"' EXIT + +assert_file() { _l="$1"; [ -f "$2" ] && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (missing $2)"; FAIL=$((FAIL+1)); }; } +assert_nofile() { _l="$1"; [ -f "$2" ] && { echo " FAIL: $_l (unexpected $2)"; FAIL=$((FAIL+1)); } || { echo " PASS: $_l"; PASS=$((PASS+1)); }; } +assert_dir() { _l="$1"; [ -d "$2" ] && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (missing dir $2)"; FAIL=$((FAIL+1)); }; } +assert_nodir() { _l="$1"; [ -d "$2" ] && { echo " FAIL: $_l (unexpected dir $2)"; FAIL=$((FAIL+1)); } || { echo " PASS: $_l"; PASS=$((PASS+1)); }; } + +# Extract just the function definition (12-space indented in the WSL branch). +FUNC_FILE=$(mktemp -p "$_TMP_ROOT") +sed -n '/_drop_shared_icon_if_unused() {/,/^ }/p' "$UNINSTALL_SH" > "$FUNC_FILE" +# shellcheck disable=SC1090 +. "$FUNC_FILE" + +# make_user [shortcut_relpath] : a fresh fake Windows user dir with unsloth.ico, +# optionally placing an "Unsloth Studio*.lnk" at the given relative path. +make_user() { + _u=$(mktemp -d -p "$_TMP_ROOT") + mkdir -p "$_u/AppData/Local/Unsloth Studio" + : > "$_u/AppData/Local/Unsloth Studio/unsloth.ico" + if [ -n "${1:-}" ]; then + mkdir -p "$_u/$(dirname "$1")" + : > "$_u/$1" + fi + echo "$_u" +} +ICO='AppData/Local/Unsloth Studio/unsloth.ico' +DIR='AppData/Local/Unsloth Studio' +SM='AppData/Roaming/Microsoft/Windows/Start Menu/Programs' + +# 1. A surviving NATIVE shortcut (Start Menu) -> icon and dir kept. +u=$(make_user "$SM/Unsloth Studio.lnk") +_drop_shared_icon_if_unused "$u" +assert_file "native Start Menu shortcut -> icon kept" "$u/$ICO" +assert_dir "native Start Menu shortcut -> dir kept" "$u/$DIR" + +# 2. A surviving native shortcut on the Desktop -> icon kept. +u=$(make_user "Desktop/Unsloth Studio.lnk") +_drop_shared_icon_if_unused "$u" +assert_file "native Desktop shortcut -> icon kept" "$u/$ICO" + +# 3. Another WSL distro's shortcut survives -> icon kept (shared by all distros). +u=$(make_user "Desktop/Unsloth Studio (WSL - OtherDistro).lnk") +_drop_shared_icon_if_unused "$u" +assert_file "other WSL distro shortcut -> icon kept" "$u/$ICO" + +# 4. A shortcut under OneDrive\Desktop is also honored. +u=$(make_user "OneDrive/Desktop/Unsloth Studio.lnk") +_drop_shared_icon_if_unused "$u" +assert_file "OneDrive Desktop shortcut -> icon kept" "$u/$ICO" + +# 5. No shortcut anywhere -> icon removed and the (now empty) dir removed. +u=$(make_user) +_drop_shared_icon_if_unused "$u" +assert_nofile "no shortcut -> icon removed" "$u/$ICO" +assert_nodir "no shortcut -> empty dir removed" "$u/$DIR" + +# 6. No shortcut, but the data dir has another file -> icon removed, dir KEPT. +u=$(make_user) +: > "$u/$DIR/launch-studio.ps1" +_drop_shared_icon_if_unused "$u" +assert_nofile "no shortcut -> icon removed (non-empty dir)" "$u/$ICO" +assert_dir "non-empty dir kept" "$u/$DIR" + +# 7. Missing data dir is a safe no-op (no error, exit 0). +u=$(mktemp -d -p "$_TMP_ROOT") +if _drop_shared_icon_if_unused "$u"; then + echo " PASS: missing data dir -> no-op"; PASS=$((PASS+1)) +else + echo " FAIL: missing data dir errored"; FAIL=$((FAIL+1)) +fi + +# 8. A non-Unsloth .lnk must NOT keep the icon alive. +u=$(make_user "Desktop/Some Other App.lnk") +_drop_shared_icon_if_unused "$u" +assert_nofile "unrelated shortcut -> icon still removed" "$u/$ICO" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] diff --git a/tests/studio/install/test_pr5940_followups.py b/tests/studio/install/test_pr5940_followups.py index 9b739272b1..ac6a96167a 100644 --- a/tests/studio/install/test_pr5940_followups.py +++ b/tests/studio/install/test_pr5940_followups.py @@ -6,9 +6,11 @@ Mock-only; no AMD hardware or network required.""" import importlib.util +import os import re import subprocess import sys +import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -29,6 +31,8 @@ _SPEC.loader.exec_module(prebuilt) _INSTALL_PS1 = PACKAGE_ROOT / "install.ps1" _SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1" _INSTALL_SH = PACKAGE_ROOT / "install.sh" +_AMD_PY = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py" +_PYSTACK_PY = PACKAGE_ROOT / "studio" / "install_python_stack.py" # ── _hf_resolve_url_parts ──────────────────────────────────────────────────── @@ -217,17 +221,23 @@ def test_setup_sh_name_arch_table_in_sync_with_install_sh(): def _amd_smi_allowed_under(system, hipinfo_present, env): - which = ( - (lambda name: r"C:\hip\bin\hipinfo.exe" if name == "hipinfo" else None) - if hipinfo_present - else (lambda name: None) - ) - with ( - patch.object(prebuilt.platform, "system", return_value = system), - patch.object(prebuilt.shutil, "which", side_effect = which), - patch.dict(prebuilt.os.environ, env, clear = True), - ): - return prebuilt._amd_smi_allowed() + # Build a real (temp) PATH so _external_hipinfo_on_path scans it like prod; + # an external hipinfo.exe outside the pinned venv models a real HIP SDK. + with tempfile.TemporaryDirectory() as tmp: + venv_root = os.path.join(tmp, "venv") + os.makedirs(venv_root) + path_env = dict(env) + if hipinfo_present: + sdk_bin = os.path.join(tmp, "hipsdk", "bin") + os.makedirs(sdk_bin) + open(os.path.join(sdk_bin, "hipinfo.exe"), "w").close() + path_env["PATH"] = sdk_bin + os.pathsep + path_env.get("PATH", "") + with ( + patch.object(prebuilt.platform, "system", return_value = system), + patch.object(prebuilt.sys, "prefix", venv_root), + patch.dict(prebuilt.os.environ, path_env, clear = True), + ): + return prebuilt._amd_smi_allowed() def test_amd_smi_allowed_on_linux_regardless(): @@ -261,12 +271,260 @@ def test_amd_smi_opt_out_overrides_hip_sdk(): ) +def test_amd_smi_skipped_when_hipinfo_is_venv_internal(tmp_path): + # The venv hipInfo.exe (AMD wheel via the bnb fix) is NOT a HIP SDK and must + # not re-open the gate -- else amd-smi pops the DiskPart UAC mid-install on + # Strix Halo with no real HIP SDK (the snapcast3r/UBER6 bug). + venv_root = tmp_path / "venv" + venv_scripts = venv_root / "Scripts" + venv_scripts.mkdir(parents = True) + (venv_scripts / "hipinfo.exe").write_text("") + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(venv_root)), + patch.dict(prebuilt.os.environ, {"PATH": str(venv_scripts)}, clear = True), + ): + assert prebuilt._amd_smi_allowed() is False + + +def test_amd_smi_allowed_when_hipinfo_outside_venv(tmp_path): + # A hipinfo from a real HIP SDK (outside the venv) still opens the gate, so + # HIP-SDK Windows users keep amd-smi (no regression for the venv-exclusion). + sdk_bin = tmp_path / "hipsdk" / "bin" + sdk_bin.mkdir(parents = True) + (sdk_bin / "hipinfo.exe").write_text("") + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(tmp_path / "venv")), + patch.dict(prebuilt.os.environ, {"PATH": str(sdk_bin)}, clear = True), + ): + assert prebuilt._amd_smi_allowed() is True + + +def test_amd_smi_allowed_when_external_hipinfo_shadowed_by_venv(tmp_path): + # Venv hipInfo first on PATH (bnb fix), real SDK's later, HIP_PATH/ROCM_PATH + # unset. A first-hit which/Get-Command stops at the venv copy and wrongly + # closes the gate; scanning every PATH entry must still find the external SDK. + venv_root = tmp_path / "venv" + venv_scripts = venv_root / "Scripts" + venv_scripts.mkdir(parents = True) + (venv_scripts / "hipinfo.exe").write_text("") + sdk_bin = tmp_path / "hipsdk" / "bin" + sdk_bin.mkdir(parents = True) + (sdk_bin / "hipinfo.exe").write_text("") + path = str(venv_scripts) + os.pathsep + str(sdk_bin) # venv copy first + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(venv_root)), + patch.dict(prebuilt.os.environ, {"PATH": path}, clear = True), + ): + assert prebuilt._amd_smi_allowed() is True + + +def test_amd_smi_skipped_when_env_root_hipinfo_is_venv_internal(tmp_path): + # The venv exclusion must also cover the HIP_PATH/ROCM_PATH fallback: a hipinfo + # under /_rocm_sdk_core/bin is still not a real HIP SDK. + venv_root = tmp_path / "venv" + hip_root = venv_root / "_rocm_sdk_core" + (hip_root / "bin").mkdir(parents = True) + (hip_root / "bin" / "hipinfo.exe").write_text("") + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(venv_root)), + patch.dict(prebuilt.os.environ, {"ROCM_PATH": str(hip_root)}, clear = True), + ): + assert prebuilt._amd_smi_allowed() is False + + +def test_amd_smi_allowed_when_env_root_hipinfo_outside_venv(tmp_path): + # A real HIP SDK pointed to by HIP_PATH (outside the venv) still opens the + # gate, so the env-root venv filter does not regress HIP-SDK Windows users. + hip_root = tmp_path / "hipsdk" + (hip_root / "bin").mkdir(parents = True) + (hip_root / "bin" / "hipinfo.exe").write_text("") + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(tmp_path / "venv")), + patch.dict(prebuilt.os.environ, {"HIP_PATH": str(hip_root)}, clear = True), + ): + assert prebuilt._amd_smi_allowed() is True + + +def test_external_hipinfo_on_path_skips_venv_only(tmp_path): + # The helper itself: a PATH holding only the venv-internal hipInfo must not + # count as an external HIP SDK (the whole point of the venv filter). + venv_root = tmp_path / "venv" + venv_scripts = venv_root / "Scripts" + venv_scripts.mkdir(parents = True) + (venv_scripts / "hipinfo.exe").write_text("") + with ( + patch.object(prebuilt.sys, "prefix", str(venv_root)), + patch.dict(prebuilt.os.environ, {"PATH": str(venv_scripts)}, clear = True), + ): + assert prebuilt._external_hipinfo_on_path() is False + + +def test_python_hipinfo_gates_scan_all_path_entries(): + # All three copies of the amd-smi HIP-SDK probe must scan every PATH entry + # (not just the first shutil.which hit) so the venv-internal hipInfo cannot + # shadow a real SDK's hipinfo later on PATH. + for src in (_PREBUILT_PATH, _AMD_PY, _PYSTACK_PY): + text = src.read_text(encoding = "utf-8") + assert ( + "_external_hipinfo_on_path" in text + ), f"{src.name} must use the PATH-scanning hipinfo helper" + + +def test_path_inside_venv_resolves_symlinks(tmp_path): + # realpath (not abspath): a venv reached through a symlink/junction must + # still count as inside, else its hipInfo escapes the filter and amd-smi + # pops the DiskPart UAC. abspath would leave the alias unresolved here. + real = tmp_path / "real" + (real / "Scripts").mkdir(parents = True) + (real / "Scripts" / "hipInfo.exe").write_text("") + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory = True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not permitted on this runner") + with patch.object(prebuilt.sys, "prefix", str(real)): + assert prebuilt._path_inside_venv(str(link / "Scripts" / "hipInfo.exe")) is True + + def test_ps_installers_gate_amd_smi_on_windows(): # Both PowerShell installers must gate amd-smi like _amd_smi_allowed(). for ps in (_INSTALL_PS1, _SETUP_PS1): text = ps.read_text(encoding = "utf-8") assert "UNSLOTH_ENABLE_AMD_SMI" in text, f"{ps.name} missing amd-smi opt-in gate" assert "amdSmiAllowed" in text, f"{ps.name} missing amd-smi gate variable" + # The HIP-SDK probe must exclude the venv-internal hipInfo.exe (mirrors + # _path_inside_venv()), else amd-smi can still pop the DiskPart UAC. + assert ( + "Test-HipinfoIsVenvInternal" in text + ), f"{ps.name} missing venv-internal hipinfo exclusion helper" + # Get-Command returns only the first hipinfo; scan -All and skip the venv + # copy so a real SDK hipinfo later on PATH is not shadowed (codex P2). + assert ( + "Get-Command hipinfo -CommandType Application -All" in text + ), f"{ps.name} must enumerate all hipinfo executables on PATH (-CommandType Application -All)" + assert ( + "Test-HipinfoIsVenvInternal $_.Source" in text + ), f"{ps.name} must run the venv exclusion while scanning hipinfo candidates" + # The HIP_PATH/ROCM_PATH candidate must also be venv-filtered, else an env + # var pointing into the venv reopens the gate. + assert ( + "Test-HipinfoIsVenvInternal $hipinfoCandidate" in text + ), f"{ps.name} must run the venv exclusion on the HIP_PATH/ROCM_PATH candidate" + # VenvDir/VIRTUAL_ENV can be unset at probe time (the update flow), so the + # venv root must also be derived from the setup python. + assert ( + "UNSLOTH_SETUP_PYTHON" in text + ), f"{ps.name} venv-internal check must seed the venv root from UNSLOTH_SETUP_PYTHON" + # A custom Studio home moves the venv off the default path; it must be + # seeded too or its hipInfo escapes the filter and reopens the gate. + assert ( + "UNSLOTH_STUDIO_HOME" in text + ), f"{ps.name} venv-internal check must seed the venv root from UNSLOTH_STUDIO_HOME" + + +@pytest.mark.parametrize("ps", [_INSTALL_PS1, _SETUP_PS1], ids = ["install.ps1", "setup.ps1"]) +def test_ps_venv_probe_expands_tilde_for_custom_studio_home(ps): + # The probe seeds the venv root from a custom Studio home; a ~\studio form + # must expand to USERPROFILE like the canonical resolver, else GetFullPath + # keeps the literal ~ (cwd-relative) and the hipInfo escapes the filter. + text = ps.read_text(encoding = "utf-8") + i = text.find("$studioHomeEnv = ") + j = text.find('Join-Path $studioHomeEnv "unsloth_studio"', i) + assert i != -1 and j != -1, f"{ps.name}: studioHomeEnv venv-root seed not found" + block = text[i:j] + assert "USERPROFILE" in block and ".Substring(1)" in block, ( + f"{ps.name}: the venv-internal probe must expand a leading ~ in the custom " + "Studio home before seeding the venv root (mirroring the canonical resolver)" + ) + # The ~ expansion must be guarded on a non-empty USERPROFILE; otherwise + # Join-Path $env:USERPROFILE throws on a service/SYSTEM account with no profile, + # aborting the whole probe (and the install). + assert "IsNullOrWhiteSpace($env:USERPROFILE)" in block, ( + f"{ps.name}: the ~ expansion must guard against an empty $env:USERPROFILE " + "before Join-Path (else it throws on a profile-less account)" + ) + # A bare "~" leaves an empty child path, which Join-Path rejects on PS 5.1, so + # the expansion must fall back to USERPROFILE directly (joining only a non-empty + # remainder) rather than call Join-Path with "". + assert "$studioHomeRest" in block and "else { $env:USERPROFILE }" in block, ( + f"{ps.name}: the ~ expansion must handle a bare ~ without passing an empty " + "child path to Join-Path (PS 5.1 rejects it)" + ) + + +def _ps_floor_map(text, prefix): + # {gfx -> spec} for entries like "gfx1151" = "torchvision>=0.26.0,<0.27.0". + return dict(re.findall(r'"(gfx[0-9a-z]+)"\s*=\s*"(' + re.escape(prefix) + r'[^"]*)"', text)) + + +def test_install_setup_ps_rocm_torch_floors_in_sync(): + # install.ps1/setup.ps1 pull from AMD's per-arch index; their torch and + # companion floor maps must match so both resolve the same ABI-consistent trio + # (install.ps1 once left companions bare -> incompatible set -> CPU). + it = _INSTALL_PS1.read_text(encoding = "utf-8") + st = _SETUP_PS1.read_text(encoding = "utf-8") + for prefix in ("torch>=", "torchvision>=", "torchaudio>="): + i_map = _ps_floor_map(it, prefix) + s_map = _ps_floor_map(st, prefix) + assert i_map, f"install.ps1 has no {prefix!r} floor map" + assert ( + i_map == s_map + ), f"{prefix!r} floor map drift:\ninstall.ps1={i_map}\nsetup.ps1={s_map}" + # Strix Halo (the field case) must be pinned, not bare. + assert _ps_floor_map(it, "torchvision>=").get("gfx1151") == "torchvision>=0.26.0,<0.27.0" + # The ROCm install must pass the pinned companion specs, not bare names. + assert ( + "$torchSpec $visionSpec $audioSpec" in it + ), "install.ps1 ROCm install must use the pinned companion specs" + + +def test_install_ps1_rocm_cpu_fallback_uses_retry(): + # The ROCm->CPU fallback (likeliest to hit a transient index issue) once used + # the non-retrying helper; it must retry like every other torch install here. + text = _INSTALL_PS1.read_text(encoding = "utf-8") + i = text.find("ROCm PyTorch install failed") + assert i != -1, "ROCm->CPU fallback block not found in install.ps1" + window = text[i : i + 600] + assert ( + "Invoke-InstallCommandRetry" in window + ), "the ROCm->CPU fallback torch install must use Invoke-InstallCommandRetry" + # Must --force-reinstall: a failed ROCm install can leave an unpinned ROCm torch + # that still satisfies the CPU torch>= range, so without it uv keeps the ROCm + # build and only swaps companions -> mismatched venv the repair block won't fix. + assert "--force-reinstall" in window, ( + "the ROCm->CPU fallback must --force-reinstall so a partial ROCm torch is " + "replaced by the CPU build" + ) + + +def test_setup_ps1_rocm_cpu_fallback_force_reinstalls(): + # setup.ps1's CPU block is shared with the genuine CPU-only path, so it force- + # reinstalls only after an AMD ROCm fallback ($ROCmCpuFallback) -- evicting a + # partial ROCm torch without slowing the common CPU install. + text = _SETUP_PS1.read_text(encoding = "utf-8") + assert "$ROCmCpuFallback = $true" in text, ( + "setup.ps1 must flag the AMD ROCm->CPU fallback so the CPU install can force-" + "reinstall a partial ROCm torch" + ) + # Build $cpuForce as a real array, NOT via an if-expression: PowerShell collapses + # `$x = if (..) { @("--force-reinstall") }` to a scalar string, which @splat then + # enumerates char-by-char into broken single-letter args (- - f o r c e ...). + assert ( + "$cpuForce = @()" in text + and 'if ($ROCmCpuFallback) { $cpuForce = @("--force-reinstall") }' in text + ), ( + "setup.ps1 must build $cpuForce as an array assigned outside an if-expression " + "so @splat passes a single --force-reinstall arg, not per-character" + ) + assert "$cpuForce = if ($ROCmCpuFallback)" not in text, ( + 'setup.ps1 must NOT assign $cpuForce from an if-expression (collapses @("x") ' + "to a scalar string that @splat explodes char-by-char)" + ) def test_install_python_stack_gates_every_amd_smi_spawn(): @@ -315,5 +573,296 @@ def test_install_python_stack_gates_every_amd_smi_spawn(): ) +def test_install_ps1_installs_rocm_torch_for_known_arch(): + # A known AMD arch (even name-inferred, $HasROCm false) must select the ROCm + # index directly, not a CPU base that setup.ps1 then force-reinstalls as ROCm. + # The repo.amd.com wheels bundle their runtime (no HIP SDK), so gate on $ROCmGfxArch. + text = _INSTALL_PS1.read_text(encoding = "utf-8") + gates = [ + ln + for ln in text.splitlines() + if '$TorchIndexUrl -like "*/cpu"' in ln and ln.lstrip().startswith("if ") + ] + assert gates, "ROCm-index selection gate not found in install.ps1" + assert any("$ROCmGfxArch" in ln for ln in gates), ( + "install.ps1 gates ROCm torch only on probe-verified ROCm ($HasROCm); a " + "known/name-inferred arch should install ROCm directly to avoid a wasted " + "CPU PyTorch base that setup.ps1 immediately force-reinstalls." + ) + + +# ── PR #6296 follow-ups (review-bot findings) ──────────────────────────────── + + +def test_external_hipinfo_strips_quoted_path_entries(tmp_path): + # Windows PATH entries can carry surrounding double quotes; the scan must strip + # them before os.path.join, else a real HIP SDK in a quoted dir is missed and a + # genuine AMD box silently loses amd-smi VRAM polling. + sdk_bin = tmp_path / "hip sdk" / "bin" + sdk_bin.mkdir(parents = True) + (sdk_bin / "hipinfo.exe").write_text("") + quoted = '"' + str(sdk_bin) + '"' # the literal quotes Windows can leave on PATH + with ( + patch.object(prebuilt.platform, "system", return_value = "Windows"), + patch.object(prebuilt.sys, "prefix", str(tmp_path / "venv")), + patch.dict(prebuilt.os.environ, {"PATH": quoted}, clear = True), + ): + assert prebuilt._external_hipinfo_on_path() is True + assert prebuilt._amd_smi_allowed() is True + + +def test_python_hipinfo_strips_quotes_in_all_copies(): + # All three copies of the PATH scan must strip surrounding quotes from entries. + for src in (_PREBUILT_PATH, _AMD_PY, _PYSTACK_PY): + text = src.read_text(encoding = "utf-8") + assert "strip('\"')" in text, f"{src.name} must strip quotes from PATH entries" + + +def test_python_path_inside_venv_guards_root_prefix_in_all_copies(): + # If sys.prefix resolves to a bare root (C:\ or /), commonpath matches every + # path on the filesystem and classifies a real external hipinfo as + # venv-internal, silently disabling amd-smi. All three copies must guard it. + for src in (_PREBUILT_PATH, _AMD_PY, _PYSTACK_PY): + text = src.read_text(encoding = "utf-8") + assert ( + "os.path.dirname(" in text and ") == " in text and "return False" in text + ), f"{src.name} _path_inside_venv must guard a root-dir sys.prefix" + + +def test_path_inside_venv_returns_false_for_root_prefix(): + # Behavioral: with sys.prefix realpath == a bare root, no external path counts as + # inside the venv (so a real HIP SDK on the same drive still opens the gate). + root = "C:\\" if os.name == "nt" else "/" + real = os.path.realpath + with patch.object( + prebuilt.os.path, + "realpath", + side_effect = lambda p: root if p == prebuilt.sys.prefix else real(p), + ): + ext = os.path.join(root, "hip", "bin", "hipinfo.exe") + assert prebuilt._path_inside_venv(ext) is False + + +@pytest.mark.parametrize("ps", [_INSTALL_PS1, _SETUP_PS1], ids = ["install.ps1", "setup.ps1"]) +def test_ps_venv_probe_skips_drive_root(ps): + # A non-venv UNSLOTH_SETUP_PYTHON like C:\Python311\python.exe yields a bare + # drive root (C:) as a venv root; without a guard it matches every path on that + # drive and misclassifies a real HIP SDK as venv-internal, disabling amd-smi. + text = ps.read_text(encoding = "utf-8") + assert "'^[a-zA-Z]:$'" in text, ( + f"{ps.name} venv-internal probe must skip bare drive roots so a non-venv " + "UNSLOTH_SETUP_PYTHON doesn't match the whole drive" + ) + + +@pytest.mark.parametrize("ps", [_INSTALL_PS1, _SETUP_PS1], ids = ["install.ps1", "setup.ps1"]) +def test_ps_env_fallback_iterates_all_hip_roots(ps): + # The HIP_PATH/ROCM_PATH fallback must iterate every env root (incl. HIP_PATH_57) + # and take the first non-venv hipinfo, so a venv-internal HIP_PATH can't mask a + # real SDK in ROCM_PATH (single-root selection would bail on the venv copy). + text = ps.read_text(encoding = "utf-8") + assert 'foreach ($hipEnvLabel in @("HIP_PATH", "HIP_PATH_57", "ROCM_PATH"))' in text, ( + f"{ps.name} must iterate HIP_PATH/HIP_PATH_57/ROCM_PATH in the env fallback, " + "not pick a single root" + ) + + +def test_install_ps1_clears_rocm_index_after_cpu_fallback(): + # After the ROCm->CPU fallback, $ROCmIndexUrl must be cleared so the later + # flavor-repair block doesn't retry the just-failed index and Exit-InstallFailure + # (the fallback lets install complete; setup.ps1 retries ROCm). + text = _INSTALL_PS1.read_text(encoding = "utf-8") + i = text.find("ROCm PyTorch install failed") + assert i != -1, "ROCm->CPU fallback block not found in install.ps1" + window = text[i : i + 1800] + assert "$ROCmIndexUrl = $null" in window, ( + "install.ps1 must clear $ROCmIndexUrl after the CPU fallback so the repair " + "block does not re-trigger the failed ROCm index and abort the install" + ) + + +def test_install_ps1_rocm_repair_pins_companions(): + # The flavor-repair ROCm reinstall must use the pinned companion specs (like the + # fresh ROCm install), not bare torchvision/torchaudio, which can resolve an + # ABI-incompatible trio on AMD's per-arch index. + text = _INSTALL_PS1.read_text(encoding = "utf-8") + i = text.find("PyTorch flavor mismatch (installed $installedTorchTag, need ROCm)") + assert i != -1, "ROCm flavor-repair block not found in install.ps1" + window = text[i : i + 400] + assert "$rocmSpec $visionSpec $audioSpec" in window, ( + "the ROCm repair reinstall must pass the pinned $visionSpec/$audioSpec, not " + "bare torchvision/torchaudio" + ) + + +def test_install_sh_wsl_reroute_uses_pipefail(): + # The `curl | sh` reroute runs via `bash -lc`; without pipefail a failed curl is + # masked by sh exiting 0 on empty input, so the reroute would wrongly report + # success and exit 0 from the parent installer. + text = _INSTALL_SH.read_text(encoding = "utf-8") + assert "set -o pipefail" in text, "reroute must enable pipefail" + # The reroute targets the selected distro ($_rr_target: 24.04 preferred, 22.04 + # fallback) via bash -lc; find that exec line. + i = text.find('wsl.exe -d "$_rr_target" -- bash -lc') + assert i != -1, "WSL reroute command not found in install.sh" + # pipefail is set in the exports prefix the reroute bash -lc runs; the wsl.exe + # call must wire that prefix in (a failed curl is otherwise masked by sh exit 0). + line = text[text.rfind("\n", 0, i) + 1 : text.find("\n", i)] + assert ( + "$_rr_exports" in line + ), "install.sh WSL reroute `bash -lc` must run the pipefail exports prefix" + + +def test_install_sh_wsl_reroute_propagates_tauri_need_sudo_exit(): + # In --tauri mode the rerouted child uses exit 2 ([TAURI:NEED_SUDO]) to ask the + # desktop app to elevate for the target distro. The reroute must propagate that + # code instead of masking it as a generic failure and dropping to CPU here. + text = _INSTALL_SH.read_text(encoding = "utf-8") + i = text.find('wsl.exe -d "$_rr_target" -- bash -lc') + assert i != -1, "WSL reroute command not found in install.sh" + window = text[i : i + 500] + assert ( + '[ "$_rr_rc" -eq 2 ]' in window and "exit 2" in window + ), "the reroute must propagate the child's tauri exit 2 (NEED_SUDO)" + assert '[ "$TAURI_MODE" = true ]' in window, ( + "exit-2 propagation must be gated on --tauri mode so the CLI path still falls " + "back to CPU on a generic reroute failure" + ) + + +def test_uninstall_sh_preserves_shared_icon_for_surviving_shortcut(): + # %LOCALAPPDATA%\Unsloth Studio\unsloth.ico is shared with the native install + # and other WSL distros; both removal paths must keep it while any "Unsloth + # Studio*.lnk" survives (reciprocal of uninstall.ps1's + # _RemoveDataDirKeepingWslIcon), not delete it unconditionally. + text = (PACKAGE_ROOT / "scripts" / "uninstall.sh").read_text(encoding = "utf-8") + assert "_drop_shared_icon_if_unused" in text, ( + "uninstall.sh drvfs path must gate the shared-icon deletion behind a " + "shortcut-in-use check, not delete unconditionally" + ) + assert "iconInUse" in text, ( + "uninstall.sh powershell-interop path must keep the icon when an Unsloth " + "shortcut still uses it" + ) + # An empty $env:LOCALAPPDATA (service/SYSTEM account) makes Join-Path throw and + # aborts the icon cleanup; the interop snippet must guard it like uninstall.ps1. + assert "IsNullOrWhiteSpace($env:LOCALAPPDATA)" in text, ( + "uninstall.sh powershell-interop path must guard an empty $env:LOCALAPPDATA " + "before Join-Path (else cleanup throws on a profile-less account)" + ) + + +def test_uninstall_removes_managed_node_runtime(): + # The isolated Node.js runtime (install_node_prebuilt.py) lives at ~/.unsloth/node + # in default mode, a sibling of studio -- deleting misses it, so both + # uninstallers must remove it explicitly (env/custom mode nests it under the + # custom root, removed with that root). + sh = (PACKAGE_ROOT / "scripts" / "uninstall.sh").read_text(encoding = "utf-8") + assert ( + '_remove_path "$HOME/.unsloth/node"' in sh + ), "uninstall.sh must remove the default-mode ~/.unsloth/node runtime" + ps = (PACKAGE_ROOT / "scripts" / "uninstall.ps1").read_text(encoding = "utf-8") + assert ( + '$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" }' in ps + ), "uninstall.ps1 must resolve the default-mode ~/.unsloth\\node runtime dir" + assert ( + "_RemovePath $defaultNode" in ps + ), "uninstall.ps1 must remove the default-mode ~/.unsloth\\node runtime" + + +def test_install_python_stack_windows_rocm_repair_pins_and_is_nonfatal(): + # The Windows AMD ROCm repair in _ensure_rocm_torch() must mirror the PS + # installer: (1) pin torchvision/torchaudio for the arches the PS side pins so + # the per-arch index resolves an ABI-consistent trio, and (2) be nonfatal so a + # transient index failure doesn't abort the install after the PS side already + # fell back to CPU torch. + text = _PYSTACK_PY.read_text(encoding = "utf-8") + assert ( + "_WINDOWS_ROCM_TORCH_PKG_SPECS" in text + ), "install_python_stack.py must define a Windows per-arch ROCm companion pin map" + for gfx in ("gfx1201", "gfx1200", "gfx1151", "gfx1150"): + assert re.search( + r'"' + gfx + r'":\s*_ROCM_TORCH_PKG_SPECS\["rocm7\.2"\]', text + ), f"{gfx} must pin to the rocm7.2 trio like install.ps1/setup.ps1" + i = text.find('f"ROCm torch (Windows, {gfx_arch})"') + assert i != -1, "Windows ROCm repair pip call not found" + # The nearest preceding call must be the nonfatal pip_install_try, not pip_install. + j = text.rfind("pip_install_try(", 0, i) + k = text.rfind("pip_install(", 0, i) + assert j != -1 and ( + k == -1 or j > k + ), "Windows ROCm repair must use the nonfatal pip_install_try wrapping the trio" + window = text[i : i + 700] + assert ( + "_torch_pkg" in window and "_vision_pkg" in window and "_audio_pkg" in window + ), "Windows ROCm repair must pass the pinned companion trio, not bare names" + assert ( + "keeping the existing torch build" in window + ), "Windows ROCm repair must keep the existing build (nonfatal) when the index fails" + + +def _load_pystack(): + # install_python_stack.py imports from backend.*, so put studio/ on sys.path. + import importlib.util + + studio_dir = str(PACKAGE_ROOT / "studio") + if studio_dir not in sys.path: + sys.path.insert(0, studio_dir) + spec = importlib.util.spec_from_file_location("pystack_pr6296", _PYSTACK_PY) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_windows_rocm_repair_nonfatal_keeps_cpu_torch_on_index_failure(monkeypatch): + # Behavioral: with a Windows AMD box whose per-arch index is down, the repair + # must attempt the pinned trio via the nonfatal helper, NOT call the fatal + # pip_install, and NOT proceed to bitsandbytes -- so the overall install + # survives the index failure with the existing (CPU) torch intact. + try: + ps = _load_pystack() + except Exception as exc: # minimal CI without backend deps + pytest.skip(f"install_python_stack deps unavailable: {exc}") + calls = {"try": [], "fatal": 0, "bnb": 0} + monkeypatch.setattr(ps, "IS_WINDOWS", True) + monkeypatch.setattr(ps, "IS_MACOS", False) + monkeypatch.setattr(ps, "_TORCH_BACKEND", "rocm", raising = False) + monkeypatch.setattr(ps, "_has_usable_nvidia_gpu", lambda: False) + monkeypatch.setattr(ps, "_detect_windows_gfx_arch", lambda: "gfx1151") + monkeypatch.setattr( + ps, "_windows_rocm_index_url", lambda a: "https://repo.amd.com/rocm/whl/gfx1151/" + ) + # torch is not already a ROCm build -> the version probe prints nothing. + monkeypatch.setattr( + ps.subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(a, 0, b"", b"") + ) + + def fake_try(label, *args, **kw): + calls["try"].append((label, args, kw)) + return False # simulate the AMD index being unreachable + + monkeypatch.setattr(ps, "pip_install_try", fake_try) + monkeypatch.setattr( + ps, "pip_install", lambda *a, **k: calls.__setitem__("fatal", calls["fatal"] + 1) + ) + monkeypatch.setattr( + ps, + "_install_bnb_windows_rocm", + lambda *a, **k: calls.__setitem__("bnb", calls["bnb"] + 1) or True, + ) + monkeypatch.delenv("UNSLOTH_ROCM_TORCH_INSTALLED", raising = False) + + ps._ensure_rocm_torch() # must not raise / SystemExit + + assert calls["fatal"] == 0, "Windows ROCm repair must not use the fatal pip_install" + assert len(calls["try"]) == 1, "expected one nonfatal ROCm torch install attempt" + _, args, _ = calls["try"][0] + assert "torch>=2.11.0,<2.12.0" in args, "torch must be pinned to the rocm7.2 floor" + assert "torchvision>=0.26.0,<0.27.0" in args, "torchvision companion must be pinned" + assert "torchaudio>=2.11.0,<2.12.0" in args, "torchaudio companion must be pinned" + assert calls["bnb"] == 0, "a failed ROCm torch install must not proceed to bitsandbytes" + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/studio/test_resolve_cuda_toolkit.ps1 b/tests/studio/test_resolve_cuda_toolkit.ps1 index 77fcdca59c..6be0adc621 100644 --- a/tests/studio/test_resolve_cuda_toolkit.ps1 +++ b/tests/studio/test_resolve_cuda_toolkit.ps1 @@ -1,4 +1,6 @@ #!/usr/bin/env pwsh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Unit test for Resolve-CudaToolkit in studio/setup.ps1. No GPU required: the # detection helpers (nvidia-smi, nvcc, Find-Nvcc, ...) are stubbed so the real # function logic runs against spoofed Blackwell sm_120 driver/toolkit scenarios. diff --git a/tests/studio/test_torch_flavor.ps1 b/tests/studio/test_torch_flavor.ps1 index 86303af16c..50be4814b0 100644 --- a/tests/studio/test_torch_flavor.ps1 +++ b/tests/studio/test_torch_flavor.ps1 @@ -1,4 +1,6 @@ #!/usr/bin/env pwsh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # Unit test for install.ps1's torch-flavor helpers (ConvertTo-TorchFlavorTag, # Get-ExpectedTorchFlavorTag) that drive the stale-CPU-PyTorch repair. Pure # helpers, AST-extracted and run in-process -- no GPU/venv needed. diff --git a/tests/studio/test_uninstall_dual_install_icon.ps1 b/tests/studio/test_uninstall_dual_install_icon.ps1 new file mode 100644 index 0000000000..938db2cb13 --- /dev/null +++ b/tests/studio/test_uninstall_dual_install_icon.ps1 @@ -0,0 +1,87 @@ +#!/usr/bin/env pwsh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# Unit test for _RemoveDataDirKeepingWslIcon in scripts/uninstall.ps1. +# +# A native uninstall must NOT delete the shared unsloth.ico while a WSL shortcut +# still points at it (else that shortcut blanks). Extracts the helper via AST and +# runs it on a temp data dir with a controlled ShortcutDirs list (dual-install vs +# native-only), so no real Desktop / Start Menu is touched. +# +# Run: pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1 + +$ErrorActionPreference = "Stop" +$uninstallPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "..", "scripts", "uninstall.ps1") +$uninstallPath = (Resolve-Path $uninstallPath).Path + +# --- Extract the nested helper function source (not the whole uninstaller) --- +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($uninstallPath, [ref]$tokens, [ref]$errors) +if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "uninstall.ps1 has parse errors" } +$fn = $ast.FindAll({ param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "_RemoveDataDirKeepingWslIcon" +}, $true) +if ($fn.Count -ne 1) { throw "expected exactly one _RemoveDataDirKeepingWslIcon, found $($fn.Count)" } + +# Stubs the helper depends on (nested in Uninstall-UnslothStudio in production). +function _Substep { param([string]$Msg, [string]$Color = "Gray") } +function _RemovePath { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue + } +} +. ([ScriptBlock]::Create($fn[0].Extent.Text)) # defines _RemoveDataDirKeepingWslIcon + +$failures = 0 +function Check($name, $cond) { + if ($cond) { Write-Host " PASS $name" } + else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ } +} + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("undi_" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $work | Out-Null +try { + # ----- Case A: a WSL shortcut survives -> keep unsloth.ico, drop the rest, keep dir ----- + $progA = Join-Path $work "shortcutsA" + New-Item -ItemType Directory -Force -Path $progA | Out-Null + Set-Content -LiteralPath (Join-Path $progA "Unsloth Studio (WSL - Ubuntu-24.04).lnk") -Value "x" + $dataA = Join-Path $work "dataA" + New-Item -ItemType Directory -Force -Path $dataA | Out-Null + Set-Content -LiteralPath (Join-Path $dataA "unsloth.ico") -Value "ICO" + Set-Content -LiteralPath (Join-Path $dataA "launch-studio.ps1") -Value "L" + Set-Content -LiteralPath (Join-Path $dataA "studio.conf") -Value "C" + _RemoveDataDirKeepingWslIcon -DataDir $dataA -ShortcutDirs @($progA) + Check "A: data dir kept" (Test-Path -LiteralPath $dataA) + Check "A: unsloth.ico preserved" (Test-Path -LiteralPath (Join-Path $dataA "unsloth.ico")) + Check "A: launch-studio.ps1 removed" (-not (Test-Path -LiteralPath (Join-Path $dataA "launch-studio.ps1"))) + Check "A: studio.conf removed" (-not (Test-Path -LiteralPath (Join-Path $dataA "studio.conf"))) + + # ----- Case B: no WSL shortcut -> whole dir removed (native-only uninstall) ----- + $progB = Join-Path $work "shortcutsB" + New-Item -ItemType Directory -Force -Path $progB | Out-Null + Set-Content -LiteralPath (Join-Path $progB "Unrelated.lnk") -Value "x" + $dataB = Join-Path $work "dataB" + New-Item -ItemType Directory -Force -Path $dataB | Out-Null + Set-Content -LiteralPath (Join-Path $dataB "unsloth.ico") -Value "ICO" + Set-Content -LiteralPath (Join-Path $dataB "launch-studio.ps1") -Value "L" + _RemoveDataDirKeepingWslIcon -DataDir $dataB -ShortcutDirs @($progB) + Check "B: whole data dir removed" (-not (Test-Path -LiteralPath $dataB)) + + # ----- Case C: empty shortcut dirs -> whole dir removed ----- + $dataC = Join-Path $work "dataC" + New-Item -ItemType Directory -Force -Path $dataC | Out-Null + Set-Content -LiteralPath (Join-Path $dataC "unsloth.ico") -Value "ICO" + _RemoveDataDirKeepingWslIcon -DataDir $dataC -ShortcutDirs @() + Check "C: removed when no shortcut dirs" (-not (Test-Path -LiteralPath $dataC)) + + # ----- Case D: missing data dir -> no-op, no throw ----- + $dataD = Join-Path $work "doesNotExist" + _RemoveDataDirKeepingWslIcon -DataDir $dataD -ShortcutDirs @($progA) + Check "D: missing dir is a safe no-op" (-not (Test-Path -LiteralPath $dataD)) +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($failures -gt 0) { Write-Host ""; Write-Host "FAILED ($failures)" -ForegroundColor Red; exit 1 } +Write-Host ""; Write-Host "All tests passed."; exit 0 diff --git a/unsloth/kernels/moe/tests/run_qwen3_moe_tests.sh b/unsloth/kernels/moe/tests/run_qwen3_moe_tests.sh index ed0b9f6210..6ab91d30a2 100755 --- a/unsloth/kernels/moe/tests/run_qwen3_moe_tests.sh +++ b/unsloth/kernels/moe/tests/run_qwen3_moe_tests.sh @@ -1,4 +1,6 @@ #!/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 -euo pipefail