From e83d4ae0729ab2f523d5fbeb46e5a5bdaff7d42d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 03:09:08 -0700 Subject: [PATCH] Windows installer: fix DiskPart UAC mid-install, drive-root cache, and spurious unsloth.exe rename warning (#6296) * Windows installer: fix DiskPart UAC, drive-root cache, spurious rename warning, CPU-base messaging amd-smi gate (DiskPart UAC mid-install): the AMD torch wheel ships hipInfo.exe inside the venv, and the bitsandbytes fix prepends that venv Scripts dir to PATH. shutil.which("hipinfo") then found it and flipped _amd_smi_allowed() to True, so the post-install AMD probe fell through to `amd-smi list` (the venv hipInfo failed to report gcnArchName, which is why the arch came from the GPU-name table) and amd-smi elevated, popping the DiskPart UAC. Fix: a hipinfo resolved inside the active venv (sys.prefix) is the torch-wheel binary, not a HIP SDK, and must not open the gate. Mirrored in install_python_stack.py, install_llama_prebuilt.py, and backend utils/hardware/amd.py (the runtime VRAM poller had the same latent prompt). TORCHINDUCTOR_CACHE_DIR: move from C:\tc to \TORCHINDUCTOR_CACHE_DIR so the inductor/Triton cache lives under the user's Studio home, not the system drive root. Long paths are already enabled above so deep inductor paths still fit. unsloth.exe rename: skip the rename (and its "pip may fail with WinError 32" warning) when SKIP_STUDIO_BASE=1. In the install.ps1 flow base packages are not reinstalled, so unsloth.exe is never rewritten; the self-rename only failed because setup runs via unsloth.exe (the running launcher holds its own file). The 'studio update' flow still attempts it. CPU PyTorch messaging: clarify that the CPU base is temporary and setup replaces it with GPU ROCm wheels, and print an explicit "GPU ROCm PyTorch installed" line after the AMD wheels land, so the log makes clear the final install is GPU-accelerated. Adds two regression tests covering the venv-internal vs external hipInfo gate. Verified end-to-end on a Strix Halo box (Radeon 8060S / gfx1151): install.ps1 --local from this branch completed exit 0 with no DiskPart prompt, no rename warning, the cache under the Studio home, and "GPU ROCm PyTorch installed (gfx1151)"; Studio then booted and detected "ROCm (HIP 7.13.99004) -- AMD Radeon 8060S Graphics". * Windows installer: drop the unreliable unsloth.exe rename and its WinError 32 warning setup.ps1 used to rename the running unsloth.exe out of the way before the base-package upgrade so pip could replace it. That rename never actually worked: setup runs *via* unsloth.exe, so renaming our own running uv-trampoline launcher failed with a sharing violation (WinError 32) and only printed a scary 'could not rename unsloth.exe; pip may fail with WinError 32' warning on every Windows install and update. It also was not needed. pip tolerates a running/locked console-script .exe: it moves the old one aside and writes the new one. The base upgrade routes through pip on Windows, so the upgrade succeeds (or, in the install.ps1 flow with SKIP_STUDIO_BASE=1, the base is not touched at all) and unsloth.exe is left intact either way. Removing the rename block and its failed-install restore block removes the false warning for all Windows devices in both the install and update flows. * Windows installer: gate venv-internal hipInfo.exe in PowerShell amd-smi probe; harden venv path checks Follow-up to PR #6296. - install.ps1 and setup.ps1: ignore the AMD torch wheel hipInfo.exe that lives inside the Studio venv when probing for a HIP SDK, so amd-smi no longer reopens the DiskPart UAC during install/update. Mirrors _path_inside_venv in the Python installers, which already do this. - amd.py, install_llama_prebuilt.py, install_python_stack.py: normcase the venv containment check (Windows paths are case-insensitive) and run the HIP_PATH/ROCM_PATH candidate through it too. - setup.ps1: fall back to a short TORCHINDUCTOR cache dir when long paths are unavailable, and create the dir wildcard-safely. - tests: isolate sys.prefix in the gate helper, add HIP_PATH/ROCM_PATH cases, and assert the PowerShell venv exclusion. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows installer: install ROCm PyTorch directly for a known AMD arch When the GPU arch is known (name-inferred from the GPU-name table) but ROCm could not be probe-verified (no HIP SDK, no amd-smi), the bootstrap installed a CPU PyTorch base that setup.ps1 then force-reinstalled as ROCm. The repo.amd.com wheels bundle their own runtime (no HIP SDK required), which setup.ps1 already relies on, so the CPU base was a pure wasted download/install. - Gate the ROCm index on a known arch, not only on probe-verified ROCm, so a mapped arch installs ROCm torch directly. Unmapped arches and no-GPU hosts still get CPU (unchanged). - Fall back to a CPU base if the ROCm-index install fails, so a transient repo.amd.com outage does not abort the install (setup.ps1 retries ROCm). - Correct the stale comment that claimed ROCm wheels need a confirmed HIP SDK. - Add a regression test for the arch-based gate. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows installer: correct the unsloth.exe rename-removal comment The comment claimed the base upgrade 'routes through pip on Windows' and that pip 'moves the old unsloth.exe aside, then writes the new one'. That is not what the code does. install_python_stack tries uv first; on a locked launcher uv aborts and falls back to pip, but the pip fallback strips --upgrade-package and base.txt lists only bare unsloth/unsloth-zoo, so pip finds them already satisfied and no-ops. The running unsloth.exe is left intact at its current version either way. Reword the comment to describe the real uv-first / pip-fallback-no-op behavior. No functional change. * Windows installer: close two gaps in the venv-internal hipinfo exclusion Review follow-up. The amd-smi/DiskPart gate could still reopen in two cases: - setup.ps1 ran the HIP probe long before $VenvDir is assigned, so without VIRTUAL_ENV (the `unsloth studio update` path) $venvRoots was empty and the venv-internal hipInfo.exe was not recognized. Seed the venv root from UNSLOTH_SETUP_PYTHON and the default Studio home too (both installers). - The HIP_PATH/ROCM_PATH candidate was accepted without the venv filter, so an env var pointing into the venv (AMD wheel) still set $HipSdkInstalled. Run Test-HipinfoIsVenvInternal on the candidate as well (both installers). Extend the PS gate test to assert both. Both .ps1 parse clean; install tests pass (the venv-internal / HIP probe coverage at 359 passed). * Windows installer: correct the CPU-base message for arches with no ROCm wheels After gating the ROCm index on a known arch, a mapped arch sets $ROCmIndexUrl and installs ROCm directly, so it no longer reaches the "temporary CPU base" branch. That branch is now reached only by a name-inferred arch with no ROCm wheels (e.g. RDNA2 gfx103X), where setup.ps1 does NOT install ROCm. The old text ("setup replaces it with GPU ROCm wheels ... the final install IS GPU-accelerated") was therefore always wrong there. Say plainly that PyTorch stays on CPU for this GPU. * Windows installer: seed the venv-internal hipInfo check from a custom Studio home Test-HipinfoIsVenvInternal seeded the venv root from VIRTUAL_ENV, VenvDir, the setup python, and the default %USERPROFILE% path only. A standalone `unsloth studio update` with a custom UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) and none of those set would not recognize the venv hipInfo on PATH, reopening the amd-smi/DiskPart gate. Seed the custom home too, in both installers, and assert it in the gate test. * Studio installer: resolve venv aliases and expand ~ in the hipInfo venv filter Two review points on the amd-smi/DiskPart UAC gate: 1. _path_inside_venv compared os.path.abspath of sys.prefix and the hipInfo path, which does not resolve symlinks, junctions, or 8.3 short names. A venv reached through an aliased path then fails the check, so its bundled hipInfo.exe is mistaken for an external HIP SDK and amd-smi runs (the DiskPart prompt this fix exists to suppress). Switch to os.path.realpath in all three copies (amd.py, install_llama_prebuilt.py, install_python_stack.py). 2. setup.ps1's early venv-internal hipInfo probe seeded the venv root from a custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME) without expanding a leading ~, while the canonical resolver does. With a tilde form, [IO.Path]::GetFullPath kept the literal ~ relative to cwd, so the custom-home hipInfo escaped the filter and reopened the gate. Expand ~ in the probe the same way as the resolver. tests/studio/install/test_pr5940_followups.py: 30 passed (adds a symlink realpath case and a setup.ps1 tilde-expansion guard). * Studio installer: mirror the hipInfo venv filter and ROCm wheel pins into install.ps1 Follow-up review on the same install.ps1 paths: 1. install.ps1's venv-internal hipInfo probe (Test-HipinfoIsVenvInternal) seeded the venv root from a custom Studio home without expanding a leading ~, unlike the canonical resolver and setup.ps1. A tilde form left [IO.Path]::GetFullPath with the literal ~ (relative to cwd), so the custom-home hipInfo escaped the filter and reopened the amd-smi/DiskPart gate. Expand ~ in the probe, matching the setup.ps1 fix. 2. The AMD ROCm path installed torchvision/torchaudio bare while pinning torch to below 2.12. AMD's per-arch index publishes the companions independently and may ship torchvision 0.27 (for torch 2.12) before removing 0.26, so a bare resolve can pick an ABI-incompatible set and fall back to CPU. Add torchvision/torchaudio floor maps and pass the pinned specs, mirroring setup.ps1 and install_python_stack.py. 3. The ROCm-to-CPU fallback torch install used Invoke-InstallCommand (no retry), the only torch step in the file without it. Switch to Invoke-InstallCommandRetry so the recovery path survives a transient index failure. tests/studio/install/test_pr5940_followups.py: 33 passed (parametrized tilde check over both installers, a torch/companion floor-map parity test, and a CPU-fallback retry guard). * Studio installer: scan all PATH hipinfo so the venv copy can't shadow a real HIP SDK The amd-smi HIP-SDK probe used shutil.which("hipinfo") / Get-Command hipinfo, which return only the first hit on PATH. The AMD torch wheel ships hipInfo.exe inside the venv and the bnb fix (plus the Studio backend) prepend the venv Scripts dir to PATH, so that venv-internal copy lands first. When a real HIP SDK hipinfo sits later on PATH with HIP_PATH/ROCM_PATH unset, the first-hit probe stopped at the venv copy, treated it as "not a HIP SDK", and closed the amd-smi gate -- AMD users in that PATH-only SDK setup lost amd-smi telemetry and could fall back to CPU. Scan every PATH entry and keep the first hipinfo that is not venv-internal; only the venv copy is ignored, so the UAC/DiskPart suppression is unchanged. Applied to all three Python copies (install_llama_prebuilt.py, install_python_stack.py, backend/utils/hardware/amd.py) via a new _external_hipinfo_on_path helper, and both PowerShell callers (install.ps1, setup.ps1) now use Get-Command hipinfo -All filtered by Test-HipinfoIsVenvInternal. tests/studio/install/test_pr5940_followups.py: 36 passed (real-PATH scan tests, a shadow-regression test for the exact venv-first ordering, and a parity check that every Python copy uses the scanning helper). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio uninstallers: fix leftovers (false "removed", shared icon, llama lock) Auditing a dual native+WSL uninstall on a real device surfaced three leftovers: 1. uninstall.ps1 removed the data dir (which holds unsloth.ico) before the shortcuts that reference that icon, so Explorer's icon cache briefly held it open. Remove-Item -Recurse reported success yet left the locked file, and the dir was never re-attempted, so it orphaned with a false "removed" log. _RemovePath now verifies the path is actually gone (retrying transient locks) and reports honestly, and the data dir is re-swept after the shortcuts go. 2. install.sh writes a shared unsloth.ico to %LOCALAPPDATA%\Unsloth Studio for the WSL shortcut, but uninstall.sh never removed it, orphaning the icon (and dir) after a WSL uninstall. uninstall.sh now drops that icon and the dir when empty, in both the powershell.exe and drvfs-fallback paths. 3. ~/.unsloth/.llama.cpp.install.lock was never removed, so the rmdir of ~/.unsloth failed and the dir lingered. Both uninstallers now remove the lock. Verified by running both uninstallers on a real dual install: device fully clean (no install dirs, shortcuts, PATH/registry entries, shared icon, or lock left). * install.sh: auto-route Strix Halo WSL to an existing Ubuntu 24.04 ROCm-on-WSL is the GPU runtime for Strix Halo and only targets Ubuntu 24.04. When the installer runs in a newer default distro (e.g. 26.04) it cannot enable the GPU and silently falls back to CPU. If a 24.04 distro already exists, re-run the install there and stop in the current one so the GPU path is taken without the user having to know about the distro requirement. Runs before venv creation so the wrong distro is left untouched, guards against re-route loops via UNSLOTH_WSL_REROUTED, leaves a working ROCm distro alone (librocdxg present), and skips the GGUF-only / opt-out / non-Strix cases. When no 24.04 distro exists we keep today's behaviour: continue to CPU and print the `wsl --install Ubuntu-24.04` guidance, never auto-downloading a distro. Adds tests/sh/test_strixhalo_wsl_reroute.sh (hermetic: extracts the function, rewrites its paths to fixtures, mocks wsl.exe) covering the full decision matrix, wired into tests/run_all.sh. * uninstall.ps1: keep shared unsloth.ico for a surviving WSL shortcut A dual native+WSL install shares %LOCALAPPDATA%\Unsloth Studio\unsloth.ico: install.sh points the WSL shortcut's icon there while the native install owns the dir. The native uninstaller removed the whole dir unconditionally, so uninstalling native while keeping WSL left the WSL shortcut with a blank icon. The old code only avoided this when Explorer happened to hold the icon open, which is unreliable; on a real dual install the dir was deleted and the WSL shortcut went blank. _RemoveDataDirKeepingWslIcon now scans the Start Menu + Desktop for a surviving "Unsloth Studio (WSL ...).lnk" and, if found, removes everything in the data dir except unsloth.ico (keeping the dir) instead of deleting it; with no WSL shortcut it removes the dir as before. uninstall.sh still drops the icon and the empty dir when WSL itself is uninstalled, so every uninstall order ends clean. Adds tests/studio/test_uninstall_dual_install_icon.ps1 (AST-extracts the helper and runs it against a temp dir with controlled shortcut dirs) covering the dual, native-only, empty, and missing-dir cases, wired into the windows-inference smoke workflow. Verified on a real dual install: native uninstall now keeps unsloth.ico and the WSL shortcut's icon stays intact. * installer: condense AMD/ROCm code comments (no behavior change) Tighten the comments added for the Strix Halo native+WSL installer work so they are shorter and clearer without losing intent: the venv-internal hipInfo amd-smi gate, the ROCm torch/companion floor maps, the WSL 24.04 reroute, and the dual-install uninstall icon handling. Comment-only; code paths unchanged. 107 insertions, 166 deletions across 11 files. * install.sh: run the Strix Halo WSL reroute before any STUDIO_HOME write The reroute fired after mkdir -p "$STUDIO_HOME" and the legacy-venv migration, so rerouting 26.04 -> 24.04 left an empty ~/.unsloth/studio stub in the origin distro (and ran venv migration in the distro about to be abandoned). Move the reroute ahead of the venv section so the origin distro is left untouched, matching the function's own comment. Behavior is identical on every non-reroute path. * installer: fix ROCm CPU-fallback, hipinfo gate edge cases, uninstall icon, WSL 22.04 - install.ps1: clear $ROCmIndexUrl/$ROCmTorchFloor after the CPU fallback so the flavor-repair block does not retry the failed ROCm index and abort the install; pin the ROCm companion specs ($visionSpec/$audioSpec) in the repair path too. - install.ps1 + setup.ps1: skip a bare drive root in Test-HipinfoIsVenvInternal so a non-venv UNSLOTH_SETUP_PYTHON does not match the whole drive; iterate HIP_PATH/HIP_PATH_57/ROCM_PATH and take the first non-venv hipinfo. - amd.py, install_llama_prebuilt.py, install_python_stack.py: strip surrounding quotes from PATH entries before probing for hipinfo. - install.sh: pipefail the WSL reroute curl|sh; do not reroute supported Ubuntu 22.04. - uninstall.sh: keep the shared unsloth.ico while any Unsloth shortcut (native or another WSL distro) still references it, in both the powershell and drvfs paths. - tests: regression coverage for all of the above. * installer: forward reroute options, guard ROCm bootstrap, harden hipinfo gate - install.sh: forward the caller's --package/--python/--verbose/--tauri and a custom UNSLOTH_STUDIO_HOME into the WSL reroute (was a bare default install); bail on --local; run the reroute BEFORE dependency/uv install so the origin distro is left untouched; set UNSLOTH_SKIP_ROCM_WSL_SETUP after a failed reroute so the later ROCm-on-WSL bootstrap does not install into the unsupported origin distro. - install.ps1 + setup.ps1: Get-Command hipinfo -CommandType Application so only real executables match (not an alias/function named hipinfo). - uninstall.ps1: guard $env:APPDATA when building the default shortcut search dirs. - tests: cover option forwarding, --local bail, the bootstrap guard, and the gate change. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * installer: guard origin ROCm bootstrap on every CPU-only fallback; harden ~ expansion WSL reroute: the no-wsl.exe, no-24.04-target and --local fallbacks all tell the user the install continues CPU-only, but only the failed-reroute branch set UNSLOTH_SKIP_ROCM_WSL_SETUP=1. The later _maybe_bootstrap_rocm_wsl gate keys off that flag, so the other three branches could still install ROCm into the unsupported origin distro (e.g. 26.04). Set the skip guard on all of them. Forward UNSLOTH_ROCM_WSL_AUTO into the reroute so a Tauri/consented GPU bootstrap carries through to the rerouted 24.04 child instead of dropping to the prompt path. install.ps1/setup.ps1: guard the venv-probe ~ expansion on a non-empty $env:USERPROFILE so Join-Path does not throw on a profile-less service account. Tests: add no-wsl.exe and UNSLOTH_ROCM_WSL_AUTO reroute cases, the USERPROFILE guard assertion, and route shell-test fixtures through a single trap-cleaned root. * installer: pin + soften Windows ROCm Python repair, reroute to 22.04, harden gates install_python_stack.py: the Windows AMD ROCm repair in _ensure_rocm_torch() installed bare torch/torchvision/torchaudio via the fatal pip_install -- the same asymmetry already fixed on the PowerShell side. A transient repo.amd.com failure could abort the whole install even after install.ps1/setup.ps1 fell back to CPU. Pin companions per-arch (gfx120X/Strix -> the rocm7.2 trio, mirroring the PS floor maps) and make the retry nonfatal: keep the existing build and let the user re-run update to retry ROCm, so the chain install.ps1 -> setup.ps1 -> stack stays CPU-safe. install.sh: reroute now targets an installed Ubuntu 24.04 OR 22.04 (24.04 preferred); both are AMD-supported for ROCm-on-WSL, matching the leave-alone set, so a box with only 22.04 reaches the GPU instead of staying CPU-only. install.ps1/setup.ps1: a bare ~ for UNSLOTH_STUDIO_HOME left an empty Join-Path child (PS 5.1 throws); fall back to USERPROFILE directly and only join a real remainder. _path_inside_venv (amd.py + both installers): guard a root-dir sys.prefix so commonpath can't classify every path on the drive as venv-internal (defensive; venv never at root). uninstall.sh: guard an empty LOCALAPPDATA in the PS-interop icon cleanup (mirror APPDATA). Tests: add 22.04-target reroute cases, Windows ROCm pin+nonfatal coverage (text + behavioral), root-dir guard coverage, and bare-~/LOCALAPPDATA guard assertions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install.sh: match WSL reroute target by exact distro name, not substring The 24.04/22.04 reroute target was chosen with grep -F (substring), so a custom distro such as 'Ubuntu-24.04-test' (with no exact Ubuntu-24.04) was picked as the target; the later 'wsl -d Ubuntu-24.04' then fails and the Strix Halo install stays CPU-only. Match whole lines (grep -ixF) and reuse the matched name so only a real Ubuntu-24.04/22.04 is targeted. Adds substring-rejection + exact-vs-custom tests. * install.sh: keep the WSL reroute target to Ubuntu 24.04 (helper-supported only) The ROCm-on-WSL bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any VERSION_ID other than 24.04 and pins the noble repo, so treating 22.04 as GPU-supported let the parent report a successful reroute while the child fell back to CPU. Drop 22.04 from the supported set and the reroute target list; 24.04 stays the sole target (keeping the exact whole-line distro match). An already-working ROCm on any other version is still left alone by the librocdxg check above. tests: reroute 22.04 cases updated to the 24.04-only behavior; make the "no wsl.exe" case hermetic so a real host wsl.exe can't leak in on dev boxes; stop the tauri exit-order check from mis-flagging the reroute helper's [ "$TAURI_MODE" = true ] && ... --tauri one-liner. * installer: tighten comment wording across the Strix Halo install/uninstall paths Condense the verbose multi-line comment blocks (amd-smi hipinfo gate, ROCm torch install + CPU fallback, WSL reroute, uninstall icon-keep) into fewer, clearer lines. Comments and a few docstrings only; no code, logic, or behavior change. Verified with bash -n, the PowerShell parser, and ast.parse, and the installer test suite still passes. * add AGPL-3.0 SPDX headers to the .sh/.ps1 scripts missing them Every shell and PowerShell script under the Studio/installer surface now carries the standard SPDX-License-Identifier: AGPL-3.0-only + copyright header (after the shebang where present): the installer (install.sh, install.ps1), build.sh, the .github and src-tauri scripts, the installer test suite, and the moe kernel test. Header-only, line endings preserved; bash -n, the PowerShell parser, and the installer tests all pass. * installer: drop the duplicate AGPL header from install.sh and install.ps1 Both already carry an SPDX-License-Identifier: AGPL-3.0-only header below their usage comment block; the prior header pass added a second one at the top because it only scanned the first few lines. Remove the duplicate so each file keeps a single original header. * installer: force-reinstall CPU fallback torch; propagate Tauri NEED_SUDO from reroute install.ps1/setup.ps1: when the AMD ROCm wheel install fails and we fall back to a CPU base, force-reinstall the torch/vision/audio triplet. 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>=2.4,<2.11.0 range, so without --force-reinstall uv keeps the ROCm build and only swaps the companions -- a mismatched venv the flavor-repair block won't fix. setup.ps1 scopes the forced reinstall to the ROCm-fallback path () so the genuine CPU-only install stays fast. install.sh: the Strix Halo WSL reroute treated every nonzero child exit as a reroute failure and fell back to CPU. In --tauri mode the child uses exit 2 ([TAURI:NEED_SUDO]) to ask the desktop app to elevate for the target distro; capture the child's exit code and propagate exit 2 in Tauri mode (the child already printed the NEED_SUDO line) instead of masking it. CLI mode still falls back to CPU on a generic failure. Tests: reroute Tauri exit-2 propagation (and non-Tauri CPU-fallback) cases; run_func now preserves the child exit code; force-reinstall assertions for both PowerShell installers. Note: codex's _rr_q apostrophe finding is a false positive -- the helper already emits POSIX-correct 'O'\''Brien' and round-trips under both sh and bash. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * setup.ps1: fix $cpuForce array collapse in the ROCm->CPU torch fallback An if-expression assignment ($cpuForce = if ($ROCmCpuFallback) { @("--force-reinstall") }) collapses the single-element array to a scalar string, so @cpuForce splatting enumerated it character-by-character into broken single-letter args (- - f o r c e ...), which made uv/pip reject the install and aborted the whole Studio setup on the AMD ROCm->CPU fallback path. Build $cpuForce as a real array assigned outside the if-expression so the splat passes a single --force-reinstall arg. Genuine CPU-only installs stay fast (empty array, no flag). Test now asserts the array-build form and rejects the if-expression form. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * uninstall: remove the isolated Node.js runtime (~/.unsloth/node) The isolated Node.js runtime (install_node_prebuilt.py, added with the managed-Node change) installs to ~/.unsloth/node in default mode -- a sibling of studio, so deleting leaves it behind (~200MB orphaned after uninstall). Both uninstallers already remove the other default-mode siblings (llama.cpp/.cache/.staging); add node alongside them. uninstall.ps1 also adds it to the handle-lock sweep so a held node.exe can't block the delete. Env/custom mode nests node under the custom root, removed with that root. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/scripts/hf-download-with-retry.sh | 2 + .../studio-windows-inference-smoke.yml | 12 + build.sh | 2 + install.ps1 | 147 ++++- install.sh | 94 +++ scripts/uninstall.ps1 | 75 ++- scripts/uninstall.sh | 53 +- studio/backend/utils/hardware/amd.py | 45 +- studio/install_llama_prebuilt.py | 43 +- studio/install_python_stack.py | 80 ++- studio/setup.ps1 | 154 +++-- studio/src-tauri/linux/postremove.sh | 2 + .../windows/sign-with-trusted-signing.ps1 | 2 + tests/run_all.sh | 4 + tests/saving/gpt-oss-merge/run_test.sh | 2 + tests/sh/test_get_torch_index_url.sh | 2 + tests/sh/test_install_host_defaults.sh | 2 + tests/sh/test_mac_intel_compat.sh | 2 + tests/sh/test_nvcc_meets_llama_minimum.sh | 2 + tests/sh/test_strixhalo_wsl_reroute.sh | 323 ++++++++++ tests/sh/test_tauri_install_exit_order.sh | 7 +- tests/sh/test_torch_constraint.sh | 2 + tests/sh/test_torch_flavor.sh | 2 + tests/sh/test_uninstall_shared_icon.sh | 102 ++++ tests/studio/install/test_pr5940_followups.py | 571 +++++++++++++++++- tests/studio/test_resolve_cuda_toolkit.ps1 | 2 + tests/studio/test_torch_flavor.ps1 | 2 + .../test_uninstall_dual_install_icon.ps1 | 87 +++ .../kernels/moe/tests/run_qwen3_moe_tests.sh | 2 + 29 files changed, 1706 insertions(+), 119 deletions(-) create mode 100644 tests/sh/test_strixhalo_wsl_reroute.sh create mode 100644 tests/sh/test_uninstall_shared_icon.sh create mode 100644 tests/studio/test_uninstall_dual_install_icon.ps1 diff --git a/.github/scripts/hf-download-with-retry.sh b/.github/scripts/hf-download-with-retry.sh index c5ee013c8..013a459f4 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 15a1affbc..a997c89b6 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 1558dca24..286664b5e 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 d48c9fa4f..765f33b1f 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 18aa922f0..7a3c49036 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 f0f6e4fdd..88defb9ea 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 e97b28799..31e851fcb 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 27dfe187c..f5b64c45d 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 569431b71..1823a0f67 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 b2de6592c..1c3918f90 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 20d53734a..20f5bd1ad 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 e721c3983..745f96f10 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 405caefc5..c0d7fd517 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 4311aa427..2c495f556 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 5a91b3135..28e9c4979 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 3dece1f69..665614262 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 7b01eaa71..8e1278b6e 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 673fd58cd..3c3bbfaa5 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 f614c0403..2a2ac9966 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 000000000..7edf475cc --- /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 49d7c247c..20df918e7 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 4dc8fe661..293a70936 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 1f25ad807..ead2c4164 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 000000000..e9dca15d5 --- /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 9b739272b..ac6a96167 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 77fcdca59..6be0adc62 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 86303af16..50be4814b 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 000000000..938db2cb1 --- /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 ed0b9f621..6ab91d30a 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