Installer: GPU detection follow-ups after #6174 (poisoned venv repair, llama.cpp routing, probe bounds) (#6183)

* Installer: harden GPU detection follow-ups after #6174

Ports the NVIDIA-priority and /proc/driver/nvidia/gpus hardening from #6174
to the remaining pathways and adds recovery for already-poisoned venvs:

- install_python_stack.py: add _ensure_cuda_torch so 'unsloth studio update'
  force-reinstalls CUDA torch when the venv carries a ROCm build on an NVIDIA
  Linux host (the pre-#6174 poisoning signature). Honors UNSLOTH_TORCH_BACKEND,
  UNSLOTH_ROCM_TORCH_INSTALLED, and CUDA_VISIBLE_DEVICES=-1/'' opt-outs; never
  touches healthy CUDA, deliberate CPU wheels, macOS, or Windows.
- install_llama_prebuilt.py: detect_host gains the /proc NVIDIA fallback and
  skips ROCm probes when NVIDIA is usable; forwarded --rocm-gfx/--has-rocm
  overrides still win.
- setup.sh: GPU summary classifies NVIDIA first through a timeout-bounded
  probe with the /proc fallback; AMD probes are bounded and gain a KFD
  vendor_id 4098 fallback; the llama.cpp source build only selects
  GGML_CUDA/GGML_HIP when the matching GPU is actually detected.
- install.sh: bound both nvidia-smi calls with a 10s timeout (no behavior
  change when healthy or when the timeout binary is absent); classify the
  exported UNSLOTH_TORCH_BACKEND on the final index path segment so custom
  mirrors containing 'rocm'/'gfx' in their base path are not mislabeled.
- install.ps1 + setup.ps1: NVIDIA probes now require a real 'GPU N:' row from
  nvidia-smi -L under a 10s bound instead of bare exit code 0; later CUDA
  version and compute_cap queries are bounded too.

Tests: 3 new test files (50+ tests), suite at 788 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix Resolve-CudaToolkit driver probe for extracted-function unit test

tests/studio/test_resolve_cuda_toolkit.ps1 extracts Resolve-CudaToolkit alone
into a child pwsh and stubs nvidia-smi with a .ps1 script. The bounded runner
is not in scope there (and ProcessStartInfo cannot dispatch .ps1 stubs), so
the DriverMaxCuda parse silently returned nothing and the major-mismatch
scenarios failed. Fall back to direct invocation when Invoke-NvidiaSmiBounded
is unavailable; production setup.ps1 always has it defined and keeps the
10s bound.

* Treat CUDA_VISIBLE_DEVICES empty or -1 as hidden in NVIDIA-first guards

The NVIDIA-first guards added in this branch only special-cased
CUDA_VISIBLE_DEVICES=-1 at two setup.sh gates and ignored the empty-string
form entirely, while the Python detector (install_llama_prebuilt.py)
already treats both as hidden. On a mixed AMD+NVIDIA host steered to the
AMD card via CUDA_VISIBLE_DEVICES, the guards suppressed the AMD probes,
so setup.sh fell to a CPU llama.cpp build and install.sh picked CUDA
wheels instead of ROCm.

Move the policy into the helpers so every consumer agrees:

- install.sh: new _cvd_hides_nvidia checked first in _has_usable_nvidia_gpu
- studio/setup.sh: same via _setup_cvd_hides_nvidia; the two ad-hoc
  CUDA_VISIBLE_DEVICES=-1 gate conditions are now redundant and removed
- studio/install_python_stack.py: _has_usable_nvidia_gpu returns False
  when CUDA_VISIBLE_DEVICES is set to  or -1 (whitespace tolerated)

Tests: 5 new sh scenarios (hidden via , -1, padded -1, visible device,
and mixed host with hidden NVIDIA restoring the ROCm route) plus a pytest
class covering all three implementations behaviourally.

Addresses the review comment on the NVIDIA-first setup.sh block.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Retrigger CI after PyPI 503 outage during the previous run

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-06-11 05:06:02 -07:00 committed by GitHub
commit 2db9fad4b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1439 additions and 74 deletions

View file

@ -1406,14 +1406,58 @@ shell.Run cmd, 0, False
}
}
# ── Helper: run nvidia-smi under a timeout ──
# A wedged NVIDIA driver can make nvidia-smi block during init or after a
# reset; WaitForExit bounds it (mirrors Invoke-AmdSmiNoElevate) so detection
# cannot hang the installer. No RunAsInvoker compat layer: nvidia-smi does
# not auto-elevate. Returns combined stdout+stderr; "" on timeout/failure.
function Invoke-NvidiaSmiBounded {
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Exe,
[Parameter(Position = 1)][string[]]$SmiArgs = @(),
[int]$TimeoutSec = 10
)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = ($SmiArgs -join ' ')
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
$global:LASTEXITCODE = 124
return ""
}
$global:LASTEXITCODE = $proc.ExitCode
return ($outTask.Result + "`n" + $errTask.Result)
} catch {
$global:LASTEXITCODE = 1
return ""
}
}
# ── Helper: nvidia-smi -L lists at least one real GPU ──
# Exit code 0 alone is not enough: a stale/driverless nvidia-smi can exit 0
# while listing no GPU, which would mark an AMD host NVIDIA and suppress
# ROCm detection. Require a "GPU <n>:" data row.
function Test-NvidiaSmiHasGpu {
param([Parameter(Mandatory = $true)][string]$Exe)
$out = Invoke-NvidiaSmiBounded $Exe @('-L')
return ($LASTEXITCODE -eq 0 -and $out -match '(?m)^GPU\s+\d+:')
}
# ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ──
$HasNvidiaSmi = $false
$NvidiaSmiExe = $null
try {
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($nvSmiCmd) {
& $nvSmiCmd.Source *> $null
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $nvSmiCmd.Source }
if ($nvSmiCmd -and (Test-NvidiaSmiHasGpu $nvSmiCmd.Source)) {
$HasNvidiaSmi = $true; $NvidiaSmiExe = $nvSmiCmd.Source
}
} catch {}
if (-not $HasNvidiaSmi) {
@ -1423,8 +1467,7 @@ shell.Run cmd, 0, False
)) {
if (Test-Path $p) {
try {
& $p *> $null
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $p; break }
if (Test-NvidiaSmiHasGpu $p) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $p; break }
} catch {}
}
}
@ -1694,7 +1737,7 @@ shell.Run cmd, 0, False
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
try {
$output = & $NvidiaSmiExe 2>&1 | Out-String
$output = Invoke-NvidiaSmiBounded $NvidiaSmiExe
# Newer NVIDIA drivers (e.g. 610.x on Windows) print
# "CUDA UMD Version: X.Y" instead of the legacy "CUDA Version: X.Y".
# Accept both spellings so we don't fall through to the cu126 default.

View file

@ -1745,13 +1745,42 @@ _has_amd_rocm_gpu() {
return 1
}
# ── Bounded command runner ──
# Runs a command under a 10s timeout when the `timeout` binary is available,
# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
# driver init or after a reset) from hanging the installer: a timed-out probe
# exits nonzero and is treated exactly like a failed probe. No-op semantics on
# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
_run_bounded() {
if command -v timeout >/dev/null 2>&1; then
timeout 10 "$@"
else
"$@"
fi
}
# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
# var, so the probes below cannot see the distinction on their own.
_cvd_hides_nvidia() {
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
[ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
}
# ── NVIDIA usable-GPU helper ──
# Returns 0 (true) if an NVIDIA GPU is present and usable.
# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
# -- handles PATH gaps, subprocess timeouts, and driver init races that
# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
_has_usable_nvidia_gpu() {
if _cvd_hides_nvidia; then
return 1
fi
_nvsmi=""
if command -v nvidia-smi >/dev/null 2>&1; then
_nvsmi="nvidia-smi"
@ -1759,7 +1788,7 @@ _has_usable_nvidia_gpu() {
_nvsmi="/usr/bin/nvidia-smi"
fi
if [ -n "$_nvsmi" ]; then
if "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
return 0
fi
fi
@ -1871,7 +1900,11 @@ get_torch_index_url() {
# of the legacy "CUDA Version: X.Y"; accept both with two BRE expressions
# (POSIX sed does not support "?" without -E). The two patterns are
# mutually exclusive per line, so head -1 picks the first emitted match.
_cuda_ver=$(LC_ALL=C $_smi 2>/dev/null \
# Bound the call (a wedged nvidia-smi would otherwise hang here) and force
# the C locale for stable parsing. LC_ALL is exported inside this command
# substitution subshell so it reaches nvidia-smi through _run_bounded
# without depending on `env`; the export is scoped to the subshell.
_cuda_ver=$(export LC_ALL=C; _run_bounded "$_smi" 2>/dev/null \
| sed -n \
-e 's/.*CUDA UMD Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \
-e 's/.*CUDA Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \
@ -2125,10 +2158,16 @@ TORCH_INDEX_URL=$(get_torch_index_url)
# Export the resolved torch backend ("cuda", "rocm", or "cpu") so that
# downstream scripts (setup.sh -> install_python_stack.py) know what was
# chosen here and can skip ROCm-specific repair steps on CUDA/CPU hosts.
case "$TORCH_INDEX_URL" in
*/rocm*|*/gfx*) export UNSLOTH_TORCH_BACKEND="rocm" ;;
*/cpu) export UNSLOTH_TORCH_BACKEND="cpu" ;;
*) export UNSLOTH_TORCH_BACKEND="cuda" ;;
# Classify on the FINAL path segment only: a custom UNSLOTH_PYTORCH_MIRROR
# whose base path happens to contain "rocm" or "gfx" must not mislabel a
# cu*/cpu index as ROCm (radeon repo URLs end in rocm-rel-X.Y/, Strix
# overrides in gfxNNNN/, so the trailing slash is stripped first).
_torch_index_leaf="${TORCH_INDEX_URL%/}"
_torch_index_leaf="${_torch_index_leaf##*/}"
case "$_torch_index_leaf" in
rocm*|gfx*) export UNSLOTH_TORCH_BACKEND="rocm" ;;
cpu) export UNSLOTH_TORCH_BACKEND="cpu" ;;
*) export UNSLOTH_TORCH_BACKEND="cuda" ;;
esac
# rocm7.2 ships torch 2.11.0 -- adjust the constraint to allow it.

View file

@ -2890,7 +2890,27 @@ def detect_host() -> HostInfo:
except Exception:
pass
# Linux /proc/driver/nvidia/gpus fallback: the NVIDIA driver exposes one
# subdir per GPU here regardless of nvidia-smi state, so a host whose
# nvidia-smi is absent from PATH, wedged, or failing is still recognised as
# NVIDIA. Mirrors the fallback added to install.sh / install_python_stack.py
# in PR 6174 so the prebuilt installer does not misroute such hosts to ROCm
# or CPU. driver_cuda_version / compute_caps stay unset here; downstream
# CUDA asset selection treats unknown SMs as "prefer portable" and an
# unknown driver runtime line as "no published CUDA match" (returns None,
# no crash), so planning falls back to a source build with GGML_CUDA=ON.
if is_linux and not has_physical_nvidia:
try:
proc_gpu_dir = "/proc/driver/nvidia/gpus"
if os.path.isdir(proc_gpu_dir) and os.listdir(proc_gpu_dir):
has_physical_nvidia = True
has_usable_nvidia = visible_device_tokens != []
except OSError:
pass
# Detect AMD ROCm (HIP) -- require actual GPU, not just tools installed
# NVIDIA takes precedence: when an NVIDIA GPU is usable, skip ROCm probing
# entirely so co-installed ROCm tools cannot misroute the host (PR 6174).
def _amd_smi_has_gpu(stdout: str) -> bool:
"""Check for 'GPU: <number>' data rows, not just a table header."""
@ -2898,7 +2918,7 @@ def detect_host() -> HostInfo:
has_rocm = False
rocm_gfx_target: str | None = None
if is_linux:
if is_linux and not has_usable_nvidia:
# WSL2 ROCDXG: the system rocminfo enumerates the GPU over /dev/dxg
# only when HSA_ENABLE_DXG_DETECTION=1 (a no-op on bare metal), and
# rocminfo can live only under /opt/rocm/bin (the profile.d PATH
@ -2937,7 +2957,7 @@ def detect_host() -> HostInfo:
has_rocm = True
rocm_gfx_target = _pick_rocm_gfx_target(_result.stdout)
break
elif is_windows:
elif is_windows and not has_usable_nvidia:
# Windows: prefer active probes that validate GPU presence.
# hipinfo / amd-smi are often NOT on PATH -- the HIP SDK installer
# sets HIP_PATH / ROCM_PATH but does not always add the bin dir to

View file

@ -90,6 +90,18 @@ _PYTORCH_WHL_BASE = (
os.environ.get("UNSLOTH_PYTORCH_MIRROR") or "https://download.pytorch.org/whl"
).rstrip("/")
# CUDA torch repair specs (see _ensure_cuda_torch). torchvision/torchaudio are
# pinned to the torch<2.11 family rather than left bare: the install uses an
# exclusive --index-url (no PyPI fallback), so a bare name could resolve a
# torchvision built against a different torch major (e.g. 0.27 for torch 2.12)
# and fail at runtime with an ABI mismatch. Same bounds as the _default ROCm
# spec above, which targets the same torch family.
_CUDA_TORCH_PKG_SPEC: tuple[str, str, str] = (
"torch>=2.4,<2.11.0",
"torchvision>=0.19,<0.26.0",
"torchaudio>=2.4,<2.11.0",
)
# AMD Windows ROCm wheels (repo.amd.com/rocm/whl/{arch_family}/).
# Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped/mirror installs.
_ROCM_WINDOWS_INDEX_BASE = (
@ -654,7 +666,15 @@ def _has_usable_nvidia_gpu() -> bool:
case where nvidia-smi is present but the subprocess fails (PATH gap,
timeout, driver initialisation race). If either probe confirms an
NVIDIA GPU the function returns True so _has_rocm_gpu() is blocked.
CUDA_VISIBLE_DEVICES set to "" or "-1" hides every NVIDIA device (mixed
AMD+NVIDIA hosts steering work to the AMD card); neither probe honours
that env var, so check it first and report the GPU as not usable. Unset
means all devices visible.
"""
cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if cvd is not None and cvd.strip() in ("", "-1"):
return False
exe = shutil.which("nvidia-smi")
if exe:
try:
@ -786,6 +806,139 @@ def _install_bnb_windows_rocm() -> bool:
return True
def _detect_cuda_torch_index_url() -> str:
"""Return the pytorch.org CUDA wheel index URL for the host's NVIDIA driver.
Mirrors install.sh::get_torch_index_url's CUDA ladder so `studio update`
repairs to the same wheel family a fresh `curl | sh` install would pick.
Probes nvidia-smi (PATH, then /usr/bin/nvidia-smi) and parses both the
legacy "CUDA Version:" and the newer "CUDA UMD Version:" spellings.
Defaults to cu126 when nvidia-smi is missing or the version is unreadable
(e.g. NVIDIA detected only via the /proc/driver/nvidia/gpus fallback).
"""
exe = shutil.which("nvidia-smi")
if not exe and os.path.isfile("/usr/bin/nvidia-smi"):
exe = "/usr/bin/nvidia-smi"
tag = "cu126" # default when the driver CUDA version cannot be read
if exe:
try:
result = subprocess.run(
[exe],
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 10,
)
if result.returncode == 0:
m = re.search(r"CUDA(?: UMD)? Version:\s*(\d+)\.(\d+)", result.stdout)
if m:
major, minor = int(m.group(1)), int(m.group(2))
if major >= 13:
tag = "cu130"
elif major == 12 and minor >= 8:
tag = "cu128"
elif major == 12 and minor >= 6:
tag = "cu126"
elif major >= 12:
tag = "cu124"
elif major >= 11:
tag = "cu118"
else:
tag = "cpu" # ancient driver: no usable CUDA wheels
except Exception:
pass
return f"{_PYTORCH_WHL_BASE}/{tag}"
def _ensure_cuda_torch() -> None:
"""Repair a venv whose torch is a ROCm build on an NVIDIA host.
Counterpart to _ensure_rocm_torch. A venv poisoned by the pre-fix KFD
gpu_id false positive (ROCm torch installed on an NVIDIA-only machine)
keeps that broken torch on `studio update`, because a torch+rocm wheel
satisfies the version constraint and nothing force-reinstalls it. This
detects that exact case and reinstalls CUDA torch.
Only repairs when torch actually links against HIP/ROCm. Healthy CUDA
torch and deliberate CPU-only torch are left untouched.
"""
# Respect an explicit backend choice from install.sh: only "" (standalone
# `studio update`) or "cuda" should ever force CUDA wheels. "rocm"/"cpu"
# (or any unrecognised value) are deliberate and must not be overridden.
if _TORCH_BACKEND not in ("", "cuda"):
return
# No CUDA torch on macOS; Windows venv/torch lifecycle is owned by
# install.ps1 (and the KFD poisoning bug is Linux-only), so skip both.
if IS_MACOS or IS_WINDOWS or NO_TORCH:
return
# Never undo a deliberate ROCm install (setup.ps1 sets this marker).
if os.environ.get("UNSLOTH_ROCM_TORCH_INSTALLED") == "1":
return
# CUDA_VISIBLE_DEVICES="" / "-1" deliberately hides the NVIDIA GPU (for
# example a mixed AMD+NVIDIA host that runs ROCm torch on the AMD card);
# never force CUDA wheels over that choice.
_cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if _cvd is not None and _cvd.strip() in ("", "-1"):
return
# Only NVIDIA hosts should carry CUDA torch. _has_usable_nvidia_gpu()
# covers the /proc/driver/nvidia/gpus fallback when nvidia-smi is absent.
if not _has_usable_nvidia_gpu():
return
# Classify the installed torch: "hip" (ROCm build -- the poisoning
# signature), "cuda" (healthy), or "cpu" (deliberate CPU wheel). A
# non-zero exit means torch is missing or un-importable; the base install
# step handles that, so leave it alone.
try:
probe = subprocess.run(
[
sys.executable,
"-c",
(
"import torch; "
"hip = getattr(torch.version, 'hip', '') or ''; "
"cuda = getattr(torch.version, 'cuda', '') or ''; "
"ver = getattr(torch, '__version__', '').lower(); "
"print('hip' if (hip or 'rocm' in ver) else ('cuda' if cuda else 'cpu'))"
),
],
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
timeout = 90,
)
except (OSError, subprocess.TimeoutExpired):
return
if probe.returncode != 0:
return
# Take the last non-empty stdout line: stray output from sitecustomize or
# an import hook must not mask the marker (fail-closed either way).
_marker_lines = [
line.strip() for line in probe.stdout.decode(errors = "replace").splitlines() if line.strip()
]
if not _marker_lines or _marker_lines[-1] != "hip":
return # healthy CUDA torch, or a deliberate CPU wheel -- leave as-is
index_url = _detect_cuda_torch_index_url()
_torch_pkg, _vision_pkg, _audio_pkg = _CUDA_TORCH_PKG_SPEC
print(
f" torch is a ROCm build on an NVIDIA host -- reinstalling "
f"CUDA torch from {index_url}\n"
f" (set UNSLOTH_TORCH_BACKEND=rocm to keep a deliberate ROCm torch "
f"on a mixed AMD+NVIDIA host)"
)
pip_install(
"CUDA torch repair",
"--force-reinstall",
"--no-cache-dir",
_torch_pkg,
_vision_pkg,
_audio_pkg,
"--index-url",
index_url,
constrain = False,
)
def _ensure_rocm_torch() -> None:
"""Reinstall torch with ROCm wheels when the venv received CPU-only torch.
@ -1848,6 +2001,7 @@ def install_python_stack() -> int:
# Must follow base packages so torch is present for inspection.
if not IS_MACOS and not NO_TORCH:
_progress(_torch_step_label("check"))
_ensure_cuda_torch()
_ensure_rocm_torch()
# Windows + AMD GPU: warn if ROCm torch was not installed (wrong Python
@ -2033,6 +2187,7 @@ def install_python_stack() -> int:
# whichever intermediate step clobbered it.
if not IS_WINDOWS and not IS_MACOS and not NO_TORCH:
_progress(_torch_step_label("final"))
_ensure_cuda_torch()
_ensure_rocm_torch()
# 14. Final check (silent; third-party conflicts are expected)

View file

@ -299,7 +299,10 @@ function Get-CudaComputeCapability {
if (-not $smiExe) { return $null }
try {
$raw = & $smiExe --query-gpu=compute_cap --format=csv,noheader 2>$null
# Bounded: a wedged nvidia-smi must not hang setup after the initial
# -L probe succeeded (the helper merges stderr after stdout, so the
# first line is still the compute_cap value).
$raw = Invoke-NvidiaSmiBounded $smiExe @('--query-gpu=compute_cap', '--format=csv,noheader')
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
# nvidia-smi may return multiple GPUs; take the first one
@ -363,10 +366,10 @@ function Get-PytorchCudaTag {
if (-not $smiExe) { return "cu126" }
try {
# 2>&1 | Out-String merges stderr into stdout then converts to a single
# string. Plain 2>$null doesn't fully suppress stderr in PS 5.1 --
# ErrorRecord objects leak into $output and break the -match.
$output = & $smiExe 2>&1 | Out-String
# Bounded: a wedged nvidia-smi must not hang setup. The helper merges
# stderr into the returned string, matching the old 2>&1 | Out-String
# shape (plain 2>$null leaks ErrorRecord objects in PS 5.1).
$output = Invoke-NvidiaSmiBounded $smiExe
# Newer NVIDIA drivers (e.g. 610.x on Windows) print
# "CUDA UMD Version: X.Y" instead of the legacy "CUDA Version: X.Y".
# Accept both spellings so we don't fall through to the cu126 default.
@ -667,16 +670,58 @@ try {
# ============================================
# 1a. GPU detection
# ============================================
# ── Helper: run nvidia-smi under a timeout ──
# A wedged NVIDIA driver can make nvidia-smi block during init or after a reset;
# WaitForExit bounds it (mirrors Invoke-AmdSmiNoElevate below) so detection
# cannot hang setup. No RunAsInvoker compat layer: nvidia-smi does not
# auto-elevate. Returns combined stdout+stderr; "" on timeout/failure.
function Invoke-NvidiaSmiBounded {
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Exe,
[Parameter(Position = 1)][string[]]$SmiArgs = @(),
[int]$TimeoutSec = 10
)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = ($SmiArgs -join ' ')
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
$global:LASTEXITCODE = 124
return ""
}
$global:LASTEXITCODE = $proc.ExitCode
return ($outTask.Result + "`n" + $errTask.Result)
} catch {
$global:LASTEXITCODE = 1
return ""
}
}
# ── Helper: nvidia-smi -L lists at least one real GPU ──
# Exit code 0 alone is not enough: a stale/driverless nvidia-smi can exit 0
# while listing no GPU, which would mark an AMD host NVIDIA and suppress ROCm
# detection. Require a "GPU <n>:" data row.
function Test-NvidiaSmiHasGpu {
param([Parameter(Mandatory = $true)][string]$Exe)
$out = Invoke-NvidiaSmiBounded $Exe @('-L')
return ($LASTEXITCODE -eq 0 -and $out -match '(?m)^GPU\s+\d+:')
}
$HasNvidiaSmi = $false
$NvidiaSmiExe = $null # Absolute path -- survives Refresh-Environment
try {
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
if ($nvSmiCmd) {
& $nvSmiCmd.Source *> $null
if ($LASTEXITCODE -eq 0) {
$HasNvidiaSmi = $true
$NvidiaSmiExe = $nvSmiCmd.Source
}
if ($nvSmiCmd -and (Test-NvidiaSmiHasGpu $nvSmiCmd.Source)) {
$HasNvidiaSmi = $true
$NvidiaSmiExe = $nvSmiCmd.Source
}
} catch {}
# Fallback: nvidia-smi may not be on PATH even though a GPU + driver exist.
@ -689,8 +734,7 @@ if (-not $HasNvidiaSmi) {
foreach ($p in $nvSmiDefaults) {
if (Test-Path $p) {
try {
& $p *> $null
if ($LASTEXITCODE -eq 0) {
if (Test-NvidiaSmiHasGpu $p) {
$HasNvidiaSmi = $true
$NvidiaSmiExe = $p
Write-Host " Found nvidia-smi at $(Split-Path $p -Parent)" -ForegroundColor Gray
@ -1151,7 +1195,16 @@ function Resolve-CudaToolkit {
$DriverMaxCuda = $null
try {
$smiOut = & $NvidiaSmiExe 2>&1 | Out-String
# Bounded: source-build toolkit resolution must not hang on a wedged smi.
# test_resolve_cuda_toolkit.ps1 extracts this function alone into a child
# pwsh (no Invoke-NvidiaSmiBounded in scope) and stubs nvidia-smi with a
# .ps1 script, so fall back to direct invocation when the bounded runner
# is unavailable; production setup.ps1 always has it defined.
$smiOut = if (Get-Command Invoke-NvidiaSmiBounded -ErrorAction SilentlyContinue) {
Invoke-NvidiaSmiBounded $NvidiaSmiExe
} else {
& $NvidiaSmiExe 2>&1 | Out-String
}
# Newer drivers report "CUDA UMD Version: X.Y" instead of "CUDA Version: X.Y"; accept both.
if ($smiOut -match "CUDA(?: UMD)? Version:\s+([\d]+)\.([\d]+)") {
$DriverMaxCuda = "$($Matches[1]).$($Matches[2])"

View file

@ -155,9 +155,60 @@ _nvcc_meets_llama_minimum() {
echo "$_raw"
}
# Run a GPU probe under a 10s timeout when `timeout` is available so a wedged
# NVIDIA driver cannot hang setup; fall back to a bare call where it is not.
_setup_run_smi() {
if command -v timeout >/dev/null 2>&1; then
timeout 10 "$@"
else
"$@"
fi
}
# Returns 0 when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every NVIDIA
# device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to the
# AMD card). Unset means all devices visible. nvidia-smi ignores this env var,
# so the probes below cannot see the distinction on their own.
_setup_cvd_hides_nvidia() {
[ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
_setup_cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
[ -z "$_setup_cvd_trim" ] || [ "$_setup_cvd_trim" = "-1" ]
}
# Returns 0 when an NVIDIA GPU is present and usable. Primary probe is
# `nvidia-smi -L` (timeout-bounded). Fallback is /proc/driver/nvidia/gpus,
# which the driver populates per GPU regardless of nvidia-smi state -- handles
# PATH gaps and driver init races. Mirrors install.sh _has_usable_nvidia_gpu
# (PR 6174) so setup routes the same way as the torch installer. A GPU hidden
# via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
# install_llama_prebuilt.py has_usable_nvidia), so the AMD probes still run
# and a mixed host steered to its AMD card keeps the ROCm route.
_setup_has_usable_nvidia_gpu() {
if _setup_cvd_hides_nvidia; then
return 1
fi
_setup_nvsmi=""
if command -v nvidia-smi >/dev/null 2>&1; then
_setup_nvsmi="nvidia-smi"
elif [ -x "/usr/bin/nvidia-smi" ]; then
_setup_nvsmi="/usr/bin/nvidia-smi"
fi
if [ -n "$_setup_nvsmi" ]; then
if _setup_run_smi "$_setup_nvsmi" -L 2>/dev/null \
| awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
return 0
fi
fi
if [ -d /proc/driver/nvidia/gpus ] && \
[ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
return 0
fi
return 1
}
_cuda_driver_max_version() {
command -v nvidia-smi >/dev/null 2>&1 || return 0
nvidia-smi 2>/dev/null \
_setup_run_smi nvidia-smi 2>/dev/null \
| sed -nE 's/.*CUDA( UMD)? Version:[[:space:]]*([0-9]+)\.([0-9]+).*/\2.\3/p' \
| head -1 || true
}
@ -815,25 +866,42 @@ _setup_amd_detected=false
_setup_nvidia_usable=false
_setup_gfx_all=""
_setup_mkt=""
if command -v rocminfo >/dev/null 2>&1 && \
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9][0-9]/{found=1} END{exit !found}'; then
_setup_amd_detected=true
_setup_gfx_all=$(rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
_setup_mkt=$(rocminfo 2>/dev/null | awk -F': ' \
'/Marketing Name:/{gsub(/^[[:space:]]+|[[:space:]]+$/,"", $2); if($2){print $2; exit}}' || true)
elif command -v amd-smi >/dev/null 2>&1 && \
amd-smi list 2>/dev/null | awk '/^GPU[[:space:]]*[:\[][[:space:]]*[0-9]/{ found=1 } END{ exit !found }'; then
_setup_amd_detected=true
_setup_gfx_all=$(amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
[ -z "$_setup_gfx_all" ] && \
_setup_gfx_all=$(amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
_setup_mkt=$(amd-smi static --asic 2>/dev/null | awk -F'[:|]' \
'/[Mm]arket.?[Nn]ame/{gsub(/^[[:space:]]+|[[:space:]]+$/,"", $2); if($2){print $2; exit}}' || true)
# NVIDIA priority: classify NVIDIA first and skip the AMD probes entirely on
# a usable-NVIDIA host (mirrors _has_rocm_gpu in install_python_stack.py).
# This also keeps a wedged rocminfo/amd-smi from hanging setup before the
# host is classified; the AMD probes themselves run under _setup_run_smi.
if _setup_has_usable_nvidia_gpu; then
_setup_nvidia_usable=true
fi
if [ "$_setup_nvidia_usable" != true ]; then
if command -v rocminfo >/dev/null 2>&1 && \
_setup_run_smi rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9][0-9]/{found=1} END{exit !found}'; then
_setup_amd_detected=true
_setup_gfx_all=$(_setup_run_smi rocminfo 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
_setup_mkt=$(_setup_run_smi rocminfo 2>/dev/null | awk -F': ' \
'/Marketing Name:/{gsub(/^[[:space:]]+|[[:space:]]+$/,"", $2); if($2){print $2; exit}}' || true)
elif command -v amd-smi >/dev/null 2>&1 && \
_setup_run_smi amd-smi list 2>/dev/null | awk '/^GPU[[:space:]]*[:\[][[:space:]]*[0-9]/{ found=1 } END{ exit !found }'; then
_setup_amd_detected=true
_setup_gfx_all=$(_setup_run_smi amd-smi list 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
[ -z "$_setup_gfx_all" ] && \
_setup_gfx_all=$(_setup_run_smi amd-smi static --asic 2>/dev/null | grep -oE 'gfx[1-9][0-9a-z]{2,3}' || true)
_setup_mkt=$(_setup_run_smi amd-smi static --asic 2>/dev/null | awk -F'[:|]' \
'/[Mm]arket.?[Nn]ame/{gsub(/^[[:space:]]+|[[:space:]]+$/,"", $2); if($2){print $2; exit}}' || true)
elif [ -e /dev/kfd ] && \
awk 'FNR==1{ gpu=0; amd=0 } /gpu_id/{ gpu=($2+0>0) } /vendor_id/{ amd=($2==4098) } \
gpu && amd { found=1 } END{ exit !found }' \
/sys/class/kfd/kfd/topology/nodes/*/properties 2>/dev/null; then
# KFD sysfs fallback, AMD vendor_id 4098 only (mirrors install.sh
# _has_amd_rocm_gpu): covers AMD hosts where rocminfo/amd-smi are
# missing but the kernel exposes the GPU, so the source-build gate
# below does not drop them to a CPU llama.cpp build. No gfx arch is
# available from this path; name-based inference handles it.
_setup_amd_detected=true
fi
fi
if command -v nvidia-smi >/dev/null 2>&1 && \
nvidia-smi -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
_setup_nvidia_usable=true
if [ "$_setup_nvidia_usable" = true ]; then
step "gpu" "NVIDIA GPU detected"
elif [ "$_setup_amd_detected" = true ]; then
_setup_vis="${HIP_VISIBLE_DEVICES:-${ROCR_VISIBLE_DEVICES:-}}"
@ -918,15 +986,15 @@ _HOST_MACHINE="$(uname -m 2>/dev/null || true)"
# use unslothai.
_LINUX_HAS_GPU=false
# Route to the fork only for a usable GPU. NVIDIA counts only when a device is
# actually enumerated (_setup_nvidia_usable, from the nvidia-smi -L probe above)
# AND not hidden via CUDA_VISIBLE_DEVICES=-1 -- mirroring install_llama_prebuilt.py's
# has_usable_nvidia. Mere nvidia-smi presence (CPU-only CUDA-toolkit containers,
# broken drivers) or a hidden GPU therefore takes the ggml-org CPU prebuilt
# instead of a slow source build. AMD is deliberately left on tooling presence,
# not usability: an unusable NVIDIA host has a good CPU prebuilt to fall back to,
# whereas tightening AMD would regress ROCm hosts exposing only hipconfig/hipinfo
# into an unnecessary CPU build.
if [ "$_setup_nvidia_usable" = true ] && [ "${CUDA_VISIBLE_DEVICES:-}" != "-1" ]; then
# actually enumerated and not hidden via CUDA_VISIBLE_DEVICES=""/-1
# (_setup_nvidia_usable, from _setup_has_usable_nvidia_gpu above) -- mirroring
# install_llama_prebuilt.py's has_usable_nvidia. Mere nvidia-smi presence
# (CPU-only CUDA-toolkit containers, broken drivers) or a hidden GPU therefore
# takes the ggml-org CPU prebuilt instead of a slow source build. AMD is
# deliberately left on tooling presence, not usability: an unusable NVIDIA host
# has a good CPU prebuilt to fall back to, whereas tightening AMD would regress
# ROCm hosts exposing only hipconfig/hipinfo into an unnecessary CPU build.
if [ "$_setup_nvidia_usable" = true ]; then
_LINUX_HAS_GPU=true
else
for _GPU_TOOL in rocminfo amd-smi hipconfig hipinfo; do
@ -1271,23 +1339,35 @@ else
GPU_BACKEND=""
NVCC_PATH=""
if command -v nvcc &>/dev/null; then
NVCC_PATH="$(command -v nvcc)"
GPU_BACKEND="cuda"
elif [ -x /usr/local/cuda/bin/nvcc ]; then
NVCC_PATH="/usr/local/cuda/bin/nvcc"
export PATH="/usr/local/cuda/bin:$PATH"
GPU_BACKEND="cuda"
elif ls /usr/local/cuda-*/bin/nvcc &>/dev/null 2>&1; then
# Pick the newest cuda-XX.X directory
NVCC_PATH="$(ls -d /usr/local/cuda-*/bin/nvcc 2>/dev/null | sort -V | tail -1)"
export PATH="$(dirname "$NVCC_PATH"):$PATH"
GPU_BACKEND="cuda"
# Gate the CUDA toolkit search on an actually-usable NVIDIA GPU
# (_setup_nvidia_usable, computed in the GPU summary block above;
# already false when hidden via CUDA_VISIBLE_DEVICES=""/-1).
# A CUDA toolkit alone (CPU-only build container, leftover packages)
# is not proof of a GPU: building with -DGGML_CUDA=ON there yields a
# binary that fails at runtime, so fall through to the CPU build.
if [ "$_setup_nvidia_usable" = true ]; then
if command -v nvcc &>/dev/null; then
NVCC_PATH="$(command -v nvcc)"
GPU_BACKEND="cuda"
elif [ -x /usr/local/cuda/bin/nvcc ]; then
NVCC_PATH="/usr/local/cuda/bin/nvcc"
export PATH="/usr/local/cuda/bin:$PATH"
GPU_BACKEND="cuda"
elif ls /usr/local/cuda-*/bin/nvcc &>/dev/null 2>&1; then
# Pick the newest cuda-XX.X directory
NVCC_PATH="$(ls -d /usr/local/cuda-*/bin/nvcc 2>/dev/null | sort -V | tail -1)"
export PATH="$(dirname "$NVCC_PATH"):$PATH"
GPU_BACKEND="cuda"
fi
fi
# Check for ROCm (AMD) only if CUDA was not already selected
# Check for ROCm (AMD) only if CUDA was not already selected, and
# only when an AMD GPU was actually detected (_setup_amd_detected).
# hipcc presence alone (HIP SDK, no GPU) must not select a HIP build.
# NVIDIA-usable hosts never build HIP (defense in depth: the AMD
# probes above are already skipped when NVIDIA is usable).
ROCM_HIPCC=""
if [ -z "$GPU_BACKEND" ]; then
if [ -z "$GPU_BACKEND" ] && [ "$_setup_nvidia_usable" != true ] && [ "$_setup_amd_detected" = true ]; then
if command -v hipcc &>/dev/null; then
ROCM_HIPCC="$(command -v hipcc)"
GPU_BACKEND="rocm"
@ -1349,7 +1429,7 @@ else
CUDA_ARCHS=""
if command -v nvidia-smi &>/dev/null; then
_raw_caps=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
_raw_caps=$(_setup_run_smi nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
while IFS= read -r _cap; do
_cap=$(echo "$_cap" | tr -d '[:space:]')
if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
@ -1455,7 +1535,7 @@ else
CMAKE_ARGS="$CMAKE_ARGS -DGPU_TARGETS=${GPU_TARGETS}"
_BUILD_DESC="building (ROCm, ${GPU_TARGETS//;/+})"
fi
elif [ -d /usr/local/cuda ] || nvidia-smi &>/dev/null; then
elif [ -d /usr/local/cuda ] || _setup_run_smi nvidia-smi &>/dev/null; then
_BUILD_DESC="building (CPU, CUDA driver found but nvcc missing)"
elif [ -d /opt/rocm ] || command -v rocm-smi &>/dev/null; then
_BUILD_DESC="building (CPU, ROCm driver found but hipcc missing)"

View file

@ -13,6 +13,10 @@ FAIL=0
_FUNC_FILE=$(mktemp)
_FAKE_SMI_DIR=$(mktemp -d)
{
sed -n '/^_run_bounded()/,/^}/p' "$INSTALL_SH"
echo ""
sed -n '/^_cvd_hides_nvidia()/,/^}/p' "$INSTALL_SH"
echo ""
sed -n '/^_has_amd_rocm_gpu()/,/^}/p' "$INSTALL_SH"
echo ""
sed -n '/^_has_usable_nvidia_gpu()/,/^}/p' "$INSTALL_SH"
@ -107,7 +111,7 @@ MOCK
# Build a minimal tools directory with symlinks to essential commands
# (uname, grep, head, etc.) but WITHOUT nvidia-smi or amd-smi.
_TOOLS_DIR=$(mktemp -d)
for _cmd in uname grep sed head sh bash cat awk printf; do
for _cmd in uname grep sed head sh bash cat awk printf tr; do
_real=$(command -v "$_cmd" 2>/dev/null || true)
[ -n "$_real" ] && ln -sf "$_real" "$_TOOLS_DIR/$_cmd"
done
@ -116,12 +120,19 @@ done
# $1 = directory with mock nvidia-smi (prepended to PATH), or "none" for no-GPU test
run_func() {
_mock_dir="$1"
# Default: strip CUDA_VISIBLE_DEVICES so the host environment cannot leak
# in; a second argument sets it explicitly (hidden-GPU scenarios).
if [ "$#" -ge 2 ]; then
_cvd_setup="export CUDA_VISIBLE_DEVICES='$2'"
else
_cvd_setup="unset CUDA_VISIBLE_DEVICES"
fi
if [ "$_mock_dir" = "none" ]; then
# Minimal PATH with only basic tools, no nvidia-smi anywhere
PATH="$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null
PATH="$_TOOLS_DIR" bash -c "$_cvd_setup; . '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null
else
# Put mock nvidia-smi dir first, then basic tools
PATH="$_mock_dir:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null
PATH="$_mock_dir:$_TOOLS_DIR" bash -c "$_cvd_setup; . '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null
fi
}
@ -332,6 +343,40 @@ _result=$(run_func "$_dir")
assert_eq "CUDA Version 13.7 -> cu130" "https://download.pytorch.org/whl/cu130" "$_result"
rm -rf "$_dir"
# 34) CUDA_VISIBLE_DEVICES="" hides the NVIDIA GPU -> cpu (no AMD present)
_dir=$(make_mock_smi "12.8")
_result=$(run_func "$_dir" "")
assert_eq "CVD='' hides NVIDIA -> cpu" "https://download.pytorch.org/whl/cpu" "$_result"
rm -rf "$_dir"
# 35) CUDA_VISIBLE_DEVICES=-1 hides the NVIDIA GPU -> cpu (no AMD present)
_dir=$(make_mock_smi "12.8")
_result=$(run_func "$_dir" "-1")
assert_eq "CVD=-1 hides NVIDIA -> cpu" "https://download.pytorch.org/whl/cpu" "$_result"
rm -rf "$_dir"
# 36) Mixed AMD+NVIDIA host with NVIDIA hidden -> ROCm route is restored
_cuda_dir=$(make_mock_smi "12.6")
_amd_dir=$(make_mock_amd_smi "6.4")
_combined_dir=$(mktemp -d)
ln -sf "$_cuda_dir/nvidia-smi" "$_combined_dir/nvidia-smi"
ln -sf "$_amd_dir/amd-smi" "$_combined_dir/amd-smi"
_result=$(run_func "$_combined_dir" "-1")
assert_eq "CUDA+ROCm with CVD=-1 -> rocm6.4" "https://download.pytorch.org/whl/rocm6.4" "$_result"
rm -rf "$_cuda_dir" "$_amd_dir" "$_combined_dir"
# 37) CUDA_VISIBLE_DEVICES=0 (a visible device) must NOT hide the GPU
_dir=$(make_mock_smi "12.8")
_result=$(run_func "$_dir" "0")
assert_eq "CVD=0 keeps NVIDIA -> cu128" "https://download.pytorch.org/whl/cu128" "$_result"
rm -rf "$_dir"
# 38) Whitespace-padded "-1" still hides the GPU
_dir=$(make_mock_smi "12.8")
_result=$(run_func "$_dir" " -1 ")
assert_eq "CVD=' -1 ' hides NVIDIA -> cpu" "https://download.pytorch.org/whl/cpu" "$_result"
rm -rf "$_dir"
rm -f "$_FUNC_FILE"
rm -rf "$_FAKE_SMI_DIR"
rm -rf "$_TOOLS_DIR"

View file

@ -0,0 +1,248 @@
"""Tests for CUDA torch repair on poisoned NVIDIA venvs.
Verifies _ensure_cuda_torch (studio/install_python_stack.py) reinstalls CUDA
torch when a venv on an NVIDIA host carries a ROCm torch build (the pre-fix KFD
gpu_id false positive), without touching healthy CUDA, deliberate CPU wheels,
ROCm hosts, macOS, or Windows. All tests use mocks -- no GPU required.
"""
import importlib.util
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ── Load module under test (mirrors test_rocm_support.py) ────────────────────
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
_STACK_PATH = PACKAGE_ROOT / "studio" / "install_python_stack.py"
_STACK_SPEC = importlib.util.spec_from_file_location("studio_install_python_stack", _STACK_PATH)
assert _STACK_SPEC is not None and _STACK_SPEC.loader is not None
stack_mod = importlib.util.module_from_spec(_STACK_SPEC)
sys.modules[_STACK_SPEC.name] = stack_mod
_STACK_SPEC.loader.exec_module(stack_mod)
_ensure_cuda_torch = stack_mod._ensure_cuda_torch
_detect_cuda_torch_index_url = stack_mod._detect_cuda_torch_index_url
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_run(
torch_state = "hip",
cuda_version = "12.8",
torch_rc = 0,
smi_rc = 0,
):
"""Build a subprocess.run side_effect.
The torch-classify probe runs sys.executable and reads bytes stdout; the
nvidia-smi version probe runs the smi path with text=True. Distinguish by
the executable.
"""
def _run(cmd, *args, **kwargs):
result = MagicMock()
exe = str(cmd[0]) if cmd else ""
if exe == sys.executable:
result.returncode = torch_rc
result.stdout = (torch_state + "\n").encode()
return result
# nvidia-smi version probe (text = True)
result.returncode = smi_rc
out = f"CUDA Version: {cuda_version}\n" if cuda_version else "No devices found\n"
result.stdout = out if kwargs.get("text") else out.encode()
return result
return _run
def _run_cuda_repair(
*,
backend = "",
nvidia = True,
torch_state = "hip",
cuda_version = "12.8",
torch_rc = 0,
smi_rc = 0,
is_macos = False,
is_windows = False,
no_torch = False,
rocm_marker = False,
smi_path = "/usr/bin/nvidia-smi",
cvd = None,
):
"""Invoke _ensure_cuda_torch under a fully mocked host; return the pip mock.
cvd controls CUDA_VISIBLE_DEVICES: None removes it from the environment
(the host machine may export one), any string sets it explicitly.
"""
env = {}
if rocm_marker:
env["UNSLOTH_ROCM_TORCH_INSTALLED"] = "1"
if cvd is not None:
env["CUDA_VISIBLE_DEVICES"] = cvd
def _which(name, *a, **k):
if name == "nvidia-smi":
return smi_path
return None
with (
patch.object(stack_mod, "_TORCH_BACKEND", backend),
patch.object(stack_mod, "IS_MACOS", is_macos),
patch.object(stack_mod, "IS_WINDOWS", is_windows),
patch.object(stack_mod, "NO_TORCH", no_torch),
patch.object(stack_mod, "_has_usable_nvidia_gpu", return_value = nvidia),
patch.object(stack_mod.shutil, "which", side_effect = _which),
patch.object(stack_mod.os.path, "isfile", return_value = bool(smi_path)),
patch.object(stack_mod, "pip_install") as mock_pip,
patch.object(
stack_mod.subprocess,
"run",
side_effect = _make_run(torch_state, cuda_version, torch_rc, smi_rc),
),
patch.dict(stack_mod.os.environ, env, clear = False),
):
if not rocm_marker:
stack_mod.os.environ.pop("UNSLOTH_ROCM_TORCH_INSTALLED", None)
if cvd is None:
stack_mod.os.environ.pop("CUDA_VISIBLE_DEVICES", None)
_ensure_cuda_torch()
return mock_pip
def _index_url(mock_pip) -> str:
"""Return the --index-url value from the recorded pip_install call."""
args = [str(a) for a in mock_pip.call_args.args]
return args[args.index("--index-url") + 1]
# ── Repair fires only on the poisoning signature ─────────────────────────────
class TestCudaRepairFires:
def test_hip_build_on_nvidia_triggers_repair(self):
mock_pip = _run_cuda_repair(torch_state = "hip", cuda_version = "12.8")
assert mock_pip.call_count == 1
call_args = [str(a) for a in mock_pip.call_args.args]
assert "--force-reinstall" in call_args
assert "--no-cache-dir" in call_args
assert "cu128" in _index_url(mock_pip)
assert mock_pip.call_args.kwargs["constrain"] is False
def test_rocm_in_version_string_triggers_repair(self):
# AMD SDK / Radeon wheels may not set torch.version.hip but encode
# rocm in __version__; the probe prints "hip" for both.
mock_pip = _run_cuda_repair(torch_state = "hip")
assert mock_pip.call_count == 1
# ── No-op cases ──────────────────────────────────────────────────────────────
class TestCudaRepairSkips:
def test_healthy_cuda_torch_no_repair(self):
mock_pip = _run_cuda_repair(torch_state = "cuda")
mock_pip.assert_not_called()
def test_deliberate_cpu_wheel_no_repair(self):
mock_pip = _run_cuda_repair(torch_state = "cpu")
mock_pip.assert_not_called()
def test_backend_rocm_skips(self):
mock_pip = _run_cuda_repair(backend = "rocm", torch_state = "hip")
mock_pip.assert_not_called()
def test_backend_cpu_skips(self):
mock_pip = _run_cuda_repair(backend = "cpu", torch_state = "hip")
mock_pip.assert_not_called()
def test_unknown_backend_skips(self):
mock_pip = _run_cuda_repair(backend = "auto", torch_state = "hip")
mock_pip.assert_not_called()
def test_no_nvidia_gpu_skips(self):
mock_pip = _run_cuda_repair(nvidia = False, torch_state = "hip")
mock_pip.assert_not_called()
def test_torch_missing_skips(self):
# Non-zero probe exit = torch missing / un-importable.
mock_pip = _run_cuda_repair(torch_state = "hip", torch_rc = 1)
mock_pip.assert_not_called()
def test_macos_skips(self):
mock_pip = _run_cuda_repair(is_macos = True, torch_state = "hip")
mock_pip.assert_not_called()
def test_windows_skips(self):
mock_pip = _run_cuda_repair(is_windows = True, torch_state = "hip")
mock_pip.assert_not_called()
def test_no_torch_mode_skips(self):
mock_pip = _run_cuda_repair(no_torch = True, torch_state = "hip")
mock_pip.assert_not_called()
def test_rocm_install_marker_skips(self):
mock_pip = _run_cuda_repair(rocm_marker = True, torch_state = "hip")
mock_pip.assert_not_called()
def test_cvd_minus_one_skips(self):
# CUDA_VISIBLE_DEVICES=-1 deliberately hides the NVIDIA GPU (mixed
# AMD+NVIDIA host running ROCm torch on the AMD card).
mock_pip = _run_cuda_repair(cvd = "-1", torch_state = "hip")
mock_pip.assert_not_called()
def test_cvd_empty_skips(self):
mock_pip = _run_cuda_repair(cvd = "", torch_state = "hip")
mock_pip.assert_not_called()
def test_cvd_explicit_device_still_repairs(self):
mock_pip = _run_cuda_repair(cvd = "0", torch_state = "hip")
assert mock_pip.call_count == 1
# ── CUDA index ladder ────────────────────────────────────────────────────────
class TestCudaIndexResolution:
def test_cuda_128_selects_cu128(self):
assert "cu128" in _index_url(_run_cuda_repair(cuda_version = "12.8"))
def test_cuda_130_selects_cu130(self):
assert "cu130" in _index_url(_run_cuda_repair(cuda_version = "13.0"))
def test_cuda_126_selects_cu126(self):
assert "cu126" in _index_url(_run_cuda_repair(cuda_version = "12.6"))
def test_cuda_124_selects_cu124(self):
assert "cu124" in _index_url(_run_cuda_repair(cuda_version = "12.4"))
def test_cuda_118_selects_cu118(self):
assert "cu118" in _index_url(_run_cuda_repair(cuda_version = "11.8"))
def test_unreadable_version_defaults_cu126(self):
# nvidia-smi runs but prints no CUDA version line (or fails).
mock_pip = _run_cuda_repair(cuda_version = "", smi_rc = 1)
assert "cu126" in _index_url(mock_pip)
def test_proc_fallback_no_smi_defaults_cu126(self):
# NVIDIA usable via /proc fallback, nvidia-smi absent entirely.
mock_pip = _run_cuda_repair(smi_path = None)
assert "cu126" in _index_url(mock_pip)
def test_detect_index_url_uses_pytorch_base(self):
with (
patch.object(stack_mod.shutil, "which", return_value = None),
patch.object(stack_mod.os.path, "isfile", return_value = False),
):
url = _detect_cuda_torch_index_url()
assert url == f"{stack_mod._PYTORCH_WHL_BASE}/cu126"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))

View file

@ -0,0 +1,480 @@
"""Tests for the GPU-detection follow-ups to PR 6174.
PR 6174 made NVIDIA take precedence and added a /proc/driver/nvidia/gpus
fallback in install.sh and studio/install_python_stack.py. These tests cover the
same hardening ported to the llama.cpp prebuilt installer
(studio/install_llama_prebuilt.py) and the Studio shell setup (studio/setup.sh):
* detect_host() recognises NVIDIA via /proc/driver/nvidia/gpus when nvidia-smi
is unavailable, and skips ROCm probing when NVIDIA is usable.
* setup.sh routes through a timeout-bounded NVIDIA probe with a /proc fallback
and only selects a CUDA/ROCm source build when the matching GPU is detected.
All tests use mocks or source-level assertions -- no GPU, network, or real
nvidia-smi/rocminfo invocation.
"""
import importlib.util
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
# Load studio/install_llama_prebuilt.py the same way the sibling suite does.
_MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py"
_SPEC = importlib.util.spec_from_file_location(
"studio_install_llama_prebuilt_followups", _MODULE_PATH
)
assert _SPEC is not None and _SPEC.loader is not None
prebuilt_mod = importlib.util.module_from_spec(_SPEC)
sys.modules[_SPEC.name] = prebuilt_mod
_SPEC.loader.exec_module(prebuilt_mod)
detect_host = prebuilt_mod.detect_host
_apply_host_overrides = prebuilt_mod._apply_host_overrides
SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh"
def _make_run_capture(rocminfo_stdout: str = ""):
"""Return a fake run_capture: rocminfo reports rocminfo_stdout, everything
else (nvidia-smi, amd-smi) returns empty so only the patched probes matter."""
def _run_capture(cmd, *args, **kwargs):
exe = str(cmd[0]) if cmd else ""
result = MagicMock()
if exe.endswith("rocminfo"):
result.returncode = 0
result.stdout = rocminfo_stdout
else:
result.returncode = 1
result.stdout = ""
result.stderr = ""
return result
return _run_capture
def _run_detect_host(
*,
machine: str = "x86_64",
system: str = "Linux",
which_map: dict | None = None,
proc_dir_entries: list | None = None,
rocminfo_stdout: str = "",
env: dict | None = None,
):
"""Drive detect_host() against a fully synthetic host."""
which_map = which_map or {}
proc_dir_entries = proc_dir_entries if proc_dir_entries is not None else []
real_isdir = prebuilt_mod.os.path.isdir
real_listdir = prebuilt_mod.os.listdir
proc_path = "/proc/driver/nvidia/gpus"
def fake_isdir(p):
if str(p) == proc_path:
return bool(proc_dir_entries)
return real_isdir(p)
def fake_listdir(p):
if str(p) == proc_path:
if not proc_dir_entries:
raise OSError("no such dir")
return list(proc_dir_entries)
return real_listdir(p)
patches = [
patch.object(prebuilt_mod.platform, "system", return_value = system),
patch.object(prebuilt_mod.platform, "machine", return_value = machine),
patch.object(prebuilt_mod.platform, "mac_ver", return_value = ("", ("", "", ""), "")),
patch.object(prebuilt_mod.shutil, "which", side_effect = lambda n: which_map.get(n)),
patch.object(prebuilt_mod, "run_capture", side_effect = _make_run_capture(rocminfo_stdout)),
patch.object(prebuilt_mod.os.path, "isdir", side_effect = fake_isdir),
patch.object(prebuilt_mod.os, "listdir", side_effect = fake_listdir),
patch.object(prebuilt_mod.os, "access", return_value = False),
patch.dict(prebuilt_mod.os.environ, env or {}, clear = False),
]
for p in patches:
p.start()
try:
# Ensure CUDA_VISIBLE_DEVICES does not leak in from the test host unless
# the scenario sets it explicitly.
if env is None or "CUDA_VISIBLE_DEVICES" not in env:
prebuilt_mod.os.environ.pop("CUDA_VISIBLE_DEVICES", None)
return detect_host()
finally:
for p in patches:
p.stop()
# ── install_llama_prebuilt.detect_host(): /proc NVIDIA fallback ──────────────
class TestDetectHostProcFallback:
def test_proc_fallback_marks_physical_nvidia_when_smi_absent(self):
"""No nvidia-smi, but /proc/driver/nvidia/gpus is populated -> NVIDIA."""
host = _run_detect_host(
which_map = {}, # nvidia-smi resolves to None
proc_dir_entries = ["0000:01:00.0"],
)
assert host.has_physical_nvidia is True
def test_proc_fallback_has_usable_nvidia_when_devices_visible(self):
"""Default CUDA_VISIBLE_DEVICES (unset) -> visible tokens non-empty -> usable."""
host = _run_detect_host(
which_map = {},
proc_dir_entries = ["0000:01:00.0"],
)
assert host.has_usable_nvidia is True
def test_proc_fallback_not_usable_when_devices_hidden(self):
"""CUDA_VISIBLE_DEVICES='' hides all GPUs -> physical yes, usable no."""
host = _run_detect_host(
which_map = {},
proc_dir_entries = ["0000:01:00.0"],
env = {"CUDA_VISIBLE_DEVICES": ""},
)
assert host.has_physical_nvidia is True
assert host.has_usable_nvidia is False
def test_empty_proc_dir_does_not_mark_nvidia(self):
"""A driver dir that exists but is empty must not assert a GPU."""
host = _run_detect_host(which_map = {}, proc_dir_entries = [])
assert host.has_physical_nvidia is False
def test_proc_fallback_is_linux_only(self):
"""The /proc fallback must not run on Windows (path is Linux-only)."""
host = _run_detect_host(
system = "Windows",
machine = "amd64",
which_map = {},
proc_dir_entries = ["0000:01:00.0"],
)
assert host.has_physical_nvidia is False
# ── install_llama_prebuilt.detect_host(): NVIDIA precedence over ROCm ────────
class TestDetectHostNvidiaPrecedence:
def test_rocm_probe_skipped_when_proc_nvidia_present(self):
"""rocminfo reports gfx1100, but a proc-detected NVIDIA GPU wins."""
host = _run_detect_host(
which_map = {"rocminfo": "/usr/bin/rocminfo"},
proc_dir_entries = ["0000:01:00.0"],
rocminfo_stdout = " Name: gfx1100\n",
)
assert host.has_usable_nvidia is True
assert host.has_rocm is False
def test_rocm_detected_when_no_nvidia(self):
"""With no NVIDIA signal at all, rocminfo gfx1100 -> has_rocm True."""
host = _run_detect_host(
which_map = {"rocminfo": "/usr/bin/rocminfo"},
proc_dir_entries = [],
rocminfo_stdout = " Name: gfx1100\n",
)
assert host.has_usable_nvidia is False
assert host.has_rocm is True
# ── _apply_host_overrides: forwarded --rocm-gfx / --has-rocm still win ───────
class TestOverridesStillWin:
def test_forwarded_gfx_forces_rocm_on_non_nvidia_host(self):
host = _run_detect_host(which_map = {}, proc_dir_entries = [])
assert host.has_rocm is False
overridden = _apply_host_overrides(host, override_rocm_gfx = "gfx1100")
assert overridden.has_rocm is True
assert overridden.rocm_gfx_target == "gfx1100"
def test_override_has_rocm_forces_rocm(self):
host = _run_detect_host(which_map = {}, proc_dir_entries = [])
overridden = _apply_host_overrides(host, override_has_rocm = True)
assert overridden.has_rocm is True
def test_force_cpu_drops_nvidia_attributes(self):
host = _run_detect_host(which_map = {}, proc_dir_entries = ["0000:01:00.0"])
assert host.has_usable_nvidia is True
overridden = _apply_host_overrides(host, force_cpu = True)
assert overridden.has_usable_nvidia is False
assert overridden.has_physical_nvidia is False
assert overridden.has_rocm is False
# ── setup.sh source-level guarantees ────────────────────────────────────────
class TestSetupShHardening:
@pytest.fixture(scope = "class")
def setup_src(self) -> str:
return SETUP_SH.read_text(encoding = "utf-8")
def test_has_usable_nvidia_helper_exists(self, setup_src):
assert "_setup_has_usable_nvidia_gpu()" in setup_src
def test_helper_uses_proc_fallback(self, setup_src):
start = setup_src.find("_setup_has_usable_nvidia_gpu()")
end = setup_src.find("\n}", start)
body = setup_src[start:end]
assert (
"/proc/driver/nvidia/gpus" in body
), "_setup_has_usable_nvidia_gpu must fall back to /proc/driver/nvidia/gpus"
def test_gpu_summary_uses_helper(self, setup_src):
assert "if _setup_has_usable_nvidia_gpu; then" in setup_src
def test_timeout_wrapper_exists(self, setup_src):
start = setup_src.find("_setup_run_smi()")
assert start >= 0, "_setup_run_smi timeout wrapper must exist"
end = setup_src.find("\n}", start)
body = setup_src[start:end]
assert "timeout 10" in body
assert "command -v timeout" in body
def test_cuda_source_build_gated_on_usable_nvidia(self, setup_src):
"""The nvcc source-build search must be gated on _setup_nvidia_usable.
The hidden-GPU policy (CUDA_VISIBLE_DEVICES=""/-1) lives inside
_setup_has_usable_nvidia_gpu, so the gate itself only needs the flag.
"""
anchor = setup_src.find('NVCC_PATH=""\n')
assert anchor >= 0
window = setup_src[anchor : anchor + 700]
assert (
'if [ "$_setup_nvidia_usable" = true ]' in window
), "CUDA toolkit search must require a usable NVIDIA GPU, not just nvcc"
def test_nvidia_helper_honours_hidden_cvd(self, setup_src):
"""_setup_has_usable_nvidia_gpu must consult the hidden-CVD helper so
CUDA_VISIBLE_DEVICES=""/-1 suppresses NVIDIA before the AMD probes are
gated (mixed hosts steered to the AMD card keep the ROCm route)."""
assert "_setup_cvd_hides_nvidia()" in setup_src
start = setup_src.find("_setup_has_usable_nvidia_gpu() {")
end = setup_src.find("\n}", start)
body = setup_src[start:end]
assert "_setup_cvd_hides_nvidia" in body
def test_rocm_source_build_gated_on_amd_detected(self, setup_src):
"""The hipcc source-build search must be gated on _setup_amd_detected."""
anchor = setup_src.find('ROCM_HIPCC=""')
assert anchor >= 0
window = setup_src[anchor : anchor + 400]
assert (
'[ "$_setup_amd_detected" = true ]' in window
), "ROCm toolkit search must require a detected AMD GPU, not just hipcc"
def test_compute_cap_probe_timeout_wrapped(self, setup_src):
assert "_setup_run_smi nvidia-smi --query-gpu=compute_cap" in setup_src
def test_driver_version_probe_timeout_wrapped(self, setup_src):
start = setup_src.find("_cuda_driver_max_version()")
end = setup_src.find("\n}", start)
body = setup_src[start:end]
assert "_setup_run_smi nvidia-smi" in body
# TEST: install.sh -- UNSLOTH_TORCH_BACKEND classified on the final path segment
class TestBackendExportLeafClassification:
"""A custom UNSLOTH_PYTORCH_MIRROR whose base path contains "rocm" or
"gfx" must not mislabel a cu*/cpu index as ROCm; classification uses the
final path segment of TORCH_INDEX_URL only."""
@pytest.fixture(scope = "class")
def install_src(self) -> str:
return (PACKAGE_ROOT / "install.sh").read_text(encoding = "utf-8")
def test_export_block_uses_leaf(self, install_src):
anchor = install_src.find("_torch_index_leaf=")
assert anchor >= 0, "backend export must classify on the final path segment"
window = install_src[anchor : anchor + 500]
assert 'export UNSLOTH_TORCH_BACKEND="rocm"' in window
assert 'export UNSLOTH_TORCH_BACKEND="cpu"' in window
assert 'export UNSLOTH_TORCH_BACKEND="cuda"' in window
def test_leaf_classification_behaviour(self, tmp_path):
import subprocess as sp
script = tmp_path / "leaf.sh"
src = (PACKAGE_ROOT / "install.sh").read_text(encoding = "utf-8")
anchor = src.find("_torch_index_leaf=")
block = src[anchor : src.find("esac", anchor) + 4]
# Drive the extracted block with adversarial mirror URLs.
script.write_text(
"#!/bin/sh\n"
'TORCH_INDEX_URL="$1"\n' + block + "\n"
'printf "%s" "$UNSLOTH_TORCH_BACKEND"\n'
)
cases = {
"https://download.pytorch.org/whl/cu128": "cuda",
"https://download.pytorch.org/whl/cpu": "cpu",
"https://download.pytorch.org/whl/rocm6.4": "rocm",
"https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2.1/": "rocm",
"https://repo.amd.com/rocm/whl/gfx1151/": "rocm",
"https://mirror.local/rocm-cache/cu128": "cuda",
"https://mirror.local/gfx-cache/cpu": "cpu",
}
for url, expected in cases.items():
out = sp.run(
["sh", str(script), url], capture_output = True, text = True, timeout = 30
).stdout.strip()
assert out == expected, f"{url} classified as {out!r}, expected {expected!r}"
# TEST: CUDA_VISIBLE_DEVICES=""/-1 hides NVIDIA in every usable-GPU helper
_STACK_PATH = PACKAGE_ROOT / "studio" / "install_python_stack.py"
_STACK_SPEC = importlib.util.spec_from_file_location(
"studio_install_python_stack_followups", _STACK_PATH
)
assert _STACK_SPEC is not None and _STACK_SPEC.loader is not None
stack_mod = importlib.util.module_from_spec(_STACK_SPEC)
sys.modules[_STACK_SPEC.name] = stack_mod
_STACK_SPEC.loader.exec_module(stack_mod)
def _stack_nvidia_usable(cvd):
"""Drive install_python_stack._has_usable_nvidia_gpu with a mocked
nvidia-smi that always reports a GPU; cvd = None removes the env var."""
def fake_run(cmd, *args, **kwargs):
result = MagicMock()
result.returncode = 0
result.stdout = "GPU 0: NVIDIA Fake (UUID: GPU-x)\n"
return result
env = {} if cvd is None else {"CUDA_VISIBLE_DEVICES": cvd}
with (
patch.object(
stack_mod.shutil,
"which",
side_effect = lambda n: "/usr/bin/nvidia-smi" if n == "nvidia-smi" else None,
),
patch.object(stack_mod.subprocess, "run", side_effect = fake_run),
patch.dict(stack_mod.os.environ, env, clear = False),
):
if cvd is None:
stack_mod.os.environ.pop("CUDA_VISIBLE_DEVICES", None)
return stack_mod._has_usable_nvidia_gpu()
class TestHiddenCvdNotUsable:
"""CUDA_VISIBLE_DEVICES set to "" or "-1" deliberately hides every NVIDIA
device (mixed AMD+NVIDIA hosts steering work to the AMD card). All three
_has_usable_nvidia_gpu implementations (install_python_stack.py, install.sh,
setup.sh) must report the GPU as not usable so the AMD/CPU routes run,
matching install_llama_prebuilt.py's has_usable_nvidia."""
def test_python_unset_cvd_is_usable(self):
assert _stack_nvidia_usable(None) is True
def test_python_empty_cvd_not_usable(self):
assert _stack_nvidia_usable("") is False
def test_python_minus_one_not_usable(self):
assert _stack_nvidia_usable("-1") is False
def test_python_padded_minus_one_not_usable(self):
assert _stack_nvidia_usable(" -1 ") is False
def test_python_explicit_device_is_usable(self):
assert _stack_nvidia_usable("0") is True
def test_python_device_list_is_usable(self):
assert _stack_nvidia_usable("0,1") is True
def test_hidden_nvidia_restores_rocm_detection(self):
"""Mixed host, NVIDIA hidden via CVD=-1, rocminfo reports gfx1100:
_has_rocm_gpu must proceed past the NVIDIA guard and return True
(before this fix the guard ignored CVD and blocked ROCm)."""
def fake_run(cmd, *args, **kwargs):
result = MagicMock()
result.returncode = 0
exe = str(cmd[0])
if exe.endswith("rocminfo"):
result.stdout = " Name: gfx1100\n"
else:
result.stdout = "GPU 0: NVIDIA Fake (UUID: GPU-x)\n"
return result
which_map = {
"rocminfo": "/usr/bin/rocminfo",
"nvidia-smi": "/usr/bin/nvidia-smi",
}
with (
patch.object(stack_mod.shutil, "which", side_effect = which_map.get),
patch.object(stack_mod.subprocess, "run", side_effect = fake_run),
patch.dict(stack_mod.os.environ, {"CUDA_VISIBLE_DEVICES": "-1"}, clear = False),
):
assert stack_mod._has_rocm_gpu() is True
@staticmethod
def _run_sh_helper(tmp_path, src: str, fn_names: list, cvd):
"""Extract shell functions, run the usable-GPU one against a fake
nvidia-smi, and return "usable"/"not_usable"."""
import os as _os
import subprocess as sp
blocks = []
for name in fn_names:
start = src.find(f"{name}() {{")
assert start >= 0, f"{name} missing"
end = src.find("\n}", start) + 2
blocks.append(src[start:end])
fake_bin = tmp_path / "bin"
fake_bin.mkdir(exist_ok = True)
smi = fake_bin / "nvidia-smi"
smi.write_text("#!/bin/sh\necho 'GPU 0: NVIDIA Fake (UUID: GPU-x)'\n")
smi.chmod(0o755)
script = tmp_path / "probe.sh"
script.write_text(
"#!/bin/sh\n" + "\n".join(blocks) + "\n"
f"if {fn_names[-1]}; then echo usable; else echo not_usable; fi\n"
)
env = dict(_os.environ)
env["PATH"] = f"{fake_bin}:{env['PATH']}"
if cvd is None:
env.pop("CUDA_VISIBLE_DEVICES", None)
else:
env["CUDA_VISIBLE_DEVICES"] = cvd
return sp.run(
["sh", str(script)], capture_output = True, text = True, timeout = 30, env = env
).stdout.strip()
@pytest.mark.parametrize(
"cvd, expected",
[(None, "usable"), ("", "not_usable"), ("-1", "not_usable"), ("0", "usable")],
)
def test_install_sh_helper_cvd(self, tmp_path, cvd, expected):
src = (PACKAGE_ROOT / "install.sh").read_text(encoding = "utf-8")
out = self._run_sh_helper(
tmp_path,
src,
["_run_bounded", "_cvd_hides_nvidia", "_has_usable_nvidia_gpu"],
cvd,
)
assert out == expected
@pytest.mark.parametrize(
"cvd, expected",
[(None, "usable"), ("", "not_usable"), ("-1", "not_usable"), ("0", "usable")],
)
def test_setup_sh_helper_cvd(self, tmp_path, cvd, expected):
src = SETUP_SH.read_text(encoding = "utf-8")
out = self._run_sh_helper(
tmp_path,
src,
["_setup_run_smi", "_setup_cvd_hides_nvidia", "_setup_has_usable_nvidia_gpu"],
cvd,
)
assert out == expected

View file

@ -0,0 +1,202 @@
"""Tests that NVIDIA probes in the installers are bounded by a timeout.
Covers audit findings 5 and 6: a wedged nvidia-smi must not hang the installer,
and the Windows probe must require a real GPU listing (not just exit code 0).
Source-level assertions verify the guards are present in install.sh / install.ps1
/ setup.ps1; one behavioral shell test confirms the bash helper actually returns
within the timeout when nvidia-smi hangs.
"""
import os
import shutil
import stat
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
INSTALL_SH = PACKAGE_ROOT / "install.sh"
INSTALL_PS1 = PACKAGE_ROOT / "install.ps1"
SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1"
def _extract_sh_function_body(source: str, name: str) -> str:
"""Return a shell function body from `source` by brace matching."""
needle = f"{name}() {{"
start = source.find(needle)
if start < 0:
return ""
depth = 0
i = start + len(needle) - 1
n = len(source)
while i < n:
ch = source[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
i += 1
return source[start:]
# ── install.sh: _run_bounded helper and its use at every nvidia-smi call ──
class TestInstallShBoundedProbe:
def _src(self) -> str:
return INSTALL_SH.read_text(encoding = "utf-8")
def test_run_bounded_helper_defined(self):
body = _extract_sh_function_body(self._src(), "_run_bounded")
assert body, "install.sh must define a _run_bounded helper"
assert (
"command -v timeout" in body
), "_run_bounded must check for the `timeout` binary before using it"
assert "timeout 10" in body, "_run_bounded must apply a 10s timeout"
# Must fall back to running unbounded when `timeout` is unavailable
# (e.g. macOS) so semantics are unchanged there.
assert (
"else" in body and '"$@"' in body
), "_run_bounded must run the command unbounded when `timeout` is absent"
def test_nvidia_smi_dash_l_probe_is_bounded(self):
body = _extract_sh_function_body(self._src(), "_has_usable_nvidia_gpu")
assert body, "install.sh must define _has_usable_nvidia_gpu"
# The -L probe must go through the bounded runner, not call nvidia-smi raw.
assert (
'_run_bounded "$_nvsmi" -L' in body
), "_has_usable_nvidia_gpu must run nvidia-smi -L through _run_bounded"
# The /proc fallback from PR 6174 must still be present.
assert "/proc/driver/nvidia" in body
def test_cuda_version_parse_is_bounded(self):
body = _extract_sh_function_body(self._src(), "get_torch_index_url")
assert body, "install.sh must define get_torch_index_url"
assert (
"_run_bounded" in body
), "get_torch_index_url CUDA-version parse must run nvidia-smi through _run_bounded"
# The locale must be forced without depending on `env` being on PATH.
assert "LC_ALL=C" in body
# _nvidia_detected gating from PR 6174 must remain.
assert "_nvidia_detected" in body
def test_no_unbounded_nvidia_smi_invocation_remains(self):
"""Every nvidia-smi *execution* in install.sh goes through _run_bounded.
`command -v nvidia-smi` and `-x /usr/bin/nvidia-smi` are resolution
checks, not executions, and are allowed. An execution looks like
`"$_nvsmi" ...` / `$_smi ...` / `nvidia-smi -L`.
"""
body_nvidia = _extract_sh_function_body(self._src(), "_has_usable_nvidia_gpu")
body_torch = _extract_sh_function_body(self._src(), "get_torch_index_url")
# In _has_usable_nvidia_gpu the only execution of $_nvsmi must be bounded.
assert '"$_nvsmi" -L' not in body_nvidia.replace(
'_run_bounded "$_nvsmi" -L', ""
), "found an unbounded nvidia-smi -L execution in _has_usable_nvidia_gpu"
# In get_torch_index_url the $_smi execution must be bounded.
assert (
"LC_ALL=C $_smi" not in body_torch
), "found an unbounded LC_ALL=C $_smi execution in get_torch_index_url"
# ── install.ps1 / setup.ps1: bounded, GPU-row-validated Windows probe ──
class TestPowerShellBoundedProbe:
@pytest.mark.parametrize("path", [INSTALL_PS1, SETUP_PS1])
def test_bounded_helper_present(self, path):
src = path.read_text(encoding = "utf-8")
assert (
"function Invoke-NvidiaSmiBounded" in src
), f"{path.name} must define Invoke-NvidiaSmiBounded"
assert (
"WaitForExit($TimeoutSec * 1000)" in src
), f"{path.name} bounded probe must use WaitForExit with a timeout"
# Kill + sentinel on timeout, mirroring Invoke-AmdSmiNoElevate.
assert (
"$proc.Kill()" in src and "124" in src
), f"{path.name} must kill nvidia-smi and signal a timeout exit code"
@pytest.mark.parametrize("path", [INSTALL_PS1, SETUP_PS1])
def test_probe_requires_gpu_row(self, path):
src = path.read_text(encoding = "utf-8")
assert (
"function Test-NvidiaSmiHasGpu" in src
), f"{path.name} must define Test-NvidiaSmiHasGpu"
assert "@('-L')" in src, f"{path.name} must probe nvidia-smi with -L"
assert (
"^GPU\\s+\\d+:" in src
), f"{path.name} must require a 'GPU <n>:' data row, not just exit code 0"
@pytest.mark.parametrize("path", [INSTALL_PS1, SETUP_PS1])
def test_detection_uses_validated_probe(self, path):
src = path.read_text(encoding = "utf-8")
# The exit-code-only pattern must be gone from the detection block.
assert (
"& $nvSmiCmd.Source *> $null" not in src
), f"{path.name} must not use the exit-code-only nvidia-smi probe"
assert (
"Test-NvidiaSmiHasGpu $nvSmiCmd.Source" in src
), f"{path.name} PATH probe must use Test-NvidiaSmiHasGpu"
assert (
"Test-NvidiaSmiHasGpu $p" in src
), f"{path.name} hardcoded-path fallback must use Test-NvidiaSmiHasGpu"
# ── Behavioral: a hanging nvidia-smi must not hang _has_usable_nvidia_gpu ──
def _have_timeout() -> bool:
return shutil.which("timeout") is not None
@pytest.mark.skipif(not _have_timeout(), reason = "`timeout` binary not available")
def test_has_usable_nvidia_gpu_returns_under_timeout():
"""Extract _run_bounded + _has_usable_nvidia_gpu, point them at a fake
nvidia-smi that sleeps 30s, and assert the probe returns well under that.
"""
src = INSTALL_SH.read_text(encoding = "utf-8")
helper = _extract_sh_function_body(src, "_run_bounded")
fn = _extract_sh_function_body(src, "_has_usable_nvidia_gpu")
assert helper and fn
workdir = tempfile.mkdtemp(prefix = "pr6174_timeout_", dir = str(PACKAGE_ROOT.parent))
try:
fake_dir = Path(workdir, "bin")
fake_dir.mkdir()
fake_smi = fake_dir / "nvidia-smi"
fake_smi.write_text("#!/bin/sh\nsleep 30\n")
fake_smi.chmod(fake_smi.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
# Build a minimal PATH that includes the fake nvidia-smi plus the real
# `timeout`/`awk`/`ls` it needs. Use the fake dir first so it wins.
real_bins = {Path(shutil.which(c)).parent for c in ("timeout", "awk", "ls", "sh")}
path_env = os.pathsep.join([str(fake_dir)] + [str(p) for p in real_bins])
# Force the /proc fallback off so the result depends only on the probe,
# and so a host with real NVIDIA does not mask the timeout behaviour.
script = (
f"{helper}\n{fn}\n"
"if _has_usable_nvidia_gpu; then echo DETECTED; else echo NONE; fi\n"
)
proc = subprocess.run(
["sh", "-c", script],
env = {"PATH": path_env},
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 20, # generous: the internal timeout is 10s, sleep is 30s
)
# The probe must have returned (not hung). On this CI host /proc/driver/
# nvidia/gpus is absent, so a timed-out smi yields NONE; on a real NVIDIA
# host the /proc fallback yields DETECTED. Either way it must not hang.
assert proc.stdout.strip() in {"NONE", "DETECTED"}
finally:
shutil.rmtree(workdir, ignore_errors = True)