Windows: unblock the consumer install on clean and no-winget machines (#7549)
* Windows: unblock the consumer install on clean and no-winget machines Four independent things stop a clean Windows box today. git was a hard Exit-SetupFailure in setup.ps1, justified as required by pip for git+https:// deps and by npm. Neither holds on the consumer path: the unsloth-zoo git+https URL is only used under STUDIO_LOCAL_INSTALL, node is a pinned nodejs.org prebuilt that never touches system npm, and the frontend lockfile has no VCS dependencies. It stays fatal for --local, where it really is needed. Ensure-VCRedist was winget-only, so on hosts without winget (LTSC, Server, managed corporate images) it silently did nothing while the install reported success, and torch then failed to import on a missing VCRUNTIME140.dll. Adds a direct aka.ms/vs/17/release/vc_redist.<arch>.exe download with /quiet /norestart, accepting exit codes 0 and 3010. The redistributable stays required: it is the runtime the prebuilt llama-server and torch link against, not the MSVC compiler, which is already detection-only. Windows on ARM has no PyTorch at all. Measured with uv against download.pytorch.org/whl/cpu and PyPI for aarch64-pc-windows-msvc / cp313: torch, torchvision and torchaudio all resolve to nothing, wheels exist only for win_amd64 and the manylinux targets. The installer burned three uv retries on an unsatisfiable resolution and reported a bare 'Failed to install PyTorch (exit code 1)'. Now it says what is actually wrong and points at --no-torch, which works because llama.cpp does publish windows-arm64-cpu. install_node_prebuilt.py hit '[WinError 5] Access is denied' on os.replace of the freshly extracted directory during a FRESH install, which is a scanner or indexer holding handles for a moment. Retries only winerror 5, 32 and 145 with capped exponential backoff; any other OSError still raises immediately. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Give the ARM64 dead end a recovery that works for web installs The only remedy printed was .\install.ps1 --no-torch, but the documented path is irm | iex, where no file exists and flags cannot be forwarded. Name the env var the script already honours at line 145. * Windows on ARM: drop torchaudio, do not abort the install The fail-fast was based on a wrong premise. Counted against download.pytorch.org/whl/cpu: torch has 42 win_arm64 wheels and torchvision 60; only torchaudio has none. PyTorch has shipped Arm-native Windows builds since April 2025, so aborting blocked a platform that mostly works. Drop the one unsatisfiable pin instead. Decide from the interpreter uv will resolve for, not the PowerShell host: an x64 CPython under emulation gets working win_amd64 wheels on an ARM64 box, and powershell.exe inherits PROCESSOR_ARCHITECTURE from its parent. * Carry the ARM64 torchaudio omission into studio setup Dropping it from the first PyTorch command was not enough: install.ps1 then runs studio setup with SKIP_STUDIO_BASE=1 and setup.ps1 reinstalls the bare trio from the CPU index, so the ARM64 path still aborted. Apply the same interpreter-based test there. An unreadable platform keeps the full trio. * Build the torch spec list outside the verbose branch The ARM64 guard landed inside `if ($script:UnslothVerbose)`, so on the default path $_torchTrio was never assigned and the splat expanded to nothing: uv ran as `uv pip install --index-url ...` with no package, exit 2, straight to Exit-SetupFailure. That broke the ordinary Windows install. Hoist it above the branch and use substep, which prints on both paths. Realign the two parity guards to the splat form; they asserted the pre-refactor literal command and were the actual cause of the red parity legs. Both halves are still checked: the bounded list is built, and it reaches the install. * Tighten the comments on the Windows install path * Windows install: honour the ARM64 torchaudio skip everywhere and keep git for source builds Hoist the venv-interpreter platform probe above every torch branch in studio/setup.ps1 so the win_arm64 torchaudio omission applies to the ROCm, CPU and CUDA/custom paths. A pinned index whose leaf is not cpu routed an ARM64 host into the CUDA/custom branch, which still asked for torchaudio. Require git again when a llama.cpp source build is opted into up front (UNSLOTH_LLAMA_FORCE_COMPILE, UNSLOTH_LLAMA_PR / PR_FORCE, a non-upstream source). Those paths git clone in phase 4, so setup used to report git as not required, install the build toolchain, then fail at the clone. A local llama.cpp dir overrides them, and the automatic source fallback after a failed prebuilt download stays non-fatal. Also tighten the comments across the changed install paths. * Install the x64 VC++ runtime unconditionally in the direct-download fallback The winget branch always installs Microsoft.VCRedist.2015+.x64, but the direct-download fallback picked the package from PROCESSOR_ARCHITECTURE, which reports the architecture of the running PowerShell process rather than the interpreter that will load the DLLs. Find-CompatiblePython in install.ps1 selects an interpreter on version and non-Conda status alone, with no architecture predicate, so a native ARM64 shell can settle on an emulated x64 Python whose win_amd64 torch and prebuilt llama-server need the x64 runtime, while the fallback had just installed the ARM64-only package. Ensure-VCRedist also runs well before the venv exists, so the interpreter cannot be probed at that point. Microsoft ships the x64 redistributable as an Arm64X superset that carries both ARM64 and x64 binaries, so it is correct on both machines and the manual instruction printed on failure already pointed at it. * Windows on ARM: prefer an x64 Python interpreter An ARM64 host cannot complete the install with a native ARM64 interpreter. pyarrow, pulled in by unsloth -> datasets, has never published a win_arm64 wheel on any version, and neither has hf-transfer, a direct dependency. Both therefore fall back to a source build: pyarrow dies in scikit-build-core CMake configuration and hf-transfer dies in openssl-sys for want of perl, several minutes into a run that looked healthy. torch and torchvision are not the problem, they have win_arm64 wheels and install fine. Windows 11 on ARM runs x64 binaries under emulation and both packages ship win_amd64 wheels, so an x64 interpreter installs cleanly. Find-CompatiblePython accepted an interpreter on version and non-Conda status alone. It now ranks candidates by architecture on ARM64 hosts and returns an x64 one when present, asking each interpreter for its own sysconfig.get_platform() rather than guessing from its path. Host architecture comes from PROCESSOR_ARCHITEW6432 and OSArchitecture as well as PROCESSOR_ARCHITECTURE, which describes only the current process and reads AMD64 in an emulated shell. This is a preference, not a requirement. If only ARM64 is found, x64 is bootstrapped through winget --architecture x64 or the python.org fallback, and if neither works the installer names pyarrow and hf-transfer up front instead of failing later on a CMake or Rust error. The ARM64 torchaudio skip stays live for that path. Non-ARM hosts return on the first match exactly as before, with no extra interpreter probing. * Windows install: three correctness fixes on the ARM64 and git-less paths Ensure-VCRedist never reached its x64 download on an ARM64 machine that already had the arm64 redistributable: Test-VCRedistInstalled accepted System32\vcruntime140_1.dll regardless of architecture, and there that file can be the pure-ARM64 package. An ARM64 PE cannot load into an emulated x64 process, so the x64 Python this branch now prefers would have been left without a usable runtime. The x64 registry entry is the only x64-specific proof, and Microsoft registers Runtimes\{x86|x64|arm64} per architecture, so vc_redist.x64.exe still writes Runtimes\x64 on an ARM64 host and the check cannot loop. The DLL probe stays for x64 hosts. Phase 1 demanded git for any non-blank UNSLOTH_LLAMA_PR_FORCE, but the promotion that actually turns it into a source build requires a positive integer, so PR_FORCE=0 or a non-numeric value aborted a git-less consumer install for a build that never runs. Both sites now use the same predicate. The automatic fallback after a failed prebuilt llama.cpp download reached git clone with no git check anywhere in between, and Invoke-SetupCommand returns 0 for a command-not-found, so a git-less host did not stop there: it continued into an empty directory and reported a cmake configure failure instead. Git is now resolved where the source build is decided, with a last winget attempt, and a missing git degrades exactly like a missing cmake rather than aborting, since the opt-in source triggers already required git in Phase 1. Also tightened the comments across the changed Windows install code, keeping the reasons on the guards that prevent a specific failure. * Rank ARM64 Python candidates by minor version before architecture The x64 preference filtered the whole candidate list on architecture, which outranks the version preference the candidates were collected in. With UNSLOTH_PYTHON=3.12 on a Windows ARM64 box holding an ARM64 3.12 and an x64 3.13, it returned the x64 3.13: the explicit pin was silently broken, and because a x64 interpreter was found the caller never ran Install-X64Python to fetch an x64 3.12. With no pin it was worse still, since an x64 3.11 outranked a newer ARM64 3.13 and defeated the newest-first fallback. Walk $minors in order and take the x64 build of the best minor available, falling back to that minor's ARM64 build so the caller bootstraps x64 for the version actually requested. x64 still wins within a minor, and non-ARM hosts are untouched. * Windows install: see every registered Python, order git before the toolchain Find-CompatiblePython only ever probed `py -3.X`, which runs the launcher's preferred build for that minor. On an ARM64 box that is the native ARM64 interpreter, so a same-minor x64 install that is registered with the launcher but neither preferred nor on PATH never became a candidate. The x64 preference then lost to ARM64, and Install-X64Python re-downloaded an x64 CPython that was already on the machine; when that download is unavailable the install continues on ARM64 and source-builds pyarrow and hf-transfer, which publish no win_arm64 wheels. Enumerate `py -0p` on ARM64 hosts and probe each listed path. The `-3.12-64` suffix cannot be used for this: it has meant "not 32-bit" since 3.11 and does not distinguish arm64 from amd64. studio/setup.ps1 ran Ensure-BuildToolsForLlamaSourceBuild before checking git in Phase 4. That helper calls Exit-SetupFailure when Visual Studio Build Tools cannot be installed, so on a clean no-winget box the git degraded path added by this PR was unreachable and a standalone update aborted instead of finishing in limited mode; where winget does exist it spent a multi-GB Build Tools download on a clone that could never run. Check and install git first, skip the toolchain helper when git is still missing, and report the git branch before the cmake branch so the message names the real cause. _swap_into_place retried the forward rename for about 16 seconds but rolled back with a bare os.replace. A scanner holding the backup for the same WinError 5/32 then left no install_dir at all and stranded the working runtime in .old-*, and its exception replaced the original failure. The rollback now uses the same backoff and logs instead of masking the error it is recovering from. * Installer: use an already installed x64 Python on ARM64 when none can be downloaded Find-CompatiblePython ranks x64 within one minor and returns the native build when that minor is ARM64-only, leaving Install-X64Python to bootstrap x64. On an offline or winget-less box that bootstrap fails, and the retry went through the same resolver, so an x64 build of a lower-priority supported minor already on the machine was never picked up and setup continued on ARM64 Python, where pyarrow and hf-transfer have no wheels. Add an -X64Only mode that returns the best installed x64 interpreter or nothing, and call it as the last resort in Install-X64Python. The version-first preference is unchanged: x64 of the requested minor is still bootstrapped first. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in the Windows ARM64 installer changes * Setup: require Git for a source build behind an unbuilt local llama.cpp dir UNSLOTH_LOCAL_LLAMA_CPP_DIR only overrides the source-build opt-ins once the directory holds a reusable llama-server.exe. Pointing it at the canonical install location with nothing built there falls through to the normal install, so the Phase 1 gate now probes the same layout candidates as the Phase 4 reuse check before dropping the requirement. * Setup: require Git when UNSLOTH_LLAMA_TAG=master forces a source build * Tighten comments in the Windows installer changes * Setup: negotiate TLS 1.2 for the direct VC++ runtime download --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com>
This commit is contained in:
parent
bd3972804d
commit
9bfa18cdb0
8 changed files with 791 additions and 35 deletions
146
install.ps1
146
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*"?(?<p>\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<digits>
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
172
studio/setup.ps1
172
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
|
||||
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 but could not be installed automatically"
|
||||
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)"
|
||||
}
|
||||
} 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) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
143
tests/python/test_windows_arm64_python_choice.py
Normal file
143
tests/python/test_windows_arm64_python_choice.py
Normal file
|
|
@ -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
|
||||
117
tests/python/test_windows_git_gate.py
Normal file
117
tests/python/test_windows_git_gate.py
Normal file
|
|
@ -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
|
||||
80
tests/python/test_windows_vcredist_download_tls.py
Normal file
80
tests/python/test_windows_vcredist_download_tls.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue