install: tighten comments in the WSL fallback paths

This commit is contained in:
Daniel Han 2026-07-20 05:22:20 +00:00
commit 7b1ca46652
8 changed files with 244 additions and 308 deletions

View file

@ -97,11 +97,9 @@ function Install-UnslothStudio {
if ($TauriMode) {
exit $Code
}
# -File ignores $LASTEXITCODE on plain return, so `exit` carries the code; under
# `irm | iex` (no $PSCommandPath) `exit` would kill the user's shell. There, set
# the var for callers that check it, then raise a terminating error: interactive
# shells survive a throw and just print it, while `-Command "irm ... | iex"`
# automation exits 1 (a plain return would report success on fatal errors).
# -File: `exit` carries the code. Under `irm | iex` (no $PSCommandPath) `exit`
# would kill the user's shell, so set the var then throw: interactive shells
# survive it, `-Command "irm ... | iex"` automation exits 1 (return would look OK).
if ($PSCommandPath) {
exit $Code
}
@ -2059,13 +2057,13 @@ exit 0
$TorchIndexUrl = Get-TorchIndexUrl
# ===== Windows-on-ARM + NVIDIA GPU -> automatic WSL2 fallback (N1X "RTX Spark" / DGX Spark-class) =====
# win_arm64 has no CUDA PyTorch/Triton wheel, so run the Linux installer inside WSL2 (full GPU) plus a
# Windows `unsloth` shim forwarding into it; x86_64 / ARM64-without-NVIDIA unaffected, and the probe
# below keeps the native install if a win_arm64 CUDA wheel ever ships.
# win_arm64 has no CUDA PyTorch/Triton wheel, so run the Linux installer inside WSL2 (full
# GPU) plus a Windows `unsloth` shim into it; x86_64 / ARM64-without-NVIDIA unaffected, and
# the probe below keeps the native install if a win_arm64 CUDA wheel ever ships.
# Opt out: UNSLOTH_NO_WSL_FALLBACK=1; pick distro with UNSLOTH_WSL_DISTRO.
try { $_winArm64 = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ieq 'Arm64') } catch { $_winArm64 = $false }
# x64-emulated PS on ARM reports X64/AMD64; Win32_Processor.Architecture (12=ARM64) and machine-level
# PROCESSOR_ARCHITECTURE read the true arch. Only ever turns $_winArm64 ON.
# x64-emulated PS on ARM reports X64/AMD64; Win32_Processor.Architecture (12=ARM64) and
# machine-level PROCESSOR_ARCHITECTURE read the true arch. Only ever turns $_winArm64 ON.
if (-not $_winArm64) {
try { if ((@(Get-CimInstance Win32_Processor -ErrorAction Stop))[0].Architecture -eq 12) { $_winArm64 = $true } } catch {}
}
@ -2078,17 +2076,16 @@ exit 0
$_nativeCudaTorchOk = $false
if ($_winArm64 -and $HasNvidiaSmi -and (-not $SkipTorch)) {
# uv resolves for the interpreter's platform tags, so an x64-emulated venv
# python resolves the existing win_amd64 CUDA wheels and would "prove" a
# native wheel WoA can't actually use. Only a real win_arm64 interpreter
# can prove a win_arm64 CUDA wheel; anything else keeps the WSL fallback.
# python would match the win_amd64 CUDA wheels WoA can't use. Only a real
# win_arm64 interpreter proves a win_arm64 CUDA wheel; else keep the WSL fallback.
$_pyArch = ""
try { $_pyArch = (& $VenvPython -c "import platform; print(platform.machine())" 2>$null | Select-Object -First 1) } catch {}
if ("$_pyArch" -imatch 'ARM64') {
# Probe the SAME spec as the real install ("torch>=2.4,<2.11.0"); a bare `torch` probe could
# match an out-of-range wheel, skipping WSL only to fail the real pinned install.
# Probe the real install's exact spec; a bare `torch` could match an
# out-of-range wheel, skipping WSL only to fail the real pinned install.
$prevEapProbe = $ErrorActionPreference; $ErrorActionPreference = "Continue"
# --reinstall: an installed (e.g. CPU-only) torch mustn't satisfy the probe -- it must prove
# a native win_arm64 CUDA wheel exists on the index.
# --reinstall: an installed (e.g. CPU-only) torch mustn't satisfy the probe;
# it must prove a native win_arm64 CUDA wheel exists on the index.
$global:LASTEXITCODE = -1
try {
& uv pip install --python $VenvPython --dry-run --reinstall "torch>=2.4,<2.11.0" --default-index $TorchIndexUrl *> $null
@ -2101,34 +2098,33 @@ exit 0
step "wsl" "Windows on ARM + NVIDIA, native CUDA unavailable -- routing GPU setup through WSL2"
substep "no win_arm64 CUDA PyTorch/Triton yet; WSL2 delivers full GPU (DGX Spark / RTX Spark path)." "Yellow"
# The Tauri desktop app launches its backend from a Windows venv (resolve_backend_binary), not
# WSL, so a WSL-only install would start nothing -- send those users to the CLI installer.
# The Tauri desktop app launches its backend from a Windows venv, not WSL, so a
# WSL-only install would start nothing -- send those users to the CLI installer.
if ($TauriMode) {
# A prior native Studio venv was already rolled aside (Start-StudioVenvRollback,
# ~L1444) before we got here; restore it so rejecting this path doesn't orphan
# the user's working install. No-op when nothing was rolled aside.
# A prior native Studio venv was rolled aside (Start-StudioVenvRollback) before
# here; restore it so rejecting this path doesn't orphan the user's working
# install. No-op when nothing was rolled aside.
Restore-StudioVenvRollback
return (Exit-InstallFailure "Windows-on-ARM + NVIDIA GPU needs the WSL2 GPU install, which the desktop app can't launch yet. Install from PowerShell instead: irm https://unsloth.ai/install.ps1 | iex" 1)
}
# --local installs the Windows checkout editably (uv pip install -e $RepoRoot) on the native
# path, but the WSL tunnel installs from PyPI / a git ref and never mounts $RepoRoot -- so a
# --local run here would silently install the published package inside WSL and report success.
# Reject it and point at the supported pre-merge mechanism (push the branch + UNSLOTH_INSTALL_REF).
# --local installs the Windows checkout editably, but the WSL tunnel installs from
# PyPI / a git ref and never mounts $RepoRoot -- so --local here would silently
# install the published package in WSL and report success. Reject it and point at
# the supported pre-merge mechanism (push the branch + UNSLOTH_INSTALL_REF).
if ($StudioLocalInstall) {
Restore-StudioVenvRollback # see TauriMode note above: don't orphan a rolled-aside venv
return (Exit-InstallFailure "--local can't be honored on Windows-on-ARM + NVIDIA: the GPU install runs inside WSL2 and installs from a published/git ref, not this Windows checkout. For pre-merge testing, push your branch and set UNSLOTH_INSTALL_REF, e.g.: `$env:UNSLOTH_INSTALL_REF='<branch>'; irm https://unsloth.ai/install.ps1 | iex" 1)
}
# A custom Studio root (UNSLOTH_STUDIO_HOME / STUDIO_HOME) only applies to the native Windows
# layout; the WoA GPU install lives inside WSL at /root/.unsloth and the shim/verification paths
# are fixed there. Don't pretend to honor it -- warn so the user isn't misled into thinking Studio
# landed at their custom path (the uninstaller still cleans the WSL install regardless).
# A custom Studio root only applies to the native Windows layout; the WoA GPU
# install lives in WSL at /root/.unsloth with fixed shim/verification paths. Warn
# rather than pretend to honor it (the uninstaller still cleans the WSL install).
if ($envOverride) {
substep "note: $envOverrideVar='$envOverride' is not used for the Windows-on-ARM WSL install -- Studio installs inside WSL at /root/.unsloth." "Yellow"
}
# --with-llama-cpp-dir names a Windows-side llama.cpp, but this install runs
# llama.cpp inside WSL2 and would silently ignore the user's explicit binary
# choice. Reject like --local and point at the supported WSL-side pins.
# --with-llama-cpp-dir names a Windows-side llama.cpp, but this install runs it
# inside WSL2 and would silently ignore the choice. Reject like --local and point
# at the supported WSL-side pins.
if ($WithLlamaCppDir -or $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR) {
Restore-StudioVenvRollback
return (Exit-InstallFailure "--with-llama-cpp-dir / UNSLOTH_LOCAL_LLAMA_CPP_DIR can't be honored on Windows-on-ARM + NVIDIA: llama.cpp runs inside WSL2 and can't use a Windows path. Remove it, or pin the WSL-side build with UNSLOTH_LLAMA_TAG or UNSLOTH_LLAMA_PR instead." 1)
@ -2153,15 +2149,14 @@ exit 0
substep "in an ADMINISTRATOR PowerShell run: wsl --install" "Cyan"
substep "reboot, then re-run: irm https://unsloth.ai/install.ps1 | iex" "Cyan"
}
# Deferred until reboot: signal not-complete through Exit-InstallFailure
# (restores the rolled-aside venv, exits 1 for -File, throws under iex so
# -Command automation cannot see a deferred setup as success).
# Deferred until reboot: fail via Exit-InstallFailure so automation can't
# read a deferred setup as success (restores venv, exits 1 / throws).
return (Exit-InstallFailure "WSL setup deferred: enable WSL2 and reboot, then re-run the installer")
}
$distro = if ($env:UNSLOTH_WSL_DISTRO) { $env:UNSLOTH_WSL_DISTRO } else { "Ubuntu-24.04" }
# For cmd-context uses (.cmd shim, copy-paste hints): wsl.exe rejects a QUOTED space-free name
# (WSL_E_DISTRO_NOT_FOUND on 2.x) but splits a bare spaced one after -d, so quote ONLY when spaced.
# For cmd-context uses (.cmd shim, hints): wsl.exe rejects a QUOTED space-free name
# but splits a bare spaced one after -d, so quote ONLY when spaced.
$_distroArg = if ($distro -match '\s') { '"' + $distro + '"' } else { $distro }
# Detect the distro by exit code (encoding-proof; wsl --list emits UTF-16 that PS mis-parses).
$haveDistro = $false
@ -2169,15 +2164,14 @@ exit 0
try { & wsl.exe -d $distro -- true *> $null; if ($LASTEXITCODE -eq 0) { $haveDistro = $true } } catch {}
if (-not $haveDistro) {
substep "installing WSL distro '$distro' (first time only)..." "Cyan"
# New distros install at the global default WSL version; force 2 so a WSL1-default
# host doesn't get a GPU-less distro (would fail only at torch.cuda).
# Force version 2 so a WSL1-default host doesn't get a GPU-less distro
# (would fail only at torch.cuda).
$global:LASTEXITCODE = -1
try { & wsl.exe --set-default-version 2 *> $null } catch {}
try { & wsl.exe --install -d $distro --no-launch } catch {}
}
# Verify WSL2 for pre-existing AND freshly installed distros: set-default-version
# can fail silently (old WSL builds), leaving a fresh WSL1 distro that would only
# fail at the final torch.cuda check. Detect from inside (encoding-proof, unlike
# Verify WSL2 (set-default-version can fail silently on old builds, leaving a WSL1
# distro that only fails at torch.cuda). Detect from inside (encoding-proof, unlike
# UTF-16 `wsl -l -v`) and convert in place -- `wsl --set-version` preserves files.
$_wsl2Probe = 'grep -qiE ''microsoft-standard|WSL2'' /proc/version 2>/dev/null || test -e /usr/lib/wsl/lib/libcuda.so'
$_isWsl2 = $false
@ -2196,75 +2190,62 @@ exit 0
substep "'$distro' converted to WSL2." "Green"
}
substep "installing Unsloth Studio inside WSL '$distro' with full GPU (this downloads PyTorch)..." "Cyan"
# Non-main ref: fetch + export THAT ref so the WSL venv gets the branch's setup.sh + patches
# (else install.sh pulls PyPI unsloth). main == plain unsloth.ai/install.sh.
# Non-main ref: fetch + export THAT ref so the WSL venv gets the branch's setup.sh
# + patches (else install.sh pulls PyPI unsloth). main == plain install.sh.
$_instRef = Get-UnslothInstallRef
# The ref is spliced into the inner `bash -lc` twice (an `export` and a raw GitHub URL), so a
# hand-set UNSLOTH_INSTALL_REF containing shell metacharacters (`;`, `&`, `'`, spaces) would
# break/inject the command. git refs can't contain those anyway; enforce a strict allow-list
# (letters, digits, `.` `_` `/` `-`) and reject loudly rather than silently mangle the install.
# The ref is spliced into the inner `bash -lc` twice, so shell metacharacters would
# inject. git refs can't contain those anyway; enforce a strict allow-list and
# reject loudly rather than silently mangle the install.
if ($_instRef -ne 'main' -and ($_instRef -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$')) {
Restore-StudioVenvRollback # see TauriMode note above: don't orphan a rolled-aside venv
return (Exit-InstallFailure "UNSLOTH_INSTALL_REF='$_instRef' is not a valid git ref (allowed: letters, digits, '.', '_', '/', '-'). Set it to a real branch or tag name." 1)
}
# UNSLOTH_WSL_LLAMA_DEFERRED=1: setup.sh skips its foreground CUDA llama.cpp build since we build it
# in the background. apt stderr stays visible (only stdout -> /dev/null) so failures are diagnosable.
# Forward UNSLOTH_NO_LLAMA_CUDA into WSL: it also skips the dispatch below, so unforwarded setup.sh
# would defer to a background builder that never starts (no llama-server).
# UNSLOTH_WSL_LLAMA_DEFERRED=1: setup.sh skips its foreground CUDA llama.cpp build;
# we build it in the background. apt stderr stays visible so failures are diagnosable.
# Forward UNSLOTH_NO_LLAMA_CUDA (it also skips the dispatch below, so unforwarded
# setup.sh would defer to a background builder that never starts).
$_fwdEnv = ''
if ($env:UNSLOTH_NO_LLAMA_CUDA -eq '1') { $_fwdEnv = 'export UNSLOTH_NO_LLAMA_CUDA=1; ' }
# Forward a user Python pin (install.sh reads UNSLOTH_PYTHON, but Windows env vars don't cross
# into WSL unless bridged). Numeric-only guard (e.g. 3.12) prevents injection.
# Forward a user Python pin (Windows env vars don't cross into WSL unless bridged).
# Numeric-only guard (e.g. 3.12) prevents injection.
if ($env:UNSLOTH_PYTHON -and ($env:UNSLOTH_PYTHON -match '^[0-9][0-9.]*$')) { $_fwdEnv += "export UNSLOTH_PYTHON=$($env:UNSLOTH_PYTHON); " }
# Forward a custom PyTorch wheel mirror. install.sh reads UNSLOTH_PYTORCH_MIRROR (get_torch_index_url)
# but, like the other Windows env vars, it doesn't cross into WSL -- so a mirror-required / restricted-
# network install would silently fall back to download.pytorch.org inside the distro even though the
# outer installer honored the mirror. Strict http(s)-URL allow-list (no shell metachars) + single-quote
# so the value can't break out of the bash -lc string.
# Forward a custom PyTorch wheel mirror (doesn't cross into WSL, so a restricted-
# network install would silently fall back to download.pytorch.org). Strict http(s)
# allow-list + single-quote so the value can't break out of the bash -lc string.
if ($env:UNSLOTH_PYTORCH_MIRROR -and ($env:UNSLOTH_PYTORCH_MIRROR -match '^https?://[A-Za-z0-9._~:/?#@%+=&-]+$')) {
$_fwdEnv += "export UNSLOTH_PYTORCH_MIRROR='$($env:UNSLOTH_PYTORCH_MIRROR)'; "
}
# Forward the npm mirror the same way: setup.sh threads UNSLOTH_NPM_REGISTRY
# into every npm/bun install, and on mirror-required networks the WSL
# frontend/OXC steps would otherwise hit registry.npmjs.org and fail the
# install. Same strict http(s) allow-list + single-quote as above.
# Forward the npm mirror the same way (else the WSL frontend/OXC steps hit
# registry.npmjs.org and fail on mirror-required networks). Same allow-list as above.
if ($env:UNSLOTH_NPM_REGISTRY -and ($env:UNSLOTH_NPM_REGISTRY -match '^https?://[A-Za-z0-9._~:/?#@%+=&-]+$')) {
$_fwdEnv += "export UNSLOTH_NPM_REGISTRY='$($env:UNSLOTH_NPM_REGISTRY)'; "
}
# Forward an explicit UNSLOTH_PYTHON pin: Windows env vars do not cross into
# WSL, so without this the inner install.sh silently built the venv on its
# default Python while the installer reported success. Strict version shape
# so the splice into bash -lc cannot break out; the default stays install.sh's.
# Forward an explicit UNSLOTH_PYTHON pin (env vars don't cross into WSL, so without
# this install.sh silently built the venv on its default Python). Strict version
# shape so the splice into bash -lc can't break out; default stays install.sh's.
if ($env:UNSLOTH_PYTHON -and ($env:UNSLOTH_PYTHON -match '^\d+\.\d+(\.\d+)?$')) {
$_fwdEnv += "export UNSLOTH_PYTHON='$($env:UNSLOTH_PYTHON)'; "
}
# install.ps1 owns the WoA shortcut (one canonical "Unsloth Studio.lnk" with a
# %USERPROFILE%\.unsloth icon that renders on WoA). Tell install.sh to skip its own
# WSL .lnk so we don't get a duplicate whose %LOCALAPPDATA% icon renders blank.
# Persist the skip as a marker file too: `unsloth studio update` reruns
# install.sh --shortcuts-only through the wsl.exe shim, which carries no env,
# so without the marker the first update would recreate the duplicate .lnk.
# Clear the completion stamp from any previous install: setup.sh rewrites
# it only after the core venv + Studio deps finish, and the post-run gate
# below requires it, so a run that dies mid-install can no longer coast on
# a stale venv passing the torch/CLI probes.
# install.ps1 owns the WoA shortcut; tell install.sh to skip its own WSL .lnk so we
# don't get a duplicate whose %LOCALAPPDATA% icon renders blank. Persist a marker
# too: `unsloth studio update` reruns install.sh through the wsl.exe shim (no env),
# so without it the first update recreates the duplicate .lnk.
# Clear any previous completion stamp: setup.sh rewrites it only after the core venv
# + Studio deps finish and the post-run gate below requires it, so a run that dies
# mid-install can no longer coast on a stale venv passing the torch/CLI probes.
$_fwdEnv += 'export UNSLOTH_SKIP_WSL_WINDOWS_SHORTCUT=1; mkdir -p /root/.unsloth; touch /root/.unsloth/.skip-wsl-windows-shortcut; rm -f /root/.unsloth/.install-ok; '
# Root login shells reset PATH via /etc/profile and can drop /usr/lib/wsl/lib,
# the only nvidia-smi location under WSL2 GPU-PV; without it install.sh's GPU
# detection picks CPU torch wheels and the torch.cuda probe then fails the
# whole install. Appended (not prepended) so a PATH nvidia-smi still wins.
# Root login shells reset PATH and can drop /usr/lib/wsl/lib, the only nvidia-smi
# location under WSL2 GPU-PV; without it install.sh picks CPU torch wheels and the
# torch.cuda probe fails. Appended (not prepended) so a PATH nvidia-smi still wins.
$_fwdEnv += 'export PATH="$PATH:/usr/lib/wsl/lib"; '
# Forward a non-default --package into the WSL install (already validated
# against ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ at parse time, so splicing is safe);
# Forward a non-default --package (validated at parse time, so splicing is safe);
# previously it was silently dropped and the user got stock unsloth.
$_shArgs = ''
if ($PackageName -ne 'unsloth') { $_shArgs = ' --package ' + $PackageName }
# Download to a file instead of `curl | sh`: a failed download feeds sh an
# empty stdin (exit 0), and on a rerun the stale venv then passes the torch
# probe below, reporting success without the installer ever running. Exit 86
# is the "download failed, installer never ran" sentinel checked after the
# run. /root/.unsloth already exists (skip-marker mkdir above) and the file
# is removed with it on uninstall.
# Download to a file instead of `curl | sh`: a failed download feeds sh empty
# stdin (exit 0) and a rerun's stale venv then passes the torch probe, faking
# success without the installer running. Exit 86 is the "download failed" sentinel
# checked after the run. /root/.unsloth exists already and is removed on uninstall.
if ($_instRef -eq 'main') {
$wslInstall = $_fwdEnv + 'export DEBIAN_FRONTEND=noninteractive UNSLOTH_WSL_LLAMA_DEFERRED=1; apt-get update -y >/dev/null; apt-get install -y build-essential cmake git curl pciutils libcurl4-openssl-dev >/dev/null; curl -fsSL https://unsloth.ai/install.sh -o /root/.unsloth/unsloth-install.sh || exit 86; sh /root/.unsloth/unsloth-install.sh' + $_shArgs
} else {
@ -2282,15 +2263,14 @@ exit 0
$ErrorActionPreference = $prevEapWsl
}
Write-Host ""
# Sentinel from the download step above: the installer never ran, so the
# probes below would only re-validate a stale venv from a previous install.
# Download sentinel: the installer never ran, so the probes below would only
# re-validate a stale venv from a previous install.
if ($wslRc -eq 86) {
step "wsl" "could not download install.sh inside WSL (network or bad ref) -- the installer never ran." "Yellow"
# Exit-InstallFailure restores the rollback and fails the process in every
# invocation mode (exit for -File, throw for iex/-Command automation).
# Exit-InstallFailure restores the rollback and fails in every invocation mode.
return (Exit-InstallFailure "could not download install.sh inside WSL; the installer never ran")
}
# $wslRc can be non-zero from the llama.cpp prebuilt step even on success, so verify torch.cuda directly.
# $wslRc can be non-zero from the llama.cpp step even on success; verify torch.cuda directly.
$torchOk = $false
$prevEapChk = $ErrorActionPreference
$ErrorActionPreference = "Continue"
@ -2300,10 +2280,9 @@ exit 0
& wsl.exe -d $distro --cd /root -u root -- /root/.unsloth/studio/unsloth_studio/bin/python -c "import torch,sys; sys.exit(0 if torch.cuda.is_available() else 3)" *> $null
$torchOk = ($LASTEXITCODE -eq 0)
} catch {} finally { $ErrorActionPreference = $prevEapChk }
# torch.cuda alone isn't success: install.sh can exit after PyTorch but before the `unsloth`
# package/console script (e.g. a transient `uv pip install unsloth`), and $wslRc can't tell
# (it also goes non-zero on the optional llama prebuilt step). Verify the exact binary the shim
# execs exists -- else we'd write a dangling shim and report a broken install as success.
# torch.cuda alone isn't success: install.sh can exit after PyTorch but before the
# `unsloth` console script, and $wslRc can't tell. Verify the exact binary the shim
# execs exists -- else we'd write a dangling shim and report a broken install as OK.
if ($torchOk) {
$prevEapCli = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$global:LASTEXITCODE = -1
@ -2314,12 +2293,10 @@ exit 0
$torchOk = $false
}
}
# Require the completion stamp setup.sh writes after the core venv +
# Studio deps finish (cleared above before the run). torch + CLI alone
# can both come from a stale venv left by a PREVIOUS install while this
# run's installer died mid-way; the tolerated nonzero $wslRc (optional
# llama.cpp step) makes that indistinguishable by exit code. Existence
# only, no mtime compare: WSL and Windows clocks can skew.
# Require the completion stamp setup.sh writes after the core venv + Studio deps
# finish (cleared before the run). torch + CLI alone can both come from a stale
# PREVIOUS install while this run died mid-way, which the tolerated nonzero $wslRc
# can't distinguish. Existence only, no mtime: WSL/Windows clocks can skew.
if ($torchOk) {
$prevEapStamp = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$global:LASTEXITCODE = -1
@ -2330,9 +2307,9 @@ exit 0
$torchOk = $false
}
}
# Self-heal web-server deps: a cut-short install.sh "studio deps" step leaves torch + unsloth but
# no fastapi/uvicorn/structlog/starlette (`unsloth studio` dies). Reinstall them unpinned (no
# huggingface-hub/transformers/datasets) so the verified GPU torch stack stays intact.
# Self-heal web-server deps: a cut-short "studio deps" step leaves torch + unsloth
# but no fastapi/uvicorn/structlog/starlette (`unsloth studio` dies). Reinstall them
# unpinned (no hf-hub/transformers/datasets) so the verified GPU torch stack stays.
if ($torchOk) {
$_studioPy = "/root/.unsloth/studio/unsloth_studio/bin/python"
$_serverOk = $false
@ -2343,9 +2320,9 @@ exit 0
} catch {} finally { $ErrorActionPreference = $prevEapS }
if (-not $_serverOk) {
substep "Studio web-server deps incomplete (install.sh step cut short) -- installing them now..." "Cyan"
# studio.txt minus the huggingface-hub pin; uv preferred, pip fallback. Bare names only:
# `>=` would become a redirection through PowerShell -> wsl.exe -> bash -lc, and latest-of-each
# satisfies the studio.txt minimums anyway.
# studio.txt minus the hf-hub pin; uv preferred, pip fallback. Bare names
# only: `>=` would become a redirection through PS -> wsl.exe -> bash -lc,
# and latest-of-each satisfies the studio.txt minimums anyway.
$_deps = 'typer fastapi uvicorn matplotlib pandas nest_asyncio pyjwt easydict addict structlog diceware ddgs cryptography httpx fastmcp sqlite-vec pymupdf python-docx'
$_repair = 'PY=/root/.unsloth/studio/unsloth_studio/bin/python; UV="$(command -v uv 2>/dev/null || echo /root/.local/bin/uv)"; if [ -x "$UV" ] || command -v uv >/dev/null 2>&1; then "$UV" pip install --python "$PY" ' + $_deps + '; else "$PY" -m pip install ' + $_deps + '; fi'
$prevEapR = $ErrorActionPreference; $ErrorActionPreference = "Continue"
@ -2357,16 +2334,15 @@ exit 0
} catch {} finally { $ErrorActionPreference = $prevEapS2 }
if ($_serverOk) { substep "Studio web-server deps installed." "Green" }
else {
# The missing set includes typer, so even the plain unsloth CLI
# dies; creating shims and reporting success over that state
# advertises commands that cannot run. Route to the failure
# path (rollback + non-zero), like the CLI-missing case above.
# The missing set includes typer, so even the plain unsloth CLI dies;
# reporting success would advertise commands that can't run. Route to
# the failure path, like the CLI-missing case above.
substep "Studio server deps missing and the repair failed -- not reporting success over a broken install." "Yellow"
$torchOk = $false
}
}
# The uv-managed venv ships no `pip`, but unsloth-zoo's check_pip() finds `uv pip` only
# when uv is on PATH. Seed pip so `save_pretrained_gguf` works regardless.
# The uv-managed venv ships no `pip`, but unsloth-zoo's check_pip() finds `uv
# pip` only when uv is on PATH. Seed pip so `save_pretrained_gguf` works regardless.
$prevEapP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
try {
& wsl.exe -d $distro --cd /root -u root -- $_studioPy -m pip --version *> $null
@ -2377,8 +2353,8 @@ exit 0
}
if ($torchOk) {
step "done" "Unsloth Studio installed in WSL '$distro' -- GPU ready (torch.cuda available)." "Green"
# Native Windows `unsloth` shim forwards every `unsloth ...` into the WSL GPU env so the user
# never touches WSL. WSL2 forwards 127.0.0.1, so http://localhost:8888 opens in Windows.
# Native Windows `unsloth` shim forwards every `unsloth ...` into the WSL GPU
# env. WSL2 forwards 127.0.0.1, so http://localhost:8888 opens in Windows.
try {
$shimDir = Join-Path $env:LOCALAPPDATA "Unsloth\bin"
New-Item -ItemType Directory -Force -Path $shimDir *> $null
@ -2388,19 +2364,16 @@ exit 0
"wsl.exe -d $_distroArg -u root -- /root/.unsloth/studio/unsloth_studio/bin/unsloth %*"
)
Set-Content -LiteralPath (Join-Path $shimDir "unsloth.cmd") -Value $shimLines -Encoding ASCII
# Record the distro so the uninstaller can clean a custom UNSLOTH_WSL_DISTRO install
# without the env var set.
# Record the distro so the uninstaller can clean a custom
# UNSLOTH_WSL_DISTRO install without the env var set.
try { Set-Content -LiteralPath (Join-Path (Split-Path $shimDir -Parent) "wsl-distro.txt") -Value $distro -Encoding ASCII } catch {}
# PREPEND (not append): a previous NATIVE install prepended
# %USERPROFILE%\.unsloth\studio\bin (unsloth.exe) to user PATH,
# and that exe outlives the venv this fallback just rolled aside
# -- an appended shim would lose to the dead native launcher in
# every new terminal. Add-ToUserPath de-dupes and hoists.
# PREPEND (not append): a previous NATIVE install prepended its
# unsloth.exe to user PATH, and that exe outlives the rolled-aside venv --
# an appended shim would lose to the dead launcher. Add-ToUserPath de-dupes.
$null = Add-ToUserPath -Directory $shimDir -Position 'Prepend'
$env:Path = $shimDir + ";" + $env:Path.TrimStart(';')
# Drop the dead default-root native shim outright when the venv
# binary it launches is gone (custom-root shims are left alone;
# the PATH prepend above already outranks them).
# Drop the dead default-root native shim when the venv binary it launches
# is gone (custom-root shims are left alone; the PATH prepend outranks them).
try {
$staleNativeShim = Join-Path $env:USERPROFILE ".unsloth\studio\bin\unsloth.exe"
$staleNativeTarget = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio\Scripts\unsloth.exe"
@ -2423,13 +2396,11 @@ exit 0
$L = @(
'$ErrorActionPreference = "SilentlyContinue"',
('$distro = "' + $distro + '"'),
# Port 8888 may already be taken on the Windows side (Jupyter, a
# second Studio): Studio inside WSL would bind a different port
# while the poll below waits on 8888 forever and the browser
# never opens. Scan the same 8888..8908 window the native
# launcher uses (Find-FreeLaunchPort) and pass the winner via
# -p. WSL2 localhost forwarding mirrors the WSL port onto
# Windows, so probing with a Windows-side TcpListener is valid.
# Port 8888 may be taken on the Windows side (Jupyter, a second
# Studio): WSL Studio would bind another port while the poll waits on
# 8888 forever. Scan the same 8888..8908 window the native launcher uses
# and pass the winner via -p. WSL2 mirrors the port onto Windows, so a
# Windows-side TcpListener probe is valid.
'$port = 0',
'foreach ($p in 8888..8908) { $l = $null; try { $l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, $p); $l.Start(); $port = $p } catch {} finally { if ($l) { try { $l.Stop() } catch {} } }; if ($port) { break } }',
'if (-not $port) { Write-Host "No free port in 8888-8908; close one of the apps using them and relaunch."; Start-Sleep 10; exit 1 }',
@ -2438,13 +2409,13 @@ exit 0
'wsl.exe -d $distro --cd /root -u root -- bash -lic "unsloth studio -p $port"'
)
Set-Content -LiteralPath $launcher -Value $L -Encoding UTF8
# Icon must live OUTSIDE %LOCALAPPDATA%: on WoA the sandboxed icon broker can't read a .ico
# under AppData\Local, so the shortcut renders BLANK; under the user profile it renders
# fine (verified on N1X). Only the icon moves.
# Icon must live OUTSIDE %LOCALAPPDATA%: on WoA the sandboxed icon broker
# can't read a .ico under AppData\Local, so the shortcut renders BLANK;
# under the user profile it renders fine (verified on N1X). Only the icon moves.
$iconDir = Join-Path $env:USERPROFILE ".unsloth"
New-Item -ItemType Directory -Force -Path $iconDir *> $null
$icon = Join-Path $iconDir "unsloth.ico"
# Prefer the bundled icon, else download from GitHub. Validate the ICO header (00 00 01 00)
# Prefer the bundled icon, else download. Validate the ICO header (00 00 01 00)
# before attaching, so a partial/HTML-404 download never makes a blank icon.
$bundledIcon = $null
if ($PSScriptRoot -and $PSScriptRoot.Trim()) { $bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico" }
@ -2475,15 +2446,15 @@ exit 0
$sc.Save()
}
step "shortcuts" "created Desktop + Start Menu shortcuts (launch WSL Studio + open browser)" "Green"
# Nudge Explorer: clear+rebuild icon cache, per-.lnk SHCNE_UPDATEITEM, global
# SHCNE_ASSOCCHANGED. (The real WoA blank-icon cause was the icon path, fixed above.)
# Nudge Explorer: clear icon cache, per-.lnk SHCNE_UPDATEITEM, global
# SHCNE_ASSOCCHANGED. (The real WoA blank-icon cause was the icon path.)
try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {}
try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {}
try {
if (-not ("UnslothShell.Notify" -as [type])) {
Add-Type -Namespace UnslothShell -Name Notify -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, System.IntPtr item2);'
}
# SHCNE_UPDATEITEM (0x00002000), SHCNF_PATHW (0x0005): the global notify alone often misses existing .lnks.
# SHCNE_UPDATEITEM (0x00002000), SHCNF_PATHW (0x0005): global notify alone often misses existing .lnks.
foreach ($lnk in $lnks) { try { [UnslothShell.Notify]::SHChangeNotify(0x00002000, 0x0005, $lnk, [System.IntPtr]::Zero) } catch {} }
# SHCNE_ASSOCCHANGED (0x08000000), SHCNF_IDLIST (0): flush global icon associations.
[UnslothShell.Notify]::SHChangeNotify(0x08000000, 0, $null, [System.IntPtr]::Zero)
@ -2491,23 +2462,22 @@ exit 0
} catch {
substep "(could not create shortcuts: $($_.Exception.Message))" "Yellow"
}
# GGUF *inference* needs a CUDA llama-server (no aarch64+CUDA prebuilt), so build one into
# ~/.unsloth/llama.cpp in the BACKGROUND. Best-effort; opt out: UNSLOTH_NO_LLAMA_CUDA=1.
# GGUF *inference* needs a CUDA llama-server (no aarch64+CUDA prebuilt), so build
# one into ~/.unsloth/llama.cpp in the BACKGROUND. Opt out: UNSLOTH_NO_LLAMA_CUDA=1.
if ($env:UNSLOTH_NO_LLAMA_CUDA -ne '1') {
$prevEapL = $ErrorActionPreference; $ErrorActionPreference = "Continue"
try {
$_llamaUrl = "https://raw.githubusercontent.com/unslothai/unsloth/$(Get-UnslothInstallRef)/studio/scripts/provision_llama_cuda.sh"
# Step 1: fetch the provision script + write a runner (base64 to dodge quoting layers).
# The runner restores PATH (non-login shells miss /usr/lib/wsl/lib nvidia-smi, so provision
# early-exits) and exports the env knobs below (Windows env vars don't cross into WSL). A
# runner FILE lets the detached launcher pass only space-free args, avoiding Start-Process
# mis-splitting `bash -lc <str>`.
# Step 1: fetch the provision script + write a runner (base64 to dodge
# quoting layers). The runner restores PATH (non-login shells miss the
# /usr/lib/wsl/lib nvidia-smi) and exports the env knobs below. A runner
# FILE lets the detached launcher pass only space-free args, avoiding
# Start-Process mis-splitting `bash -lc <str>`.
$_pathLine = 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/wsl/lib:$PATH"' + "`n"
$_jobsLine = if ($env:UNSLOTH_LLAMA_BUILD_JOBS) { "export UNSLOTH_LLAMA_BUILD_JOBS=$($env:UNSLOTH_LLAMA_BUILD_JOBS)`n" } else { "" }
# Bridge UNSLOTH_LLAMA_TAG / UNSLOTH_LLAMA_PR pins into WSL, else the deferred build
# ignores them. sh-single-quoted since tags/PRs are simple tokens.
# Same allow-lists as the other forwarded knobs: a quote in the
# value would break out of the single-quoted export in the runner.
# Bridge UNSLOTH_LLAMA_TAG / UNSLOTH_LLAMA_PR pins into WSL, else the
# deferred build ignores them. Same allow-lists as the other forwarded
# knobs so a quote can't break out of the single-quoted export.
$_tagLine = if ($env:UNSLOTH_LLAMA_TAG -and ($env:UNSLOTH_LLAMA_TAG -match '^[A-Za-z0-9][A-Za-z0-9._/-]*$')) { "export UNSLOTH_LLAMA_TAG='$($env:UNSLOTH_LLAMA_TAG)'`n" } else { "" }
$_prLine = if ($env:UNSLOTH_LLAMA_PR -and ($env:UNSLOTH_LLAMA_PR -match '^\d+$')) { "export UNSLOTH_LLAMA_PR='$($env:UNSLOTH_LLAMA_PR)'`n" } else { "" }
$_runner = "#!/usr/bin/env bash`n" + $_pathLine + $_jobsLine + $_tagLine + $_prLine + "exec bash /root/.unsloth/provision_llama_cuda.sh > /root/.unsloth/llama_cuda_build.log 2>&1`n"
@ -2515,10 +2485,10 @@ exit 0
$_fetchCmd = 'mkdir -p /root/.unsloth; if curl -fsSL "' + $_llamaUrl + '" -o /root/.unsloth/provision_llama_cuda.sh && [ -s /root/.unsloth/provision_llama_cuda.sh ]; then chmod +x /root/.unsloth/provision_llama_cuda.sh; echo ' + $_runnerB64 + ' | base64 -d > /root/.unsloth/run_llama_build.sh; chmod +x /root/.unsloth/run_llama_build.sh; echo PROV_FETCHED; else echo PROV_NOSCRIPT; fi'
$_fetchOut = & wsl.exe -d $distro --cd /root -u root -- bash -lc $_fetchCmd 2>$null
if ("$_fetchOut" -match 'PROV_FETCHED') {
# Step 2: a detached Windows-side wsl.exe keeps the WSL VM up for the whole build
# (a WSL-side `nohup &` dies when the launching session exits). PS 5.1 Start-Process
# joins -ArgumentList WITHOUT quoting, so pass $_distroArg (pre-quoted only when
# spaced); all other tokens are space-free.
# Step 2: a detached Windows-side wsl.exe keeps the WSL VM up for the
# whole build (a WSL-side `nohup &` dies when the session exits). PS 5.1
# Start-Process joins -ArgumentList WITHOUT quoting, so pass $_distroArg
# (pre-quoted only when spaced); other tokens are space-free.
Start-Process -WindowStyle Hidden -FilePath 'wsl.exe' -ArgumentList @('-d', $_distroArg, '--cd', '/root', '-u', 'root', '--', 'bash', '/root/.unsloth/run_llama_build.sh') | Out-Null
step "llama.cpp" "building CUDA llama.cpp for GGUF inference in the background (a few min); log: ~/.unsloth/llama_cuda_build.log" "Green"
} else {
@ -2531,20 +2501,17 @@ exit 0
substep "retry, or launch manually: wsl -d $_distroArg -u root -- bash -lic 'unsloth studio -p 8888'" "Cyan"
}
if ($torchOk) {
# Success: the Windows venv is vestigial (everything runs in WSL), so drop the
# rolled-aside previous-venv backup instead of orphaning it. EXCEPT for a
# custom UNSLOTH_STUDIO_HOME: the installer told the user above that their
# custom root is not used by the WSL install, so deleting the venv that
# lived there would contradict that disclaimer -- put it back instead
# (the WSL shim does not depend on the Windows venv).
# Success: the Windows venv is vestigial (all runs in WSL), so drop the
# rolled-aside backup instead of orphaning it. EXCEPT a custom
# UNSLOTH_STUDIO_HOME: we told the user their custom root isn't used by the WSL
# install, so restore its venv rather than delete it (the shim doesn't need it).
if ($envOverride) { Restore-StudioVenvRollback } else { Complete-StudioVenvRollback }
substep "GPU training + GGUF export run inside WSL. (GGUF *inference* additionally needs a CUDA llama.cpp build.)" "Yellow"
$global:LASTEXITCODE = 0
return
}
# Failed (torch.cuda unavailable): Exit-InstallFailure restores the rolled-aside
# venv and fails the process in every invocation mode, so iex/-Command
# automation cannot read this as success.
# Failed (torch.cuda unavailable): Exit-InstallFailure restores the venv and fails
# in every invocation mode, so automation cannot read this as success.
return (Exit-InstallFailure "WSL Studio install did not finish cleanly (torch.cuda not detected; inner exit $wslRc)")
}

View file

@ -16,11 +16,10 @@ function Uninstall-UnslothStudio {
function _Step { param([string]$Msg) Write-Host $Msg }
function _Substep { param([string]$Msg, [string]$Color = "Gray") Write-Host " $Msg" -ForegroundColor $Color }
# True host architecture, mirroring install.ps1's WSL-fallback gate: an
# x64-emulated PowerShell on ARM64 reports AMD64 in PROCESSOR_ARCHITECTURE,
# which made the legacy marker-less WSL cleanup below skip exactly the
# machines the fallback installed on. Each probe only ever turns the answer
# ON; Win32_Processor.Architecture 12 = ARM64.
# True host architecture, mirroring install.ps1's WSL-fallback gate: x64-emulated
# PowerShell on ARM64 reports AMD64, which made the legacy marker-less WSL cleanup
# skip the machines the fallback installed on. Each probe only turns the answer ON;
# Win32_Processor.Architecture 12 = ARM64.
function _IsArm64Host {
$arm = $false
try { $arm = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ieq 'Arm64') } catch { }
@ -419,12 +418,10 @@ function Uninstall-UnslothStudio {
}
# ── Remove desktop and Start Menu shortcuts ──
# Canonical name is "Unsloth Studio.lnk". Distro-suffixed names
# ("Unsloth Studio (WSL - <distro>).lnk") belong to per-distro WSL installs, which
# the WSL-fallback section below only cleans for evidenced distros (env var,
# wsl-distro.txt marker, or the legacy ARM64 probe) -- scope this sweep to the
# same set so a surviving WSL install keeps its launcher. Anything that is not a
# live wsl.exe launcher (pre-release leftovers) is still swept.
# Canonical name is "Unsloth Studio.lnk". Distro-suffixed names belong to per-distro
# WSL installs, which the section below only cleans for evidenced distros (env var,
# wsl-distro.txt, or legacy ARM64 probe) -- scope this sweep to the same set so a
# surviving WSL install keeps its launcher. Non-wsl.exe launchers are still swept.
_Step "Removing desktop and Start Menu shortcuts..."
$_scCands = @()
if ($env:UNSLOTH_WSL_DISTRO) { $_scCands += $env:UNSLOTH_WSL_DISTRO }
@ -455,8 +452,8 @@ function Uninstall-UnslothStudio {
$_sc = $_scWs.CreateShortcut($_.FullName)
if ("$($_sc.TargetPath) $($_sc.Arguments)" -match "wsl\.exe") {
$_scD = $null
# install.sh quotes spaced distro names (-d "Ubuntu Preview"), so match a
# full quoted token first; a naive [^"\s]+ would truncate at the space.
# install.sh quotes spaced distro names, so match a full quoted
# token first; a naive [^"\s]+ would truncate at the space.
if ($_sc.Arguments -match '-d\s+(?:"([^"]+)"|(\S+))') {
$_scD = if ($Matches[1]) { $Matches[1] } else { $Matches[2] }
}
@ -543,11 +540,11 @@ function Uninstall-UnslothStudio {
# ── Windows-on-Arm WSL-fallback artifacts ──
# The ARM64+NVIDIA fallback puts Studio in WSL plus a native shim + launcher under
# %LOCALAPPDATA%\Unsloth (not "Unsloth Studio") with a PATH entry -- none caught above.
# %LOCALAPPDATA%\Unsloth with a PATH entry -- none caught above.
_Step "Removing WSL-fallback artifacts (shim, launcher, PATH entry, WSL install)..."
$unslothDir = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth" } else { $null }
# wsl-distro.txt records a custom UNSLOTH_WSL_DISTRO install so it's cleanable without the env
# var set; read it BEFORE the directory is removed below.
# wsl-distro.txt records a custom UNSLOTH_WSL_DISTRO install so it's cleanable without
# the env var set; read it BEFORE the directory is removed below.
$_recordedDistro = $null
if ($unslothDir) {
try {
@ -580,10 +577,9 @@ function Uninstall-UnslothStudio {
_RemovePath $unslothDir
}
# The WoA shortcut icon lives under the user profile (icon broker can't read
# AppData\Local). The shortcut sweep above deliberately keeps launchers for
# WSL installs it has no evidence for; those .lnks point at this icon, so
# only remove it when no Unsloth shortcut survives anywhere (mirrors the
# _drop_shared_icon_if_unused guard on the WSL-side uninstaller).
# AppData\Local). The sweep above keeps launchers for WSL installs it has no evidence
# for, and those .lnks point at this icon, so only remove it when no Unsloth shortcut
# survives anywhere (mirrors _drop_shared_icon_if_unused on the WSL-side uninstaller).
if ($env:USERPROFILE) {
$_icoInUse = $false
foreach ($_icoDir in $shortcutDirs) {
@ -594,33 +590,30 @@ function Uninstall-UnslothStudio {
}
if (-not $_icoInUse) { _RemovePath (Join-Path $env:USERPROFILE ".unsloth\unsloth.ico") }
}
# The empty-dir sweep of ~/.unsloth above ran BEFORE this icon removal, so on a WoA install
# the still-present unsloth.ico kept ~/.unsloth non-empty then and it was skipped -- leaving an
# empty ~/.unsloth behind. Re-attempt now that the icon (the last default-mode child) is gone.
# The ~/.unsloth empty-dir sweep above ran BEFORE this icon removal, so the still-present
# unsloth.ico kept it non-empty and it was skipped. Re-attempt now that the icon (the
# last default-mode child) is gone.
if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and
-not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) {
_RemovePath $defaultUnslothHome
}
# Remove the Studio install inside each WSL distro (the real GPU install + any CUDA llama.cpp build).
# Remove the Studio install inside each WSL distro (GPU install + any CUDA llama.cpp build).
if (Get-Command wsl.exe -ErrorAction SilentlyContinue) {
try {
# Probe candidates by exit code ('' = default distro) since `wsl --list` emits UTF-16 PS
# mis-parses. Kills run BEFORE rm: a live CUDA build (cmake/nvcc under
# /root/.unsloth/llama.cpp) would otherwise keep burning CPU/GPU and recreate files after
# the rm. Each matched PID's whole process GROUP is signalled (cmake --build children carry
# relative argv no pattern can match), guarded against this shell's own pgid, plus direct
# children via pkill -P; this shell cannot self-match (its argv carries an extra backslash
# and the [h]-bracket in the pkill pattern). Scope STRICTLY to /root (the fallback's
# install dir); /home/*/.unsloth may be another user's. The 8888 kill only targets a
# listener whose process cmdline is under /root/.unsloth (Studio's bind), so an unrelated
# service on 8888 -- Jupyter et al. default to it -- is NOT killed; it's also gated on an
# Unsloth install having existed. /proc cmdline greps still work after kills since they
# read process state, not files.
# Probe candidates by exit code ('' = default distro) since `wsl --list` emits
# UTF-16 PS mis-parses. Kills run BEFORE rm so a live CUDA build (cmake/nvcc under
# /root/.unsloth/llama.cpp) can't recreate files after the rm. Each matched PID's
# whole process GROUP is signalled (cmake children carry relative argv), guarded
# against this shell's pgid, plus direct children via pkill -P; this shell can't
# self-match (extra backslash + [h]-bracket). Scope STRICTLY to /root; /home/*
# may be another user's. The 8888 kill only targets a listener whose cmdline is
# under /root/.unsloth, so an unrelated service on 8888 isn't killed, and is gated
# on an Unsloth install having existed. /proc greps still work after kills.
$_clean = '_had=0; if [ -d /root/.unsloth ] || [ -L /root/.local/bin/unsloth ]; then _had=1; fi; _mypg=$(ps -o pgid= -p $$ 2>/dev/null | tr -d " "); for _p in $(pgrep -f ''/root/\.unslot[h]/'' 2>/dev/null); do _pg=$(ps -o pgid= -p $_p 2>/dev/null | tr -d " "); case "$_pg" in ""|0|1|"$_mypg") pkill -9 -P $_p 2>/dev/null; kill -9 $_p 2>/dev/null ;; *) kill -9 -- -$_pg 2>/dev/null || kill -9 $_p 2>/dev/null ;; esac; done; if [ $_had -eq 1 ]; then for _p in $(fuser 8888/tcp 2>/dev/null); do grep -qa /root/\.unsloth/ /proc/$_p/cmdline 2>/dev/null && kill -9 $_p 2>/dev/null; done; fi; rm -rf /root/.unsloth /root/llama-cuda /root/provision_llama_cuda.sh /root/llama_cuda_build.log 2>/dev/null; rm -f /root/.local/bin/unsloth 2>/dev/null; true'
# Clean only distros with evidence of a fallback install: the wsl-distro.txt marker or an
# explicit UNSLOTH_WSL_DISTRO. The broad candidate probe is only for legacy marker-less
# installs (ARM64 only); on x86 it would delete distros this installer never touched
# (e.g. a ROCm-on-WSL Studio under /root).
# Clean only distros with fallback-install evidence: wsl-distro.txt or an
# explicit UNSLOTH_WSL_DISTRO. The broad candidate probe is only for legacy
# marker-less installs (ARM64 only); on x86 it would delete distros this
# installer never touched (e.g. a ROCm-on-WSL Studio under /root).
$_cands = @()
if ($env:UNSLOTH_WSL_DISTRO) { $_cands += $env:UNSLOTH_WSL_DISTRO }
if ($_recordedDistro) { $_cands += $_recordedDistro }
@ -653,8 +646,8 @@ function Uninstall-UnslothStudio {
Write-Host " `$env:UNSLOTH_STUDIO_HOME = 'C:\your\path'; irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex"
}
# The distro probes leave a failing $LASTEXITCODE; reset so success exits 0. Set the var
# rather than `exit 0` so `irm ... | iex` doesn't terminate the caller's shell.
# The distro probes leave a failing $LASTEXITCODE; reset so success exits 0. Set the
# var rather than `exit 0` so `irm ... | iex` doesn't terminate the caller's shell.
$global:LASTEXITCODE = 0
}

View file

@ -212,22 +212,17 @@ _custom_studio_roots | while IFS= read -r _custom_root; do
_remove_path "$_custom_root"
done
_remove_path "$HOME/.unsloth/studio"
# Stop a detached CUDA llama.cpp build BEFORE deleting its tree: _pkill_studio
# only matches Studio roots, and a live cmake/nvcc under ~/.unsloth/llama.cpp
# would keep burning CPU/thermals, recreate build/ files between the rm and the
# rmdir, and leave a partial tree. TERM first, then KILL after the same grace
# _pkill_studio uses.
# Stop a detached CUDA llama.cpp build BEFORE deleting its tree: _pkill_studio only
# matches Studio roots, and a live cmake/nvcc under ~/.unsloth/llama.cpp would recreate
# build/ files between the rm and the rmdir. TERM first, then KILL after the same grace.
if command -v pkill >/dev/null 2>&1; then
_llama_re=$(_pkill_escape "$HOME/.unsloth/llama.cpp")
# Signal the whole process GROUP of each match, not just the matching PID:
# the provisioner cds into the tree before `cmake --build build`, so cmake/
# make children carry relative argv that no pattern can match, and killing
# only the wrapper orphans them mid-build. Group kill sweeps the tree; PID
# kill remains the fallback when pgid is unreadable or shared with init.
# Never group-kill our own group: in a non-interactive session (no job
# control) a lingering provisioner can share the uninstaller's pgid, and
# kill(-pgid) would TERM this script and its caller mid-cleanup. Fall back
# to the PID plus its direct children in that case.
# Signal the whole process GROUP of each match: the provisioner cds into the tree
# before `cmake --build build`, so cmake/make children carry relative argv no pattern
# matches, and killing only the wrapper orphans them. PID kill is the fallback when
# pgid is unreadable or shared. Never group-kill our own group: a lingering provisioner
# in a non-interactive session can share our pgid, and kill(-pgid) would TERM this
# script mid-cleanup; fall back to the PID plus its direct children then.
_self_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d '[:space:]')
_kill_llama_build() {
_sig="$1"
@ -354,10 +349,9 @@ case "$_os" in
}
}
# Remove the WoA WSL-fallback native shim/launcher dir
# (%LOCALAPPDATA%\Unsloth) + its PATH entry that install.ps1
# created, so a WSL-side bash uninstall is complete. Only when THIS
# distro owns the fallback (wsl-distro.txt) -- else uninstalling a
# different distro would break the still-installed shim.
# (%LOCALAPPDATA%\Unsloth) + its PATH entry, so a WSL-side bash uninstall
# is complete. Only when THIS distro owns the fallback (wsl-distro.txt),
# else uninstalling a different distro breaks the still-installed shim.
$ud = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth" } else { $null };
$owner = $null;
if ($ud) { $of = Join-Path $ud "wsl-distro.txt"; if (Test-Path -LiteralPath $of) { $owner = (Get-Content -LiteralPath $of | Select-Object -First 1).Trim() } }
@ -365,9 +359,9 @@ case "$_os" in
$shim = (Join-Path $ud "bin").TrimEnd("\","/");
$up = [Environment]::GetEnvironmentVariable("Path","User");
if ($up) { [Environment]::SetEnvironmentVariable("Path", (($up -split ";" | Where-Object { $_ -and ($_.TrimEnd("\","/") -ine $shim) }) -join ";"), "User") }
# The WoA-fallback shortcuts target powershell.exe + launch-studio-wsl.ps1
# (not wsl.exe), so the sweep above keeps them; remove them here before
# their launcher dir is deleted or they would dangle.
# WoA-fallback shortcuts target powershell.exe + launch-studio-wsl.ps1
# (not wsl.exe), so the sweep above keeps them; remove them here
# before their launcher dir is deleted or they would dangle.
foreach ($d in $dirs) {
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
$l = Join-Path $d "Unsloth Studio.lnk";
@ -392,9 +386,9 @@ case "$_os" in
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 }
}
# install.sh also writes the WSL shortcut icon to the Windows
# profile (%USERPROFILE%\.unsloth\unsloth.ico) because the WoA
# icon broker cannot read AppData\Local; clean it the same way.
# install.sh also writes the WSL shortcut icon to the Windows profile
# (%USERPROFILE%\.unsloth\unsloth.ico) since the WoA icon broker cannot
# read AppData\Local; clean it the same way.
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
$pIconDir = Join-Path $env:USERPROFILE ".unsloth";
$pIco = Join-Path $pIconDir "unsloth.ico";

View file

@ -728,11 +728,10 @@ class TestLoadHubDownloadExclusion:
source = (Path(__file__).resolve().parent.parent / "routes" / "inference.py").read_text()
gguf_branch = source[source.index("if config.is_gguf:") :]
# The gguf_load_in_flight marker must be entered before the hub-download
# guard and the unload so a concurrent load can't race the download
# manager. The llama_extra_args inheritance that used to sit between the
# marker and the guard now runs in _guard_chat_load_against_training, ahead
# of the GGUF branch, so it is no longer a landmark inside this slice.
# The gguf_load_in_flight marker must be entered before the hub-download guard
# and the unload so a concurrent load can't race the download manager. The
# llama_extra_args inheritance that used to sit between them now runs in
# _guard_chat_load_against_training, ahead of the GGUF branch, so it's gone here.
assert (
gguf_branch.index("enter_context(gguf_load_in_flight")
< gguf_branch.index("_hub_download_blocks_gguf_load")

View file

@ -116,11 +116,10 @@ def _clean_state(monkeypatch, tmp_path):
monkeypatch.delenv("UNSLOTH_LLAMA_CPP_PATH", raising = False)
# Never hit the network in these tests.
monkeypatch.setattr(freshness, "_fetch_latest_release_tag", lambda repo, timeout = 5.0: None)
# Default to no live inference backend: on a fully-installed host
# `from routes.inference import get_llama_cpp_backend` (inside _run_update)
# imports a real Studio singleton and blocks on its load lock, making the
# worker tests hang/flake by host. This is the CI/fail-open path; the
# load-coordination tests inject their own backend over this default.
# Default to no live inference backend: on a fully-installed host _run_update's
# `from routes.inference import get_llama_cpp_backend` imports a real Studio
# singleton and blocks on its load lock, hanging/flaking the worker tests. This is
# the fail-open path; load-coordination tests inject their own backend over it.
_routes_pkg = ModuleType("routes")
_routes_pkg.__path__ = []
_inference_mod = ModuleType("routes.inference")

View file

@ -3025,9 +3025,9 @@ def detect_host() -> HostInfo:
nvidia_smi = shutil.which("nvidia-smi")
if not nvidia_smi:
# Root WSL sessions drop /usr/lib/wsl/lib (the only nvidia-smi home
# under WSL2 GPU-PV) from PATH, which would misroute an ARM NVIDIA WSL
# host to the CPU prebuilt; mirror setup.sh's resolver order.
# Root WSL sessions drop /usr/lib/wsl/lib (the only nvidia-smi home under
# WSL2 GPU-PV) from PATH, misrouting an ARM NVIDIA WSL host to the CPU
# prebuilt; mirror setup.sh's resolver order.
for _cand in ("/usr/lib/wsl/lib/nvidia-smi", "/usr/bin/nvidia-smi"):
if os.access(_cand, os.X_OK):
nvidia_smi = _cand

View file

@ -275,10 +275,9 @@ _setup_cvd_hides_nvidia() {
# via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
# install_llama_prebuilt.py has_usable_nvidia), so the AMD probes still run
# and a mixed host steered to its AMD card keeps the ROCm route.
# nvidia-smi resolver: on WSL2 GPU-PV the binary lives ONLY in
# /usr/lib/wsl/lib, which root login shells drop from PATH (/etc/profile
# resets it), and /proc/driver/nvidia is not populated under the dxg driver --
# so bare `command -v nvidia-smi` misses real GPUs on the flagship WoA path.
# nvidia-smi resolver: on WSL2 GPU-PV the binary lives ONLY in /usr/lib/wsl/lib,
# which root login shells drop from PATH, so bare `command -v nvidia-smi` misses
# real GPUs on the flagship WoA path.
_resolve_nvsmi() {
command -v nvidia-smi 2>/dev/null && return 0
[ -x /usr/lib/wsl/lib/nvidia-smi ] && { echo /usr/lib/wsl/lib/nvidia-smi; return 0; }
@ -1191,9 +1190,9 @@ LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp"
LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server"
_NEED_LLAMA_SOURCE_BUILD=false
_LLAMA_CPP_DEGRADED=false
# Deferred != degraded: on WSL2 aarch64+NVIDIA install.ps1 builds the CUDA server
# in the background, so an absent server is success -- must not trip the arm64
# CPU-prebuilt last-resort or exit 1.
# Deferred != degraded: on WSL2 aarch64+NVIDIA install.ps1 builds the CUDA server in
# the background, so an absent server is success -- must not trip the arm64 CPU-prebuilt
# last-resort or exit 1.
_LLAMA_CPP_DEFERRED=false
_LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}"
_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}"
@ -1430,13 +1429,11 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && \
fi
# ── WSL2 aarch64 + NVIDIA, no nvcc yet: defer to the background CUDA build ──
# install.ps1 builds the CUDA llama-server in the background AND signals that by
# exporting UNSLOTH_WSL_LLAMA_DEFERRED=1 into this install. ONLY defer when that
# flag is set: a direct in-WSL `unsloth studio update` has no background builder,
# so deferring there would report "CUDA build running in background" while nothing
# builds -- hiding a no-server state. Without the flag we fall through to section 9
# (a slow CPU server, still better than a phantom background build). With nvcc we
# fall through too; opted out (UNSLOTH_NO_LLAMA_CUDA=1) the CPU build is the only server.
# install.ps1 builds the CUDA llama-server in the background and signals it via
# UNSLOTH_WSL_LLAMA_DEFERRED=1. ONLY defer when that flag is set: a direct in-WSL
# `unsloth studio update` has no background builder, so deferring there would claim
# "CUDA build running in background" while nothing builds. Without the flag (or with
# nvcc) we fall through to a slow CPU server; opted out the CPU build is the only server.
if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \
&& [ "${UNSLOTH_WSL_LLAMA_DEFERRED:-0}" = "1" ] \
&& [ "$_LLAMA_FORCE_COMPILE" != "1" ] \
@ -1457,10 +1454,9 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \
fi
# ── Native Linux aarch64 + NVIDIA, no nvcc yet: skip the CPU build too ──
# The aarch64+NVIDIA provision block below installs the CUDA toolkit and does
# the only build this host needs; a CPU source build first would burn minutes
# (and thermal headroom on Spark-class machines) on a binary the CUDA rebuild
# replaces in the same run. Provision failure still cascades to the
# The provision block below installs the CUDA toolkit and does the only build this host
# needs; a CPU source build first would burn minutes (and thermal headroom on Spark-class
# machines) on a binary the CUDA rebuild replaces. Provision failure still cascades to the
# CPU-prebuilt last resort via _LLAMA_CPP_DEGRADED, so no-server states surface.
if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] \
&& [ "$_LLAMA_FORCE_COMPILE" != "1" ] \
@ -1749,10 +1745,9 @@ else
fi
if [ "$_CUDA_TOOLKIT_ALLOWED" = true ]; then
# glibc >= 2.41 + CUDA < 13.3: rsqrt/rsqrtf header clash fails
# every .cu -> CPU fallback; only fix is CUDA >= 13.3. Diagnostic
# only (never changes flags or aborts), against the final _NVCC_VER
# (after the driver-compat swap above).
# glibc >= 2.41 + CUDA < 13.3: rsqrt/rsqrtf header clash fails every
# .cu -> CPU fallback; only fix is CUDA >= 13.3. Diagnostic only (never
# changes flags), against the final _NVCC_VER (post driver-compat swap).
_GLIBC_VER="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}')" || _GLIBC_VER=""
if [ -n "$_GLIBC_VER" ]; then
_GLIBC_MAJ="${_GLIBC_VER%%.*}"; _GLIBC_MIN="${_GLIBC_VER#*.}"; _GLIBC_MIN="${_GLIBC_MIN%%.*}"
@ -1891,15 +1886,12 @@ else
substep "$_BUILD_DESC..."
NCPU=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
# Thermal cap for the aarch64 + NVIDIA foreground CUDA build. A full
# -j(nproc) nvcc compile draws enough sustained power to trip a
# thermal shutdown on the lightly-cooled NVIDIA-ARM boxes this path
# targets (DGX Spark / GB10, N1X "RTX Spark" laptops) -- the same
# reason provision_llama_cuda.sh caps its background build. Mirror
# that cap here (this foreground build only runs when no prebuilt was
# available and a CUDA toolkit is already present): ~half the cores,
# also bounded by ~1.5 GB/nvcc job. Other platforms keep full
# -j(nproc); override on any host with UNSLOTH_LLAMA_BUILD_JOBS=N.
# Thermal cap for the aarch64 + NVIDIA foreground CUDA build. A full -j(nproc)
# nvcc compile can trip a thermal shutdown on the lightly-cooled NVIDIA-ARM boxes
# this targets (DGX Spark / GB10, N1X "RTX Spark") -- same reason
# provision_llama_cuda.sh caps its background build. Mirror it here: ~half the
# cores, also bounded by ~1.5 GB/nvcc job. Other platforms keep full -j(nproc);
# override anywhere with UNSLOTH_LLAMA_BUILD_JOBS=N.
if { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; } \
&& [ "${GPU_BACKEND:-}" = "cuda" ]; then
if [ -n "${UNSLOTH_LLAMA_BUILD_JOBS:-}" ] && [ "${UNSLOTH_LLAMA_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then
@ -2013,20 +2005,17 @@ fi # end _SKIP_GGUF_BUILD check
# ── aarch64 + NVIDIA (DGX Spark / GB10 / N1X "RTX Spark"): provision a CUDA
# llama.cpp when the source build above could not (no CUDA toolkit found) ──
# No aarch64+CUDA prebuilt exists and a fresh Spark ships only driver + nvidia-smi,
# so the build above fell back to CPU; mirror the Windows/WSL fix
# (provision_llama_cuda.sh) for native Linux. Best-effort: provision always exits
# 0, and on failure the prior CPU/degraded state stands.
# No aarch64+CUDA prebuilt exists and a fresh Spark ships only driver + nvidia-smi, so the
# build above fell back to CPU; mirror the Windows/WSL fix (provision_llama_cuda.sh) for
# native Linux. Best-effort: on failure the prior CPU/degraded state stands.
# CUDA detection covers both layouts: monolithic (libggml-cuda in ldd) and split
# (dlopen-ed libggml-cuda.so* beside the binary, missed by ldd). Its presence is
# the signal -- CPU-only builds ship no libggml-cuda.so.
# (dlopen-ed libggml-cuda.so* beside the binary, missed by ldd). CPU-only builds ship none.
_have_cuda_llama_server() {
[ -x "$LLAMA_SERVER_BIN" ] || return 1
ldd "$LLAMA_SERVER_BIN" 2>/dev/null | grep -qi 'libggml-cuda' && return 0
# Split-.so builds on this path come from provision_llama_cuda.sh, which stamps
# .unsloth-cuda-ok only after its final CUDA check. Requiring the stamp keeps
# the interrupted-relink state (new .so + old CPU server) provisioning instead
# of being reported as ready.
# Split-.so builds here come from provision_llama_cuda.sh, which stamps
# .unsloth-cuda-ok only after its final CUDA check. Requiring the stamp keeps an
# interrupted-relink state (new .so + old CPU server) provisioning, not "ready".
_stamp="$(dirname "$LLAMA_SERVER_BIN")/.unsloth-cuda-ok"
for _so in "$(dirname "$LLAMA_SERVER_BIN")"/libggml-cuda.so*; do
[ -e "$_so" ] && [ -e "$_stamp" ] && return 0
@ -2044,10 +2033,9 @@ if [ "$_HOST_SYSTEM" = "Linux" ] \
&& [ "$_LOCAL_LLAMA_CPP_LINKED" != true ] \
&& ! _have_cuda_llama_server; then
# Under WSL this runs ONLY for a DIRECT `install.sh` run: install.ps1 sets
# UNSLOTH_WSL_LLAMA_DEFERRED=1 and builds in the background; a direct run has
# no background builder, so provision here.
# Resolve provision_llama_cuda.sh: beside setup.sh, then local-dev repo, else
# fetch from GitHub (so `curl | sh` works on an older wheel without it).
# UNSLOTH_WSL_LLAMA_DEFERRED=1 and builds in the background; a direct run has none.
# Resolve provision_llama_cuda.sh: beside setup.sh, then local-dev repo, else fetch
# from GitHub (so `curl | sh` works on an older wheel without it).
_PROV_SH=""
if [ -f "$SCRIPT_DIR/scripts/provision_llama_cuda.sh" ]; then
_PROV_SH="$SCRIPT_DIR/scripts/provision_llama_cuda.sh"
@ -2069,7 +2057,7 @@ if [ "$_HOST_SYSTEM" = "Linux" ] \
step "llama.cpp" "CUDA llama-server ready (aarch64 + NVIDIA)"
_LLAMA_CPP_DEGRADED=false
# Claim ownership of the fresh $LLAMA_CPP_DIR, else the next custom-STUDIO_HOME
# run's _assert_studio_owned_or_absent aborts on it.
# run's _assert_studio_owned_or_absent aborts.
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
@ -2077,15 +2065,14 @@ if [ "$_HOST_SYSTEM" = "Linux" ] \
substep "CUDA build unavailable; keeping existing (CPU) llama-server" "$C_WARN"
else
substep "CUDA build unavailable and no llama-server present; see $LLAMA_CPP_DIR build output" "$C_WARN"
# No server at all: mark degraded so the arm64 CPU-prebuilt last resort
# and the failure exit fire instead of reporting a working install.
# No server at all: mark degraded so the arm64 CPU-prebuilt last resort and
# the failure exit fire instead of reporting a working install.
_LLAMA_CPP_DEGRADED=true
fi
else
# Provisioner unreachable (not packaged and the GitHub fetch failed). The
# native deferral above may have skipped the CPU source build expecting
# this block to build; without a server that must surface as degraded so
# the CPU-prebuilt last resort fires instead of reporting success.
# Provisioner unreachable (not packaged and the GitHub fetch failed). The native
# deferral above may have skipped the CPU source build expecting this block; without
# a server, surface degraded so the CPU-prebuilt last resort fires, not success.
substep "CUDA provision script unavailable (offline?); cannot build CUDA llama.cpp" "$C_WARN"
[ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true
fi
@ -2169,13 +2156,11 @@ else
fi
echo ""
# Core install (venv + torch + Studio deps) is complete here; only the
# optional llama.cpp engine can still be missing. Stamp that fact BEFORE the
# tolerated nonzero exit below: install.ps1's WSL fallback cannot tell that
# exit apart from a real mid-install failure at the process level, so it
# removes this file before the run and requires it to exist afterwards --
# otherwise its torch/CLI probes can pass on a stale venv from a previous
# install. Removed by scripts/uninstall.sh with the rest of ~/.unsloth.
# Core install (venv + torch + Studio deps) is complete here; only the optional llama.cpp
# engine can still be missing. Stamp that BEFORE the tolerated nonzero exit below:
# install.ps1's WSL fallback can't tell that exit from a real mid-install failure, so it
# removes this file before the run and requires it afterwards -- else its torch/CLI probes
# could pass on a stale venv. Removed by scripts/uninstall.sh with the rest of ~/.unsloth.
mkdir -p "$HOME/.unsloth" 2>/dev/null || true
: > "$HOME/.unsloth/.install-ok" 2>/dev/null || true

View file

@ -265,9 +265,8 @@ class TestSetupShHardening:
assert wrapped, "compute_cap probe must be wrapped in _setup_run_smi (timeout-bounded)"
def test_driver_version_probe_timeout_wrapped(self, setup_src):
# The probe resolves nvidia-smi explicitly (root WSL shells drop
# /usr/lib/wsl/lib from PATH) and must still go through the timeout
# wrapper with the resolved path.
# The probe resolves nvidia-smi explicitly (root WSL shells drop /usr/lib/wsl/lib
# from PATH) and must still go through the timeout wrapper with the resolved path.
start = setup_src.find("_cuda_driver_max_version()")
end = setup_src.find("\n}", start)
body = setup_src[start:end]