diff --git a/install.ps1 b/install.ps1
index 0b06cb3ea1..5b205df96d 100644
--- a/install.ps1
+++ b/install.ps1
@@ -57,6 +57,26 @@ function Install-UnslothStudio {
}
}
+ # Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
+ # ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
+ function Get-HostMachineArch {
+ $osArch = ""
+ try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
+ $signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
+ foreach ($s in $signals) {
+ if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
+ }
+ foreach ($s in $signals) {
+ if ([string]::IsNullOrWhiteSpace($s)) { continue }
+ switch ($s.ToLowerInvariant()) {
+ "amd64" { return "x86_64" }
+ "x64" { return "x86_64" }
+ "x86" { return "x86" }
+ }
+ }
+ return "unknown"
+ }
+
function Get-TauriTorchIndexFamily {
param([string]$TorchIndexUrl)
if ($SkipTorch) { return "none" }
@@ -1124,10 +1144,27 @@ exit 0
return $false
}
+ # The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"".
+ function Get-PythonPlatformTag {
+ param([string]$Exe)
+ try {
+ return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
+ } catch { return "" }
+ }
+
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
# The resolved Path is passed to `uv venv --python` to prevent uv from
# re-resolving the version string back to a conda interpreter.
function Find-CompatiblePython {
+ # -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for
+ # Install-X64Python, where x64 of a lower-priority minor beats ARM64.
+ param([switch]$X64Only)
+ # Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no
+ # win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake /
+ # Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all
+ # there is, and the caller then bootstraps x64 or warns.
+ $preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64")
+ $candidates = @()
# Try the Python Launcher first (most reliable on Windows)
# py.exe resolves to the standard CPython install, not conda.
# Prefer the requested $PythonVersion, then newest-first fallback.
@@ -1145,7 +1182,8 @@ exit 0
# Resolve the actual executable path and verify it is not conda-based
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
- return @{ Version = $ver; Path = $resolvedExe }
+ if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } }
+ $candidates += @{ Version = $ver; Path = $resolvedExe }
}
}
} catch {}
@@ -1166,11 +1204,53 @@ exit 0
try {
$out = & $cmd.Source --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
- return @{ Version = $Matches[1]; Path = $cmd.Source }
+ if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } }
+ $candidates += @{ Version = $Matches[1]; Path = $cmd.Source }
}
} catch {}
}
}
+ # `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so
+ # a same-minor x64 install that is neither preferred nor on PATH never becomes a
+ # candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not
+ # 32-bit"), so enumerate every registration with -0p and probe each path.
+ if ($preferX64) {
+ foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) {
+ if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue }
+ $listed = @()
+ try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {}
+ foreach ($line in $listed) {
+ # " -V:3.12 * C:\...\python.exe": tag, optional default marker, path.
+ $m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?
\S.*?\.exe)"?\s*$')
+ if (-not $m.Success) { continue }
+ $exe = $m.Groups['p'].Value.Trim()
+ if ($candidates | Where-Object { $_.Path -eq $exe }) { continue }
+ if (-not (Test-Path -LiteralPath $exe)) { continue }
+ if (Test-IsCondaPython $exe) { continue }
+ try {
+ $out = & $exe --version 2>&1 | Out-String
+ if ($out -match "Python (3\.1[1-3])\.\d+") {
+ $candidates += @{ Version = $Matches[1]; Path = $exe }
+ }
+ } catch {}
+ }
+ }
+ }
+ # Prefer x64, but only within one minor: $minors is the caller's version preference,
+ # so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and
+ # never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above.
+ foreach ($c in $candidates) {
+ $tag = Get-PythonPlatformTag $c.Path
+ $c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" }
+ }
+ foreach ($minor in $minors) {
+ $sameMinor = @($candidates | Where-Object { $_.Version -eq $minor })
+ if ($sameMinor.Count -eq 0) { continue }
+ $x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1
+ if ($x64) { return $x64 }
+ if (-not $X64Only) { return $sameMinor[0] }
+ }
+ if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] }
return $null
}
@@ -1181,8 +1261,11 @@ exit 0
# (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv ->
# astral.sh fallback below. Returns @{ Version; Path } or $null.
function Install-PythonFromPythonOrg {
+ # $Arch overrides the host arch, to pull x64 onto an ARM64 box.
+ param([string]$Arch = "")
# python.org ships one installer per architecture.
- $archSuffix = switch (Get-TauriDiagArch) {
+ $targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch }
+ $archSuffix = switch ($targetArch) {
"x86_64" { "-amd64" }
"arm64" { "-arm64" }
"x86" { "" }
@@ -1247,6 +1330,28 @@ exit 0
return (Find-CompatiblePython)
}
+ # ── Windows on ARM: get an x64 CPython ──
+ # --architecture x64 forces winget off the ARM64 build; python.org takes the same override.
+ function Install-X64Python {
+ if ($script:WingetAvailable) {
+ $prevEAP = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements
+ } catch { }
+ $ErrorActionPreference = $prevEAP
+ Refresh-SessionPath
+ $found = Find-CompatiblePython
+ if ($found -and $found.Arch -eq "x86_64") { return $found }
+ substep "winget could not provide an x64 Python -- trying python.org..." "Yellow"
+ }
+ $found = Install-PythonFromPythonOrg -Arch "x86_64"
+ if ($found -and $found.Arch -eq "x86_64") { return $found }
+ # Nothing installable (offline / no winget): an x64 build of another supported minor
+ # still runs the wheels ARM64 cannot, so take it over the native interpreter.
+ return (Find-CompatiblePython -X64Only)
+ }
+
# ── Install Python if no compatible version (3.11-3.13) found ──
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
Write-TauriLog "STEP" "Installing Python"
@@ -1318,6 +1423,26 @@ exit 0
return (Exit-InstallFailure "Python installation failed")
}
}
+ # ── Windows on ARM: swap a native ARM64 interpreter for x64 ──
+ # pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds
+ # both and fails deep into the run. Warn up front if x64 is unobtainable.
+ if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") {
+ substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow"
+ substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow"
+ $X64Python = Install-X64Python
+ if ($X64Python) {
+ $DetectedPython = $X64Python
+ step "python" "using x64 Python $($DetectedPython.Version) under emulation"
+ } else {
+ Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow
+ Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow
+ Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow
+ Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow
+ Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow
+ Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow
+ }
+ }
+
$DiagPythonVersion = $PythonVersion
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
$InitialGpuBranch = "unknown"
@@ -2438,6 +2563,13 @@ exit 0
}
} else {
Write-TauriLog "STEP" "Installing PyTorch"
+ # Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42,
+ # torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the
+ # interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists.
+ $VenvPlatform = ""
+ try {
+ $VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
+ } catch { $VenvPlatform = "" }
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
# Bound the companions to the capped torch on EVERY index, cu
# families included: torchaudio 2.11 dropped its exact torch pin from
@@ -2445,7 +2577,13 @@ exit 0
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
- $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" $_pinVisionSpec $_pinAudioSpec --default-index $TorchIndexUrl }
+ $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)
+ if ($VenvPlatform -eq "win-arm64") {
+ substep "windows on arm: skipping torchaudio (upstream publishes no"
+ substep "win_arm64 wheel); torch and torchvision install normally."
+ $_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec)
+ }
+ $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
diff --git a/studio/install_node_prebuilt.py b/studio/install_node_prebuilt.py
index 82ca1d2c68..1f42729c80 100644
--- a/studio/install_node_prebuilt.py
+++ b/studio/install_node_prebuilt.py
@@ -707,18 +707,55 @@ def existing_install_usable(install_dir: Path, host: HostInfo) -> bool:
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
+def _replace_with_retry(
+ src: Path,
+ dst: Path,
+ *,
+ attempts: int = 8,
+) -> None:
+ """os.replace, retried against transient Windows sharing violations.
+
+ A directory rename fails with WinError 5/32 while any process holds a handle inside
+ it, and Defender or the indexer routinely does right after extraction (seen in CI on
+ a fresh install, with no existing directory to conflict with). Handles clear in a
+ second or two, so a bounded backoff turns the failure into a pause; other errors
+ raise immediately rather than stalling on a real problem.
+ """
+ delay = 0.25
+ for attempt in range(attempts):
+ try:
+ os.replace(src, dst)
+ return
+ except OSError as exc:
+ transient = os.name == "nt" and getattr(exc, "winerror", None) in (5, 32, 145)
+ if not transient or attempt == attempts - 1:
+ raise
+ log(
+ f"rename blocked ({exc.winerror}), retrying in {delay:.2f}s "
+ f"-- a scanner is likely still holding the extracted files"
+ )
+ time.sleep(delay)
+ delay = min(delay * 2, 4.0)
+
+
def _swap_into_place(extracted_root: Path, install_dir: Path) -> None:
"""Atomically replace install_dir with extracted_root (same filesystem)."""
install_dir.parent.mkdir(parents = True, exist_ok = True)
backup: Path | None = None
if install_dir.exists():
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
- os.replace(install_dir, backup)
+ _replace_with_retry(install_dir, backup)
try:
- os.replace(extracted_root, install_dir)
+ _replace_with_retry(extracted_root, install_dir)
except OSError:
+ # The forward rename retries ~16s, ample time for a scanner to grab the backup too.
+ # A plain os.replace would then raise over the original error and leave no
+ # install_dir at all, so the rollback gets the same backoff and never masks it.
if backup is not None and not install_dir.exists():
- os.replace(backup, install_dir)
+ try:
+ _replace_with_retry(backup, install_dir)
+ except OSError as rollback_exc:
+ log(f"could not restore the previous Node install from {backup}: {rollback_exc}")
raise
if backup is not None:
shutil.rmtree(backup, ignore_errors = True)
diff --git a/studio/setup.ps1 b/studio/setup.ps1
index a4eb54a9ef..0b6cf292c2 100644
--- a/studio/setup.ps1
+++ b/studio/setup.ps1
@@ -869,12 +869,22 @@ function Ensure-BuildToolsForLlamaSourceBuild {
}
}
-# Detect the VC++ 2015-2022 Redistributable that the prebuilt llama-server and
-# PyTorch need (they link VCRUNTIME140_1.dll etc., which the Universal CRT lacks).
-# Signal is System32\vcruntime140_1.dll (VS 2019+), registry as fallback.
+# Machine arch: PROCESSOR_ARCHITECTURE describes this PROCESS, so an emulated x64 shell on
+# ARM64 reports AMD64; PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
+function Get-HostMachineArch {
+ $osArch = ""
+ try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { }
+ foreach ($s in @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)) {
+ if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
+ }
+ return "other"
+}
+
+# Detect the VC++ 2015-2022 Redistributable prebuilt llama-server and PyTorch need (they
+# link VCRUNTIME140_1.dll, absent from the Universal CRT). Registry first: Runtimes\x64 is
+# the only x64-specific proof; System32\vcruntime140_1.dll is arch-blind and on ARM64 may
+# be the ARM64-only package, unloadable under x64 emulation.
function Test-VCRedistInstalled {
- $sys = $env:SystemRoot
- if ($sys -and (Test-Path (Join-Path $sys 'System32\vcruntime140_1.dll'))) { return $true }
foreach ($k in @(
'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
@@ -884,10 +894,14 @@ function Test-VCRedistInstalled {
if ($r.Installed -eq 1 -and [int]$r.Major -ge 14 -and [int]$r.Minor -ge 20) { return $true }
} catch { }
}
+ if ((Get-HostMachineArch) -eq "arm64") { return $false }
+ $sys = $env:SystemRoot
+ if ($sys -and (Test-Path (Join-Path $sys 'System32\vcruntime140_1.dll'))) { return $true }
return $false
}
-# Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op).
+# Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op). Unlike CMake
+# and Build Tools torch cannot import without it, and winget is absent on LTSC/Server images.
function Ensure-VCRedist {
if (Test-VCRedistInstalled) { step "vcredist" "present"; return }
Write-Host "Microsoft Visual C++ Redistributable (2015-2022) is missing; the prebuilt llama.cpp and PyTorch need it. Installing the runtime..." -ForegroundColor Yellow
@@ -897,6 +911,45 @@ function Ensure-VCRedist {
Refresh-Environment
} catch { substep "VCRedist install failed: $($_.Exception.Message)" "Yellow" }
}
+ if (-not (Test-VCRedistInstalled)) {
+ # Evergreen link; /quiet /norestart so it never blocks or reboots an unattended run.
+ # Always the x64 package, deliberately: Microsoft ships it as the Arm64X superset of
+ # both ARM64 and X64 binaries and documents it as the one for ARM64 devices, while
+ # the arm64 package is ARM64-only (learn.microsoft.com/cpp/windows/latest-supported-vc-redist).
+ # PROCESSOR_ARCHITECTURE is wrong twice here: it reports the process, and the runtime
+ # must match the interpreter loading the DLLs, an emulated x64 Python not yet created.
+ $url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"
+ $dst = Join-Path ([System.IO.Path]::GetTempPath()) "vc_redist.x64.exe"
+ substep "winget unavailable or failed; downloading the runtime directly..."
+ # Windows PowerShell 5.1 on an old image can carry a .NET default protocol set that
+ # predates TLS 1.2, which aka.ms refuses -- exactly the no-winget host this fallback
+ # exists for. SystemDefault (0) means "let the OS choose" and already covers TLS 1.2+,
+ # so only an explicit legacy set is upgraded, and it is restored afterwards.
+ $_prevProtocol = $null
+ try {
+ $_cur = [System.Net.ServicePointManager]::SecurityProtocol
+ if ([int]$_cur -ne 0 -and ([int]$_cur -band [int][System.Net.SecurityProtocolType]::Tls12) -eq 0) {
+ [System.Net.ServicePointManager]::SecurityProtocol = $_cur -bor [System.Net.SecurityProtocolType]::Tls12
+ $_prevProtocol = $_cur
+ }
+ } catch { $_prevProtocol = $null }
+ try {
+ Invoke-WebRequest -Uri $url -OutFile $dst -UseBasicParsing -TimeoutSec 300
+ $p = Start-Process -FilePath $dst -ArgumentList '/quiet', '/norestart' -Wait -PassThru
+ # 3010 = success, reboot required; usable either way.
+ if ($p.ExitCode -notin @(0, 3010)) {
+ substep "VC++ runtime installer exited $($p.ExitCode)" "Yellow"
+ }
+ Refresh-Environment
+ } catch {
+ substep "Direct VC++ runtime download failed: $($_.Exception.Message)" "Yellow"
+ } finally {
+ if ($null -ne $_prevProtocol) {
+ try { [System.Net.ServicePointManager]::SecurityProtocol = $_prevProtocol } catch { }
+ }
+ Remove-Item -LiteralPath $dst -Force -ErrorAction SilentlyContinue
+ }
+ }
if (Test-VCRedistInstalled) { step "vcredist" "installed" }
else {
substep "Could not install the VC++ Redistributable automatically." "Yellow"
@@ -1650,11 +1703,42 @@ if ($LongPathsEnabled) {
}
# ============================================
-# 1b. Git (required by pip for git+https:// deps and by npm)
+# 1b. Git (only required for --local / source installs)
# ============================================
+# Was fatal as "required by pip and npm", but the consumer path uses neither: the
+# unsloth-zoo git+https URL is STUDIO_LOCAL_INSTALL only, node is a pinned prebuilt, and the
+# frontend lockfile has no VCS deps. Being fatal blocked clean no-winget Windows boxes.
$HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
if (-not $HasGit) {
- Write-Host "Git not found -- installing via winget..." -ForegroundColor Yellow
+ # Fatal only where git is used: --local and the opt-in llama.cpp source build. A local
+ # llama.cpp dir overrides those opt-ins, but only once it holds a reusable binary:
+ # pointing at the canonical install location with nothing built there falls through to
+ # the normal install, so an explicit source build still needs git. The automatic
+ # fallback after a failed prebuilt download is not knowable here; Phase 4 handles it.
+ $gitNeeded = ($env:STUDIO_LOCAL_INSTALL -eq '1')
+ $_localLlamaDir = if ($env:UNSLOTH_LOCAL_LLAMA_CPP_DIR) { $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR.Trim() } else { "" }
+ $_localLlamaBuilt = $false
+ if ($_localLlamaDir) {
+ # Same layout candidates as the reuse check in Phase 4.
+ foreach ($_c in @("llama-server.exe", "build\bin\llama-server.exe", "build\bin\Release\llama-server.exe")) {
+ if (Test-Path -LiteralPath (Join-Path $_localLlamaDir $_c)) { $_localLlamaBuilt = $true; break }
+ }
+ }
+ if (-not $_localLlamaBuilt) {
+ $_prForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
+ $_llamaSrc = $DefaultLlamaSource -replace '\.git$', ''
+ # Same tag resolution as Phase 4. "master" is a branch, never a release, so the
+ # prebuilt lookup always misses and Phase 4 rebuilds it from source.
+ $_llamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag }
+ if ($_llamaTag -eq "master") { $gitNeeded = $true }
+ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq '1') { $gitNeeded = $true }
+ if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_LLAMA_PR)) { $gitNeeded = $true }
+ # Same positive-integer predicate as the PR_FORCE promotion below: 0 or non-numeric
+ # never forces a source build, so it must not demand git.
+ if ($_prForce -match '^\d+$' -and [int]$_prForce -gt 0) { $gitNeeded = $true }
+ if ($_llamaSrc -ne "https://github.com/ggml-org/llama.cpp") { $gitNeeded = $true }
+ }
+ Write-Host "Git not found -- attempting install via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
try {
@@ -1664,11 +1748,18 @@ if (-not $HasGit) {
} catch { }
}
if (-not $HasGit) {
- Write-Host "[ERROR] Git is required but could not be installed automatically." -ForegroundColor Red
- Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red
- Exit-SetupFailure "Git is required but could not be installed automatically"
+ if ($gitNeeded) {
+ Write-Host "[ERROR] Git is required for --local and llama.cpp source-build installs but could not be installed." -ForegroundColor Red
+ Write-Host " --local clones unsloth-zoo, and a source build clones llama.cpp." -ForegroundColor Red
+ Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red
+ Exit-SetupFailure "Git is required for --local / source-build installs but could not be installed"
+ }
+ step "git" "not found (not required)" "Yellow"
+ substep "Unsloth installs prebuilt binaries and wheels, so git is not needed."
+ substep "Install it only for --local/source installs: https://git-scm.com/download/win"
+ } else {
+ step "git" "$(git --version)"
}
- step "git" "$(git --version)"
} else {
step "git" "$(git --version)"
}
@@ -3275,18 +3366,32 @@ $PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR
$TorchInstallIndexUrl = if ($ROCmIndexUrl) { "$PyTorchWhlBase/cpu" } elseif ($PinnedTorchIndexUrl) { $PinnedTorchIndexUrl } else { "$PyTorchWhlBase/$CuTag" }
if (-not $NoTorchMode) {
+# Windows on ARM has win_arm64 torch and torchvision wheels but no torchaudio on any index,
+# so every branch below drops it. Ask the interpreter uv resolves for, not
+# PROCESSOR_ARCHITECTURE, which describes the host process. Inside the no-torch guard
+# because all three uses are, and no-torch installs nothing to skip.
+$_setupPlatform = ""
+try {
+ $_setupPlatform = (& python -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
+} catch { $_setupPlatform = "" }
+$WinArm64NoAudio = ($_setupPlatform -eq "win-arm64")
+if ($WinArm64NoAudio) { substep "windows on arm: skipping torchaudio (no win_arm64 wheel upstream)" }
+
$ROCmCpuFallback = $false
if ($ROCmIndexUrl) {
substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..."
if ($ROCmTorchSpec -ne "torch") {
substep " enforcing $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec (known _grouped_mm bug in older wheels)" "Cyan"
}
+ # Built above the verbose branch: a splat assigned inside it is unset on the other.
+ $_rocmTrio = @($ROCmTorchSpec, $ROCmVisionSpec, $ROCmAudioSpec)
+ if ($WinArm64NoAudio) { $_rocmTrio = @($ROCmTorchSpec, $ROCmVisionSpec) }
if ($script:UnslothVerbose) {
- Fast-Install $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec --force-reinstall --index-url $ROCmIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
+ Fast-Install @_rocmTrio --force-reinstall --index-url $ROCmIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
- $output = Fast-Install $ROCmTorchSpec $ROCmVisionSpec $ROCmAudioSpec --force-reinstall --index-url $ROCmIndexUrl | Out-String
+ $output = Fast-Install @_rocmTrio --force-reinstall --index-url $ROCmIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
@@ -3322,12 +3427,14 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) {
$cpuVisionSpec = "torchvision>=0.19,<0.27.0"
$cpuAudioSpec = "torchaudio>=2.4,<2.12.0"
}
+ $_torchTrio = @($cpuTorchSpec, $cpuVisionSpec, $cpuAudioSpec)
+ if ($WinArm64NoAudio) { $_torchTrio = @($cpuTorchSpec, $cpuVisionSpec) }
if ($script:UnslothVerbose) {
- Fast-Install $cpuTorchSpec $cpuVisionSpec $cpuAudioSpec @cpuForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
+ Fast-Install @_torchTrio @cpuForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
- $output = Fast-Install $cpuTorchSpec $cpuVisionSpec $cpuAudioSpec @cpuForce --index-url $TorchInstallIndexUrl | Out-String
+ $output = Fast-Install @_torchTrio @cpuForce --index-url $TorchInstallIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
@@ -3354,12 +3461,16 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) {
$cudaVisionSpec = "torchvision>=0.19,<0.26.0"
$cudaAudioSpec = "torchaudio>=2.4,<2.11.0"
}
+ # A custom pin whose leaf is not cpu (a corporate /simple mirror) lands an ARM64 host
+ # here, so this branch drops torchaudio too.
+ $_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec, $cudaAudioSpec)
+ if ($WinArm64NoAudio) { $_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec) }
if ($script:UnslothVerbose) {
- Fast-Install $cudaTorchSpec $cudaVisionSpec $cudaAudioSpec @cudaForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
+ Fast-Install @_cudaTrio @cudaForce --index-url $TorchInstallIndexUrl | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
$torchInstallExit = $LASTEXITCODE
$output = ""
} else {
- $output = Fast-Install $cudaTorchSpec $cudaVisionSpec $cudaAudioSpec @cudaForce --index-url $TorchInstallIndexUrl | Out-String
+ $output = Fast-Install @_cudaTrio @cudaForce --index-url $TorchInstallIndexUrl | Out-String
$torchInstallExit = $LASTEXITCODE
}
if ($torchInstallExit -ne 0) {
@@ -4048,6 +4159,7 @@ $BuildDir = Join-Path $LlamaCppDir "build"
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
$HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
+$HasGitForBuild = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
# Check if existing llama-server matches current GPU mode. A CUDA-built binary
# on a now-CPU-only machine (or vice versa) needs to be rebuilt.
@@ -4073,9 +4185,27 @@ if (Test-Path -LiteralPath $LlamaServerBin) {
$WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and `
-not ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master")
if ($WillBuildLlamaFromSource) {
- Ensure-BuildToolsForLlamaSourceBuild
- # refresh so the chain below sees a newly installed cmake
- $HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
+ if (-not $HasGitForBuild) {
+ # Phase 1 keeps git optional, so only the automatic fallback after a failed prebuilt
+ # download arrives here without it. Last chance to install: Invoke-SetupCommand
+ # returns 0 for command-not-found, so a git-less clone misreports as a cmake failure.
+ if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) {
+ try {
+ Invoke-SetupCommand { winget install Git.Git --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
+ Refresh-Environment
+ } catch { }
+ }
+ $HasGitForBuild = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
+ }
+ # Git first, then the toolchain: Ensure-BuildToolsForLlamaSourceBuild exits setup when
+ # Build Tools cannot be installed, so running it first made the degraded path below
+ # unreachable on a no-winget box, and elsewhere spent a multi-GB download on a clone
+ # that cannot happen.
+ if ($HasGitForBuild) {
+ Ensure-BuildToolsForLlamaSourceBuild
+ # refresh so the chain below sees a newly installed cmake
+ $HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
+ }
}
if ($LocalLlamaCppLinked) {
@@ -4093,6 +4223,16 @@ if ($LocalLlamaCppLinked) {
# up new model architecture support (e.g. Gemma 4).
Write-Host ""
step "llama.cpp" "already built"
+} elseif (-not $HasGitForBuild) {
+ # Before cmake: the toolchain install is skipped without git, so cmake may be missing
+ # purely as a consequence. Degrade rather than abort; the opt-in source triggers already
+ # required git in Phase 1, so only the automatic fallback lands here.
+ Write-Host ""
+ step "llama.cpp" "build skipped (git not available)" "Yellow"
+ substep "The prebuilt download failed and a source build clones llama.cpp." "Yellow"
+ substep "GGUF inference and export will not be available." "Yellow"
+ substep "Install Git from https://git-scm.com/download/win and re-run setup." "Yellow"
+ $script:LlamaCppDegraded = $true
} elseif (-not $HasCmakeForBuild) {
Write-Host ""
if (-not $HasNvidiaSmi) {
diff --git a/tests/python/test_cross_platform_parity.py b/tests/python/test_cross_platform_parity.py
index b20e715ebc..06e444314b 100644
--- a/tests/python/test_cross_platform_parity.py
+++ b/tests/python/test_cross_platform_parity.py
@@ -454,9 +454,12 @@ class TestKnown211SetParity:
"$_pinCuLeaf" not in text
), "install.ps1 must bound companions on every index (no cu-family exemption)"
# The bounded companions must actually be passed to the install command.
- assert re.search(
- r'"torch>=2\.4,<2\.11\.0" \$_pinVisionSpec \$_pinAudioSpec --default-index \$TorchIndexUrl',
- text,
+ # Specs are splatted, so check both halves: the list is built, and it is passed.
+ assert (
+ '$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)' in text
+ ), "install.ps1 custom-pin install must build the bounded spec list"
+ assert (
+ "@_torchSpecs --default-index $TorchIndexUrl" in text
), "install.ps1 custom-pin install must pass the bounded companion specs to uv"
def test_gfx_allowlist_matches_across_installers(self):
@@ -704,9 +707,13 @@ class TestPinnedIndexClearsUvEnvParity:
assert (
"if ($TorchIndexPinned -and -not (Test-CudaFamilyLeaf $CuTag)) {" in text
), "the custom-leaf trio bounds must be gated on a pinned non-cu-family leaf"
+ # Specs are splatted, so check both halves: the list is built, and it is passed.
assert (
- "Fast-Install $cudaTorchSpec $cudaVisionSpec $cudaAudioSpec" in text
- ), "setup.ps1's CUDA branch must install via the bounded spec variables"
+ "$_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec, $cudaAudioSpec)" in text
+ ), "setup.ps1's CUDA branch must build the trio from the bounded spec variables"
+ assert (
+ "Fast-Install @_cudaTrio @cudaForce" in text
+ ), "setup.ps1's CUDA branch must install the trio it built"
def test_setup_ps1_bounds_pinned_cpu_torch(self):
"""setup.ps1's CPU branch must bound the trio under an explicit pin (parity with
@@ -724,8 +731,11 @@ class TestPinnedIndexClearsUvEnvParity:
"if ($TorchIndexPinned) {" in text
), "the CPU trio bounds must be gated on an explicit pin"
assert (
- "Fast-Install $cpuTorchSpec $cpuVisionSpec $cpuAudioSpec @cpuForce" in text
- ), "setup.ps1's CPU branch must install via the spec variables"
+ "$_torchTrio = @($cpuTorchSpec, $cpuVisionSpec, $cpuAudioSpec)" in text
+ ), "setup.ps1's CPU branch must build the trio from the spec variables"
+ assert (
+ "Fast-Install @_torchTrio @cpuForce" in text
+ ), "setup.ps1's CPU branch must install the trio it built"
# The ceilings mirror the Python repair spec exactly.
stack = STACK_PY.read_text(encoding = "utf-8")
spec_block = re.search(r"_CUDA_TORCH_PKG_SPEC[^(]*\(\s*(.*?)\)", stack, re.DOTALL)
diff --git a/tests/python/test_windows_arm64_python_choice.py b/tests/python/test_windows_arm64_python_choice.py
new file mode 100644
index 0000000000..89546e7ac4
--- /dev/null
+++ b/tests/python/test_windows_arm64_python_choice.py
@@ -0,0 +1,143 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+"""Windows on ARM: install.ps1 must not settle for a native ARM64 interpreter.
+
+pyarrow (via datasets) and hf-transfer publish no win_arm64 wheels, so an ARM64
+Python source-builds both and dies minutes into the run. The resolver prefers an
+x64 build of the requested minor and bootstraps one otherwise; the case pinned
+here is the recovery path, where nothing can be downloaded but an x64 build of a
+lower-priority supported minor is already installed.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+INSTALL_PS1 = REPO_ROOT / "install.ps1"
+
+
+def _extract(pattern: str, source: str) -> str:
+ match = re.search(pattern, source, flags = re.DOTALL)
+ assert match is not None, f"install.ps1 block not found: {pattern}"
+ return match.group(0)
+
+
+def _resolver_script(installed: list[tuple[str, str]], can_download: bool) -> str:
+ """Both production functions verbatim, over a fake set of interpreters.
+
+ Extracted rather than reimplemented so the test cannot drift away from the
+ text install.ps1 actually runs. `installed` is (minor, arch) in py-launcher
+ order, so the first entry for a minor is what a bare `py -3.13` resolves to.
+ The fake interpreters are named `*.exe` and invoked through the call operator,
+ which resolves a string to a function, so no real binary is needed.
+ """
+ source = INSTALL_PS1.read_text(encoding = "utf-8")
+ finder = _extract(r" function Find-CompatiblePython \{.*?\n \}\n", source)
+ installer = _extract(r" function Install-X64Python \{.*?\n \}\n", source)
+
+ names = [f"Py{minor.replace('.', '')}{arch}.exe" for minor, arch in installed]
+ table = ", ".join(
+ f'@{{ Minor = "{minor}"; Arch = "{arch}"; Name = "{name}" }}'
+ for (minor, arch), name in zip(installed, names)
+ )
+ downloaded = (
+ '@{ Version = "3.13"; Path = "Downloaded.exe"; Arch = "x86_64" }'
+ if can_download
+ else "$null"
+ )
+ version_stubs = "\n".join(
+ f"function {name} {{ param([Parameter(ValueFromRemainingArguments = $true)]$Rest)\n"
+ f' if ($Rest -contains "--version") {{ return "Python {minor}.0" }}\n'
+ f' return "{name}" }}'
+ for (minor, _arch), name in zip(installed, names)
+ )
+ return f"""
+$ErrorActionPreference = "Stop"
+$PythonVersion = "3.13"
+$script:WingetAvailable = $false
+$script:CondaSkipPattern = 'conda'
+$Interpreters = @({table})
+{version_stubs}
+# `py -0p` lists every registration; `py -3.x` runs the launcher's preferred build
+# for that minor, which on an ARM64 host is normally the native one.
+function FakePy {{
+ param([Parameter(ValueFromRemainingArguments = $true)]$Rest)
+ if ($Rest -contains "-0p") {{
+ return @($Interpreters | ForEach-Object {{ " -V:$($_.Minor) * $($_.Name)" }})
+ }}
+ $minor = ([string]$Rest[0]).TrimStart('-')
+ $hit = @($Interpreters | Where-Object {{ $_.Minor -eq $minor }})
+ if ($hit.Count -eq 0) {{ return "" }}
+ if ($Rest -contains "--version") {{ return "Python $minor.0" }}
+ return $hit[0].Name
+}}
+function substep {{ param($a, $b) }}
+function Get-HostMachineArch {{ return "arm64" }}
+function Get-Command {{
+ param([Parameter(Position = 0)][string]$Name,
+ [Parameter(ValueFromRemainingArguments = $true)]$Rest)
+ if ($Name -eq "py") {{ return @([pscustomobject]@{{ Source = "FakePy" }}) }}
+ return @()
+}}
+function Test-Path {{ param([Parameter(ValueFromRemainingArguments = $true)]$Rest) return $true }}
+function Test-IsCondaPython {{ param([string]$Exe) return $false }}
+function Get-PythonPlatformTag {{
+ param([string]$Exe)
+ foreach ($i in $Interpreters) {{
+ if ($i.Name -eq $Exe) {{
+ if ($i.Arch -eq "x86_64") {{ return "win-amd64" }} else {{ return "win-arm64" }}
+ }}
+ }}
+ return "win-amd64"
+}}
+function Refresh-SessionPath {{ }}
+function Install-PythonFromPythonOrg {{ param([string]$Arch = "") return {downloaded} }}
+{finder}
+{installer}
+# The caller's ARM64 swap, condensed to what decides the interpreter.
+$found = Find-CompatiblePython
+if ($found -and $found.Arch -ne "x86_64") {{
+ $x64 = Install-X64Python
+ if ($x64) {{ $found = $x64 }}
+}}
+if ($found) {{ Write-Output "$($found.Version)|$($found.Arch)" }} else {{ Write-Output "none" }}
+"""
+
+
+def _pwsh(script: str) -> str:
+ result = subprocess.run(
+ ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
+ check = True,
+ capture_output = True,
+ text = True,
+ env = os.environ.copy(),
+ )
+ return result.stdout.strip()
+
+
+@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
+@pytest.mark.parametrize(
+ ("installed", "can_download", "expected"),
+ [
+ # An x64 build of the requested minor wins outright, downloads irrelevant.
+ ([("3.13", "arm64"), ("3.13", "x86_64")], False, "3.13|x86_64"),
+ # Requested minor is ARM64-only: bootstrap x64 rather than take the native one.
+ ([("3.13", "arm64")], True, "3.13|x86_64"),
+ # Offline, but an x64 build of a lower-priority minor is here. Use it: the native
+ # 3.13 cannot resolve pyarrow or hf-transfer, and this one can.
+ ([("3.13", "arm64"), ("3.11", "x86_64")], False, "3.11|x86_64"),
+ # ARM64 everywhere: still returned, and the caller warns.
+ ([("3.13", "arm64"), ("3.11", "arm64")], False, "3.13|arm64"),
+ ],
+)
+def test_arm64_host_prefers_an_x64_interpreter(installed, can_download, expected):
+ assert _pwsh(_resolver_script(installed, can_download)) == expected
diff --git a/tests/python/test_windows_git_gate.py b/tests/python/test_windows_git_gate.py
new file mode 100644
index 0000000000..60de430191
--- /dev/null
+++ b/tests/python/test_windows_git_gate.py
@@ -0,0 +1,117 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+"""Git is optional on the consumer Windows path, but still required for source builds."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
+
+_START = "$gitNeeded = ($env:STUDIO_LOCAL_INSTALL -eq '1')"
+_TAIL = "if (-not $_localLlamaBuilt) {"
+
+
+def _git_gate_block() -> str:
+ """Slice the real $gitNeeded computation out of setup.ps1 so the test cannot drift."""
+ source = SETUP_PS1.read_text(encoding = "utf-8")
+ start = source.index(_START)
+ brace = source.index("{", source.index(_TAIL, start))
+ depth = 0
+ for index in range(brace, len(source)):
+ if source[index] == "{":
+ depth += 1
+ elif source[index] == "}":
+ depth -= 1
+ if depth == 0:
+ return source[start : index + 1]
+ raise AssertionError("Unclosed git gate block in setup.ps1")
+
+
+def _script() -> str:
+ return f"""
+$DefaultLlamaPrForce = "0"
+$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
+$DefaultLlamaTag = "latest"
+{_git_gate_block()}
+Write-Output $gitNeeded
+"""
+
+
+def _needs_git(env: dict[str, str]) -> bool:
+ merged = {k: v for k, v in os.environ.items() if not k.startswith(("UNSLOTH_", "STUDIO_"))}
+ merged.update(env)
+ result = subprocess.run(
+ ["pwsh", "-NoProfile", "-NonInteractive", "-Command", _script()],
+ check = True,
+ capture_output = True,
+ text = True,
+ env = merged,
+ )
+ return result.stdout.strip() == "True"
+
+
+pwsh_only = pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
+
+
+@pwsh_only
+@pytest.mark.parametrize(
+ ("env", "expected"),
+ [
+ # The consumer install: prebuilt wheels and a prebuilt llama.cpp, so no git.
+ ({}, False),
+ # --local clones unsloth-zoo.
+ ({"STUDIO_LOCAL_INSTALL": "1"}, True),
+ # Opt-in source builds clone llama.cpp.
+ ({"UNSLOTH_LLAMA_FORCE_COMPILE": "1"}, True),
+ ({"UNSLOTH_LLAMA_PR": "1234"}, True),
+ # PR_FORCE only forces a build for a positive integer.
+ ({"UNSLOTH_LLAMA_PR_FORCE": "0"}, False),
+ ({"UNSLOTH_LLAMA_PR_FORCE": "not-a-number"}, False),
+ ({"UNSLOTH_LLAMA_PR_FORCE": "1234"}, True),
+ # "master" is a branch with no release, so Phase 4 always builds it from source.
+ ({"UNSLOTH_LLAMA_TAG": "master"}, True),
+ # A release tag resolves to a prebuilt bundle.
+ ({"UNSLOTH_LLAMA_TAG": "latest"}, False),
+ ({"UNSLOTH_LLAMA_TAG": "b8635"}, False),
+ ],
+)
+def test_git_is_required_only_for_local_and_source_builds(env, expected):
+ assert _needs_git(env) is expected
+
+
+@pwsh_only
+def test_a_built_local_llama_dir_drops_the_source_build_git_requirement(tmp_path):
+ (tmp_path / "llama-server.exe").write_text("", encoding = "utf-8")
+ env = {
+ "UNSLOTH_LOCAL_LLAMA_CPP_DIR": str(tmp_path),
+ "UNSLOTH_LLAMA_FORCE_COMPILE": "1",
+ }
+ # Reusing an existing binary skips both the prebuilt download and the source build.
+ assert _needs_git(env) is False
+
+
+@pwsh_only
+@pytest.mark.parametrize("trigger", ["UNSLOTH_LLAMA_FORCE_COMPILE", "UNSLOTH_LLAMA_PR"])
+def test_an_unbuilt_local_llama_dir_still_requires_git(tmp_path, trigger):
+ # Nothing built at the canonical install location falls through to the normal install,
+ # so the source build still runs and still needs git. Suppressing the requirement here
+ # let a no-git host silently degrade to a prebuilt instead.
+ env = {
+ "UNSLOTH_LOCAL_LLAMA_CPP_DIR": str(tmp_path),
+ trigger: "1",
+ }
+ assert _needs_git(env) is True
+
+
+@pwsh_only
+def test_an_unbuilt_local_llama_dir_alone_does_not_require_git(tmp_path):
+ assert _needs_git({"UNSLOTH_LOCAL_LLAMA_CPP_DIR": str(tmp_path)}) is False
diff --git a/tests/python/test_windows_vcredist_download_tls.py b/tests/python/test_windows_vcredist_download_tls.py
new file mode 100644
index 0000000000..9fb1c697e2
--- /dev/null
+++ b/tests/python/test_windows_vcredist_download_tls.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+"""The direct VC++ runtime download must negotiate TLS 1.2 on legacy protocol defaults."""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
+
+_START = '$url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"'
+_END = "Remove-Item -LiteralPath $dst -Force -ErrorAction SilentlyContinue\n }"
+
+
+def _download_block() -> str:
+ """Slice the real download block out of setup.ps1 so the test cannot drift."""
+ source = SETUP_PS1.read_text(encoding = "utf-8")
+ start = source.index(_START)
+ end = source.index(_END, start) + len(_END)
+ return source[start:end]
+
+
+def _script(starting_protocol: str) -> str:
+ # Start from a non-zero set that lacks Tls12. Tls13 is the only such value modern .NET
+ # accepts, and it stands in for the legacy Ssl3/Tls default of Windows PowerShell 5.1.
+ return f"""
+function substep {{ param($a, $b) }}
+function Refresh-Environment {{ }}
+function Invoke-WebRequest {{
+ param($Uri, $OutFile, [switch]$UseBasicParsing, $TimeoutSec)
+ Write-Output "DURING=$([System.Net.ServicePointManager]::SecurityProtocol)"
+ throw "stop before Start-Process"
+}}
+[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::{starting_protocol}
+{_download_block()}
+Write-Output "AFTER=$([System.Net.ServicePointManager]::SecurityProtocol)"
+"""
+
+
+def _run(starting_protocol: str) -> dict[str, str]:
+ result = subprocess.run(
+ ["pwsh", "-NoProfile", "-NonInteractive", "-Command", _script(starting_protocol)],
+ check = True,
+ capture_output = True,
+ text = True,
+ )
+ out = {}
+ for line in result.stdout.splitlines():
+ if "=" in line:
+ key, _, value = line.partition("=")
+ out[key.strip()] = value.strip()
+ return out
+
+
+pwsh_only = pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable")
+
+
+@pwsh_only
+def test_tls12_is_added_for_the_download_and_restored_after():
+ seen = _run("Tls13")
+ during = {part.strip() for part in seen["DURING"].split(",")}
+ assert "Tls12" in during, "the download must negotiate TLS 1.2 or aka.ms refuses it"
+ assert "Tls13" in during, "adding TLS 1.2 must not drop protocols the host already allowed"
+ assert seen["AFTER"] == "Tls13", "the process-wide protocol must be restored"
+
+
+@pwsh_only
+def test_system_default_is_left_alone():
+ # SystemDefault means "let the OS choose" and already covers TLS 1.2+; pinning it to
+ # Tls12 would strip TLS 1.3 from every later request in the process.
+ seen = _run("SystemDefault")
+ assert seen["DURING"] == "SystemDefault"
+ assert seen["AFTER"] == "SystemDefault"
diff --git a/tests/studio/install/test_install_node_prebuilt_logic.py b/tests/studio/install/test_install_node_prebuilt_logic.py
index 5476702d65..5bcc9733cf 100644
--- a/tests/studio/install/test_install_node_prebuilt_logic.py
+++ b/tests/studio/install/test_install_node_prebuilt_logic.py
@@ -762,3 +762,94 @@ def test_pinned_target_wrong_sha_not_kept_when_download_fails(tmp_path: Path, mo
monkeypatch.setattr(M, "download_file_verified", _offline) # transient download failure
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "pinned", min_major = 24, force = False)
+
+
+# ── _replace_with_retry: transient Windows sharing violations ──────────────────
+# Seen in CI: WinError 5 renaming extracted Node into place on a FRESH install, a scanner
+# still holding handles inside the new files.
+
+
+def _oserror(winerror: int) -> OSError:
+ exc = OSError(winerror, "mock")
+ exc.winerror = winerror
+ return exc
+
+
+@pytest.mark.parametrize("winerror", [5, 32, 145])
+def test_replace_retries_transient_windows_errors(monkeypatch, tmp_path, winerror):
+ monkeypatch.setattr(M.os, "name", "nt")
+ monkeypatch.setattr(M.time, "sleep", lambda _s: None) # no real backoff in tests
+ calls = {"n": 0}
+
+ def flaky(src, dst):
+ calls["n"] += 1
+ if calls["n"] < 3:
+ raise _oserror(winerror)
+
+ monkeypatch.setattr(M.os, "replace", flaky)
+ M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
+ assert calls["n"] == 3, "should have retried until the handle was released"
+
+
+def test_replace_gives_up_and_reports_the_real_error(monkeypatch, tmp_path):
+ monkeypatch.setattr(M.os, "name", "nt")
+ monkeypatch.setattr(M.time, "sleep", lambda _s: None)
+ monkeypatch.setattr(M.os, "replace", lambda s, d: (_ for _ in ()).throw(_oserror(5)))
+ # A scanner that never lets go must surface as a failure, not a hang.
+ with pytest.raises(OSError) as excinfo:
+ M._replace_with_retry(tmp_path / "src", tmp_path / "dst", attempts = 3)
+ assert excinfo.value.winerror == 5
+
+
+def test_replace_does_not_retry_a_genuine_error(monkeypatch, tmp_path):
+ # A cross-device move or real permissions problem must fail immediately.
+ monkeypatch.setattr(M.os, "name", "nt")
+ monkeypatch.setattr(M.time, "sleep", lambda _s: None)
+ calls = {"n": 0}
+
+ def hard_fail(src, dst):
+ calls["n"] += 1
+ raise _oserror(17) # ERROR_NOT_SAME_DEVICE
+
+ monkeypatch.setattr(M.os, "replace", hard_fail)
+ with pytest.raises(OSError):
+ M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
+ assert calls["n"] == 1
+
+
+def test_replace_is_a_plain_rename_on_posix(monkeypatch, tmp_path):
+ # POSIX has no sharing violations, so the retry must add no latency there.
+ monkeypatch.setattr(M.os, "name", "posix")
+ calls = {"n": 0}
+
+ def once(src, dst):
+ calls["n"] += 1
+ raise _oserror(5)
+
+ monkeypatch.setattr(M.os, "replace", once)
+ with pytest.raises(OSError):
+ M._replace_with_retry(tmp_path / "src", tmp_path / "dst")
+ assert calls["n"] == 1
+
+
+def test_swap_into_place_survives_a_transient_lock(monkeypatch, tmp_path):
+ # End-to-end through the function the installer actually calls.
+ monkeypatch.setattr(M.os, "name", "nt")
+ monkeypatch.setattr(M.time, "sleep", lambda _s: None)
+ extracted = tmp_path / "extracted" / "node-v24"
+ extracted.mkdir(parents = True)
+ (extracted / "marker.txt").write_text("node", encoding = "utf-8")
+ install_dir = tmp_path / "node"
+
+ real_replace = os.replace
+ state = {"failed": False}
+
+ def flaky(src, dst):
+ if not state["failed"]:
+ state["failed"] = True
+ raise _oserror(32)
+ real_replace(src, dst)
+
+ monkeypatch.setattr(M.os, "replace", flaky)
+ M._swap_into_place(extracted, install_dir)
+ assert (install_dir / "marker.txt").read_text(encoding = "utf-8") == "node"