diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index ceae8e049d..15a1affbc5 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -26,6 +26,7 @@ on: - 'unsloth_cli/**' - 'install.ps1' - 'pyproject.toml' + - 'tests/studio_setup_ps1/**' - '.github/workflows/studio-windows-inference-smoke.yml' push: branches: [main, pip] @@ -1244,3 +1245,537 @@ jobs: logs/install.log logs/llama-server/*.log retention-days: 7 + + # ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ── + no-vs-cpu: + name: Studio install + inference without Visual Studio + runs-on: windows-latest + timeout-minutes: 35 + defaults: + run: + shell: bash + env: + GGUF_REPO: unsloth/gemma-3-270m-it-GGUF + GGUF_VARIANT: UD-Q4_K_XL + GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf + STUDIO_PORT: '18820' + HF_HOME: ${{ github.workspace }}/hf-cache + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Restore HF_HOME for ${{ env.GGUF_REPO }} + id: cache-hf + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + continue-on-error: true + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Prime HF_HOME with the GGUF + id: prime-hf + if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success' + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + python -m pip install --upgrade huggingface_hub + mkdir -p hf-cache + bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" + bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf + + - name: Save HF_HOME for ${{ env.GGUF_REPO }} + if: always() && steps.prime-hf.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: hf-cache + key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2 + + - name: Pre-install Windows tweaks (npm 11 + Defender exclusions) + shell: pwsh + run: | + $ProgressPreference = 'SilentlyContinue' + npm install -g 'npm@^11' 2>&1 | Out-Host + foreach ($p in @( + "$env:USERPROFILE\.unsloth", + "$env:USERPROFILE\AppData\Local\uv", + "$env:GITHUB_WORKSPACE\studio\frontend\node_modules", + "$env:GITHUB_WORKSPACE\studio\frontend\dist" + )) { + try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { } + } + + - name: Hide Visual Studio + CMake (simulate a host with no build tools) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Rename the Visual Studio install roots (incl. the Installer that holds + # vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss. + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + if (Test-Path -LiteralPath $d) { + Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff') + Write-Host "Hid VS: $d" + } + } + # Surgically rename each cmake executable on PATH (not its parent dir -- + # cmake can share a dir with other shims) so Get-Command cmake fails. + $hidden = @() + foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) { + if ($c.Source -and (Test-Path -LiteralPath $c.Source)) { + Rename-Item -LiteralPath $c.Source -NewName ((Split-Path $c.Source -Leaf) + '.off') + $hidden += $c.Source + Write-Host "Hid cmake: $($c.Source)" + } + } + ("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Assert Visual Studio + CMake are genuinely undetectable + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) { + . ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn))) + } + $vs = Find-VsBuildTools + if ($vs) { Write-Error "Find-VsBuildTools still detects VS: $($vs.Generator) @ $($vs.InstallPath)"; exit 1 } + if (Get-Command cmake -ErrorAction SilentlyContinue) { Write-Error "cmake is still on PATH"; exit 1 } + if (Get-Command cl.exe -ErrorAction SilentlyContinue) { Write-Error "cl.exe is still on PATH"; exit 1 } + Write-Host "Confirmed: no Visual Studio, no cmake, no cl.exe." + + - name: PyTorch CPU wheel installs and imports (no Visual Studio) + run: | + python -m pip install --upgrade pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())" + + - name: Install Studio (--local, --no-torch) with no build tools present + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + $ProgressPreference = 'SilentlyContinue' + & ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log + + - name: Assert prebuilt used AND no build tools were installed + run: | + LLAMA_DIR=~/.unsloth/llama.cpp + INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json" + BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe" + fail=0 + if grep -q "falling back to source build" logs/install.log; then + echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1 + fi + # The deferred build-tool installs must NOT run on the prebuilt path. + for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do + if grep -qi "$pat" logs/install.log; then + echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1 + fi + done + [ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; } + [ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; } + if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi + echo "Prebuilt installed with no build tools:" + cat "$INFO" + + - name: Add Studio shim to GITHUB_PATH + run: | + SHIM_DIR=~/.unsloth/studio/bin + [ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; } + cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH" + + - name: Reset auth + boot Studio (API-only) + run: | + unsloth studio reset-password + mkdir -p logs + UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ + > logs/studio.log 2>&1 & + echo "STUDIO_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for /api/health, log in, load the GGUF + run: | + for i in $(seq 1 180); do + if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then + jq -e '.status == "healthy"' /tmp/health.json && break + fi + sleep 1 + done + jq -e '.status == "healthy"' /tmp/health.json || { tail -200 logs/studio.log; exit 1; } + OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password) + NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')" + echo "::add-mask::$OLD" + echo "::add-mask::$NEW" + OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token) + curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \ + -H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \ + -d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null + TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \ + -H 'content-type: application/json' \ + -d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token) + echo "API_KEY=$TOKEN" >> "$GITHUB_ENV" + LOAD_OK=0 + for attempt in 1 2 3; do + HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \ + -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \ + -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + --max-time 600 \ + -d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}") + if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi + echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10 + done + [ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; } + jq '{status, display_name, is_gguf}' /tmp/load.json + + - name: Inference works via the prebuilt llama.cpp (no VS) + run: | + RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \ + --max-time 240 \ + -d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}') + echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; } + CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content') + [ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; } + echo "Inference OK without Visual Studio: $CONTENT" + + - name: Restore Visual Studio + CMake + if: always() + shell: pwsh + run: | + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } + if ($env:HIDDEN_CMAKE) { + foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) { + if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) } + } + } + + - name: Stop Studio + if: always() + shell: cmd + run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end) + + - name: Collect llama-server logs + if: always() + continue-on-error: true + run: | + mkdir -p logs/llama-server + cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs" + + - name: Upload logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-no-vs-cpu-log + path: | + logs/install.log + logs/studio.log + logs/llama-server/*.log + retention-days: 7 + + # ───────────────────────────────────────────────────────────────────── + # Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability) + # ───────────────────────────────────────────────────────────────────── + no-vs-gpu-resolve: + name: GPU prebuilt resolves without Visual Studio + runs-on: windows-latest + timeout-minutes: 15 + defaults: + run: + shell: bash + env: + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Hide Visual Studio + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + if (Test-Path -LiteralPath $d) { Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" } + } + + - name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/unslothai/llama.cpp/releases/latest" > /tmp/rel.json + echo "release: $(jq -r .tag_name /tmp/rel.json)" + ASSETS=$(jq -r '.assets[].name' /tmp/rel.json) + echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || { + echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + # AMD parity: hosted runners have no AMD GPU, so the resolver step below + # can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx + # Windows ROCm bundles here so a release that drops them fails loudly -- + # the AMD no-VS guarantee otherwise rides only on shared resolver code. + echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || { + echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release" + echo "$ASSETS"; exit 1; } + echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling." + + - name: The prebuilt resolver runs without Visual Studio + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Resolver-only (no GPU on hosted runners, so the host resolves to the + # CPU bundle). The point is that resolution needs no compiler/VS. + python -m pip install --upgrade huggingface_hub + python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || { + echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; } + cat /tmp/resolve.json + echo "Prebuilt resolver ran with no Visual Studio present." + + - name: Restore Visual Studio + if: always() + shell: pwsh + run: | + foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) { + $off = "$d.vsoff" + if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" } + } + + # ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ── + pester: + name: setup.ps1 unit tests (VS 2026 / CMake guard) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Pester v5 + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + Import-Module Pester -MinimumVersion 5.5.0 + Get-Module Pester | Select-Object Name, Version | Format-Table + + - name: Run Pester suite + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1' + if (-not (Test-Path $testDir)) { + Write-Error "Test directory not found: $testDir" + exit 1 + } + $cfg = New-PesterConfiguration + $cfg.Run.Path = $testDir + $cfg.Run.Exit = $true # non-zero exit => job fails + $cfg.Run.Throw = $true # also throw on test failure / 0 tests + $cfg.TestResult.Enabled = $true + $cfg.TestResult.OutputFormat = 'NUnitXml' + $cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml' + $cfg.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $cfg + + - name: Upload Pester results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pester-results-setup-ps1 + path: pester-results.xml + if-no-files-found: warn + + vs-integration: + # Real detection against the VS installed on the runner image (no mocks). + name: real-VS detection (${{ matrix.label }}) + strategy: + fail-fast: false + matrix: + include: + - { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' } + - { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' } + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect the real Visual Studio with setup.ps1 functions + shell: pwsh + env: + EXPECT_GEN: ${{ matrix.expectGen }} + EXPECT_TOOLSET: ${{ matrix.expectToolset }} + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) { + . ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn))) + } + + # Ground truth from the real vswhere (independent of our code), for visibility. + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsw) { + $year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1) + $path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1) + Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'" + } else { + Write-Host "vswhere not present at $vsw (relying on filesystem fallback)" + } + + # Our detection must find the real VS and report the expected generator. + $r = Find-VsBuildTools + if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" } + Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'" + if ($r.Generator -ne $env:EXPECT_GEN) { + throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'" + } + if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" } + + # Toolset path derivation must match the expected v-number... + $bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator + $derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180 + Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')" + if ($derived -ne $env:EXPECT_TOOLSET) { + throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'" + } + + # ...and that v-number is a real folder on the VS install (where CUDA's + # BuildCustomizations would land). + $vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC' + if (Test-Path $vcRoot) { + $realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name) + Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')" + if ($realToolsets -notcontains $derived) { + throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))" + } + Write-Host "OK: toolset '$derived' exists on the real VS install." + } else { + Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check." + } + + Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'." + + vcredist-clean-box: + # Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner: + # present on the stock image, fires on a clean box (signals removed restorably), + # then a literal uninstall/reinstall round trip. Always restored before the end. + name: VC++ runtime detect + install round-trip (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-2025-vs2026] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect present, fire on a clean box, and round-trip the install + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + . (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1') + $setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1' + # Dot-source the guard + the logging closure it reaches + # (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi). + $script:StudioVtOk = $false + $script:UnslothVerbose = $false + foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep', + 'Invoke-SetupCommand', 'Refresh-Environment', + 'Test-VCRedistInstalled', 'Ensure-VCRedist')) { + $src = Get-FunctionSource -Path $setup -Name $fn + if (-not $src) { throw "Function '$fn' not found in setup.ps1" } + . ([scriptblock]::Create($src)) + } + + $regKeys = @( + 'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', + 'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64' + ) + function Show-GroundTruth { + $dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll' + Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll)) + foreach ($k in $regKeys) { + $r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue + if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) } + else { Write-Host (" {0}: (absent)" -f $k) } + } + } + + Write-Host '== A. Detection on the stock runner (expect present) ==' + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' } + Write-Host ' Test-VCRedistInstalled -> present OK' + + Write-Host '== B. Genuinely clean box (restorable): detection must FIRE ==' + $scratch = Join-Path $env:RUNNER_TEMP 'cleanwin' + New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null + $backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup' + New-Item -ItemType Directory -Force -Path $backup | Out-Null + $origSysRoot = $env:SystemRoot + try { + for ($i = 0; $i -lt $regKeys.Count; $i++) { + reg query $regKeys[$i] *> $null + if ($LASTEXITCODE -eq 0) { + reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null + reg delete $regKeys[$i] /f *> $null + } + } + $env:SystemRoot = $scratch + if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' } + Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)' + } finally { + $env:SystemRoot = $origSysRoot + for ($i = 0; $i -lt $regKeys.Count; $i++) { + $f = Join-Path $backup "$i.reg" + if (Test-Path $f) { reg import $f *> $null } + } + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' } + + Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection ==' + $exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe' + Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe + Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait + Show-GroundTruth + Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled)) + if (Test-VCRedistInstalled) { + Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package' + Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.' + } + + Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed ==' + Ensure-VCRedist + if (-not (Test-VCRedistInstalled)) { + Write-Host ' winget path did not restore it; using the official installer to close the round trip.' + Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait + } + Show-GroundTruth + if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' } + Write-Host ' Test-VCRedistInstalled -> present OK' + Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.' diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 28941d91a6..20d53734a1 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -390,45 +390,199 @@ function Get-PytorchCudaTag { return "cu126" } -# Find Visual Studio Build Tools for cmake -G flag. -# Strategy: (1) vswhere, (2) scan filesystem (handles broken vswhere registration). -# Returns @{ Generator = "Visual Studio 17 2022"; InstallPath = "C:\..."; Source = "..." } or $null. -function Find-VsBuildTools { - $map = @{ '2022' = '17'; '2019' = '16'; '2017' = '15' } +# VS generator -> MSBuild BuildCustomizations dir; toolset tracks the VS major +# (18->v180, 17->v170), defaulting to v170 when unparseable. +function Get-VcBuildCustomizationsDir { + param( + [Parameter(Mandatory)][string]$VsInstallPath, + [string]$Generator + ) + $toolset = 'v170' + if ($Generator -and ($Generator -match 'Visual Studio (\d+)\b')) { + $toolset = "v$($Matches[1])0" + } + return (Join-Path $VsInstallPath "MSBuild\Microsoft\VC\$toolset\BuildCustomizations") +} - # --- Try vswhere first (works when VS is properly registered) --- +# Installed cmake version, or $null if absent/unparseable. +function Get-CmakeVersion { + $raw = & cmake --version 2>$null | Select-Object -First 1 + if ($raw -and ($raw -match '(\d+)\.(\d+)(?:\.(\d+))?')) { + $patch = if ($Matches[3]) { $Matches[3] } else { '0' } + return [version]"$($Matches[1]).$($Matches[2]).$patch" + } + return $null +} + +# VS 18 2026 generator needs cmake >= 4.2 (added there); true for older VS generators. +function Test-CmakeSupportsGenerator { + param( + [Parameter(Mandatory)][string]$CmakeVersion, + [Parameter(Mandatory)][string]$Generator + ) + if ($Generator -match 'Visual Studio 18\b') { + $clean = ($CmakeVersion -replace '[^0-9.].*$', '').TrimEnd('.') + try { $v = [version]$clean } catch { return $false } + return ($v -ge [version]'4.2') + } + return $true +} + +function Test-CmakeListsGenerator { + # Does `cmake --help` actually list the generator? A VS-bundled cmake can drive + # VS 2026 below the 4.2 floor, so probe rather than trust the version. (#6473) + param([Parameter(Mandatory)][string]$Generator) + $help = & cmake --help 2>$null | Out-String + if (-not $help) { return $false } + $haystack = ($help -replace '\s+', ' ') + $needle = ($Generator -replace '\s+', ' ') + return $haystack.Contains($needle) +} + +function Test-CmakeCanDriveGenerator { + # cmake can drive $Generator if it lists it (VS-bundled below 4.2) or meets the floor. + param([Parameter(Mandatory)][string]$Generator) + if (Test-CmakeListsGenerator -Generator $Generator) { return $true } + $verObj = Get-CmakeVersion + $verStr = if ($verObj) { $verObj.ToString() } else { '0.0' } + return (Test-CmakeSupportsGenerator -CmakeVersion $verStr -Generator $Generator) +} + +function Add-DefaultCmakeToPath { + # Prepend the default CMake dir so a freshly winget-installed cmake wins over an + # older one already on PATH. $true if found. (#6473) + $cmakeDefaults = @( + "$env:ProgramFiles\CMake\bin", + "${env:ProgramFiles(x86)}\CMake\bin", + "$env:LOCALAPPDATA\CMake\bin" + ) + foreach ($d in $cmakeDefaults) { + if (Test-Path (Join-Path $d "cmake.exe")) { + $env:Path = "$d;$env:Path" + Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null + return $true + } + } + return $false +} + +function Get-FallbackVsGenerator { + # Newest pre-2026 VS whose generator the current cmake can drive, for when the + # VS 2026 generator is unusable (old/offline cmake) but an older toolchain exists. + # vswhere first (catches non-default roots like D:\), then Program Files; matches + # Find-VsBuildTools. Returns @{ Generator; InstallPath } or $null. (#6473) + $knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview') + + # install path if it holds a usable cl.exe, else $null + $tryCandidate = { + param($gen, $installPath) + if (-not $installPath) { return $null } + $vcDir = Join-Path $installPath "VC\Tools\MSVC" + if (-not (Test-Path $vcDir)) { return $null } + $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cl) { return @{ Generator = $gen; InstallPath = $installPath } } + return $null + } + + # vswhere (non-default roots) + $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsw) { + $json = & $vsw -all -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -format json 2>$null | Out-String + if ($json) { + try { $instances = @($json | ConvertFrom-Json) } catch { $instances = @() } + $ranked = $instances | ForEach-Object { + $label = if ($_.catalog -and $_.catalog.productLineVersion) { [string]$_.catalog.productLineVersion } else { '' } + [pscustomobject]@{ Gen = (Resolve-VsGeneratorFromLabel $label); Path = [string]$_.installationPath } + } | Where-Object { $_.Gen -and ($_.Gen -notmatch 'Visual Studio 18\b') } + # newest first: 2022 > 2019 > 2017 + $ranked = $ranked | Sort-Object { switch -regex ($_.Gen) { '17 2022' {0} '16 2019' {1} '15 2017' {2} default {9} } } + foreach ($cand in $ranked) { + if (-not (Test-CmakeListsGenerator -Generator $cand.Gen)) { continue } + $res = & $tryCandidate $cand.Gen $cand.Path + if ($res) { return $res } + } + } + } + + # Program Files scan + $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } + $older = @( + @{ Dir = '2022'; Generator = 'Visual Studio 17 2022' }, + @{ Dir = '2019'; Generator = 'Visual Studio 16 2019' }, + @{ Dir = '2017'; Generator = 'Visual Studio 15 2017' } + ) + foreach ($entry in $older) { + if (-not (Test-CmakeListsGenerator -Generator $entry.Generator)) { continue } + foreach ($r in $roots) { + $vsBase = Join-Path $r "Microsoft Visual Studio\$($entry.Dir)" + if (-not (Test-Path $vsBase)) { continue } + foreach ($ed in $knownEditions) { + $candidate = Join-Path $vsBase $ed + if (-not (Test-Path $candidate)) { continue } + $res = & $tryCandidate $entry.Generator $candidate + if ($res) { return $res } + } + } + } + return $null +} + +# VS version label -> cmake generator. vswhere's productLineVersion is the year for +# VS <= 2022 but the internal major "18" for VS 2026, and dir names use either form, +# so accept both. (VS 2026 detection adapted from @LeoBorcherding's #6038.) +function Resolve-VsGeneratorFromLabel { + param([string]$Label) + if (-not $Label) { return $null } + $map = @{ + '2026' = 'Visual Studio 18 2026'; '18' = 'Visual Studio 18 2026' + '2022' = 'Visual Studio 17 2022'; '17' = 'Visual Studio 17 2022' + '2019' = 'Visual Studio 16 2019'; '16' = 'Visual Studio 16 2019' + '2017' = 'Visual Studio 15 2017'; '15' = 'Visual Studio 15 2017' + } + return $map[$Label.Trim()] +} + +# Find VS Build Tools for cmake -G: vswhere, then a filesystem scan (handles broken +# vswhere registration). Returns @{ Generator; InstallPath; Source } or $null. +function Find-VsBuildTools { + # vswhere first (works when VS is properly registered) $vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vsw) { $info = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property catalog_productLineVersion 2>$null $path = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null if ($info -and $path) { - $y = $info.Trim() - $n = $map[$y] - if ($n) { - return @{ Generator = "Visual Studio $n $y"; InstallPath = $path.Trim(); Source = 'vswhere' } + $gen = Resolve-VsGeneratorFromLabel $info + if ($gen) { + return @{ Generator = $gen; InstallPath = $path.Trim(); Source = 'vswhere' } } } } - # --- Scan filesystem (handles broken vswhere registration after winget cycles) --- - $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) - $editions = @('BuildTools', 'Community', 'Professional', 'Enterprise') - $years = @('2022', '2019', '2017') + # filesystem scan (handles broken vswhere registration); VS 2026+ dir is "18" + $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } + $knownEditions = @('BuildTools', 'Community', 'Professional', 'Enterprise', 'Preview') + $dirs = @('18', '2026', '2022', '2019', '2017') - foreach ($y in $years) { + foreach ($d in $dirs) { + $gen = Resolve-VsGeneratorFromLabel $d + if (-not $gen) { continue } foreach ($r in $roots) { - foreach ($ed in $editions) { - $candidate = Join-Path $r "Microsoft Visual Studio\$y\$ed" - if (Test-Path $candidate) { - $vcDir = Join-Path $candidate "VC\Tools\MSVC" - if (Test-Path $vcDir) { - $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($cl) { - $n = $map[$y] - if ($n) { - return @{ Generator = "Visual Studio $n $y"; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName } - } - } + $vsBase = Join-Path $r "Microsoft Visual Studio\$d" + if (-not (Test-Path $vsBase)) { continue } + # VS 2026 (dir "18") may use non-standard edition names, so scan every subdir + if ($d -eq '18' -or $d -eq '2026') { + $editionCandidates = Get-ChildItem -Path $vsBase -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName } + } else { + $editionCandidates = $knownEditions | ForEach-Object { Join-Path $vsBase $_ } + } + foreach ($candidate in $editionCandidates) { + if (-not (Test-Path $candidate)) { continue } + $vcDir = Join-Path $candidate "VC\Tools\MSVC" + if (Test-Path $vcDir) { + $cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cl) { + $ed = Split-Path $candidate -Leaf + return @{ Generator = $gen; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName } } } } @@ -438,6 +592,103 @@ function Find-VsBuildTools { return $null } +# Install CMake + VS Build Tools, deferred here from Phase 1 so the prebuilt path +# never pays for a multi-GB install. Called only when a source build is committed. +# CMake is best-effort (build skips downstream if absent); VS Build Tools are +# required, so exit 1 with guidance if missing. No-ops for VS when already detected. +function Ensure-BuildToolsForLlamaSourceBuild { + # CMake + if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) { + Write-Host "CMake not found -- installing via winget (needed for the llama.cpp source build)..." -ForegroundColor Yellow + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + try { + Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { } + } + # winget may install cmake but not put it on PATH yet; try the default dir + if ($null -eq (Get-Command cmake -ErrorAction SilentlyContinue)) { + $cmakeDefaults = @( + "$env:ProgramFiles\CMake\bin", + "${env:ProgramFiles(x86)}\CMake\bin", + "$env:LOCALAPPDATA\CMake\bin" + ) + foreach ($d in $cmakeDefaults) { + if (Test-Path (Join-Path $d "cmake.exe")) { + $env:Path = "$d;$env:Path" + Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null + break + } + } + } + if ($null -ne (Get-Command cmake -ErrorAction SilentlyContinue)) { step "cmake" "installed" } + } + + # VS Build Tools + if ($script:VsInstallPath) { return } # already detected by the early probe + $vsResult = Find-VsBuildTools + if (-not $vsResult) { + Write-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow + Write-Host " (Needed only for the llama.cpp source build; may take several minutes)" -ForegroundColor Gray + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + $prevEAPTemp = $ErrorActionPreference + $ErrorActionPreference = "Continue" + winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait" + $ErrorActionPreference = $prevEAPTemp + # Re-scan after install (don't trust vswhere catalog) + $vsResult = Find-VsBuildTools + } + } + if ($vsResult) { + $script:CmakeGenerator = $vsResult.Generator + $script:VsInstallPath = $vsResult.InstallPath + step "vs" "$($vsResult.Generator) ($($vsResult.Source))" + if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" } + } else { + Write-Host "[ERROR] Visual Studio Build Tools are required for the llama.cpp source build but could not be found or installed." -ForegroundColor Red + Write-Host " Manual install:" -ForegroundColor Red + Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow + Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow + exit 1 + } +} + +# 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. +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' + )) { + try { + $r = Get-ItemProperty -Path $k -ErrorAction Stop + if ($r.Installed -eq 1 -and [int]$r.Major -ge 14 -and [int]$r.Minor -ge 20) { return $true } + } catch { } + } + return $false +} + +# Install the VC++ 2015-2022 runtime if missing (non-fatal; usually a no-op). +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 + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + try { + Invoke-SetupCommand { winget install --id Microsoft.VCRedist.2015+.x64 --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "VCRedist install failed: $($_.Exception.Message)" "Yellow" } + } + if (Test-VCRedistInstalled) { step "vcredist" "installed" } + else { + substep "Could not install the VC++ Redistributable automatically." "Yellow" + substep "If llama-server or torch reports a missing VCRUNTIME140.dll, install:" "Yellow" + substep "https://aka.ms/vs/17/release/vc_redist.x64.exe" "Yellow" + } +} + # ───────────────────────────────────────────── # Output style (aligned with studio/setup.sh: step / substep) # ───────────────────────────────────────────── @@ -1110,83 +1361,39 @@ if (-not $HasGit) { } # ============================================ -# 1c. CMake (required for llama.cpp build) +# 1b.5. Visual C++ Redistributable (runtime for the prebuilt llama.cpp + PyTorch) # ============================================ +# Runtime dep, not a build tool: the prebuilt llama-server and PyTorch load it. +Ensure-VCRedist + +# ============================================ +# 1c. CMake (only needed for a llama.cpp SOURCE build -- detection only) +# ============================================ +# Detection only: the prebuilt path needs no compiler, so do not install or exit +# here. Ensure-BuildToolsForLlamaSourceBuild installs CMake if a source build runs. $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) -if (-not $HasCmake) { - Write-Host "CMake not found -- installing via winget..." -ForegroundColor Yellow - $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) - if ($HasWinget) { - try { - Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null - Refresh-Environment - $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) - } catch { } - } - # winget may succeed but cmake isn't on PATH yet (MSI PATH changes need a - # new shell). Try the default install location as a fallback. - if (-not $HasCmake) { - $cmakeDefaults = @( - "$env:ProgramFiles\CMake\bin", - "${env:ProgramFiles(x86)}\CMake\bin", - "$env:LOCALAPPDATA\CMake\bin" - ) - foreach ($d in $cmakeDefaults) { - if (Test-Path (Join-Path $d "cmake.exe")) { - $env:Path = "$d;$env:Path" - # Persist to user PATH (Prepend so this cmake wins over older ones). - Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null - $HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) - if ($HasCmake) { - Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray - break - } - } - } - } - if ($HasCmake) { - step "cmake" "installed" - } else { - Write-Host "[ERROR] CMake is required but could not be installed." -ForegroundColor Red - Write-Host " Install CMake from https://cmake.org/download/ and re-run." -ForegroundColor Red - exit 1 - } -} else { +if ($HasCmake) { step "cmake" "$(cmake --version | Select-Object -First 1)" +} else { + step "cmake" "not detected (only needed if a llama.cpp source build is required)" "Yellow" } # ============================================ -# 1d. Visual Studio Build Tools (C++ compiler for llama.cpp) +# 1d. Visual Studio Build Tools (only needed for a llama.cpp SOURCE build -- detection only) # ============================================ +# Detection only: detect VS for a possible source build, but never install or exit +# here. Install is deferred to Ensure-BuildToolsForLlamaSourceBuild. $CmakeGenerator = $null $VsInstallPath = $null $vsResult = Find-VsBuildTools -if (-not $vsResult) { - Write-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow - Write-Host " (This is a one-time install, may take several minutes)" -ForegroundColor Gray - $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) - if ($HasWinget) { - $prevEAPTemp = $ErrorActionPreference - $ErrorActionPreference = "Continue" - winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait" - $ErrorActionPreference = $prevEAPTemp - # Re-scan after install (don't trust vswhere catalog) - $vsResult = Find-VsBuildTools - } -} - if ($vsResult) { $CmakeGenerator = $vsResult.Generator $VsInstallPath = $vsResult.InstallPath - step "vs" "$CmakeGenerator ($($vsResult.Source))" + step "vs" "$CmakeGenerator ($($vsResult.Source)) (only used if a source build is needed)" if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" } } else { - Write-Host "[ERROR] Visual Studio Build Tools could not be found or installed." -ForegroundColor Red - Write-Host " Manual install:" -ForegroundColor Red - Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow - Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow - exit 1 + step "vs" "not detected (only needed if a llama.cpp source build is required)" "Yellow" } # ============================================ @@ -1429,7 +1636,7 @@ if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') { # the MSBuild .targets/.props files that let VS compile .cu files are missing. # cmake fails with "No CUDA toolset found". Fix: copy from CUDA extras dir. if ($VsInstallPath -and $CudaToolkitRoot) { - $vsCustomizations = Join-Path $VsInstallPath "MSBuild\Microsoft\VC\v170\BuildCustomizations" + $vsCustomizations = Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator $cudaExtras = Join-Path $CudaToolkitRoot "extras\visual_studio_integration\MSBuildExtensions" if ((Test-Path $cudaExtras) -and (Test-Path $vsCustomizations)) { $hasTargets = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue @@ -3070,6 +3277,17 @@ if (Test-Path -LiteralPath $LlamaServerBin) { } } +# Install build tools now (last resort) rather than eagerly in Phase 1, so the +# prebuilt path stays fast. Same condition as the if/elseif chain below: a source +# build runs only when needed and no usable binary is already present. +$WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and ` + -not ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") +if ($WillBuildLlamaFromSource) { + Ensure-BuildToolsForLlamaSourceBuild + # refresh so the chain below sees a newly installed cmake + $HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue) +} + if (-not $NeedLlamaSourceBuild) { Write-Host "" step "llama.cpp" "prebuilt (validated)" @@ -3092,10 +3310,56 @@ if (-not $NeedLlamaSourceBuild) { substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow" $script:LlamaCppDegraded = $true } else { - # A source build is committed here. The CUDA toolkit is only needed now, so - # resolve (and winget-install if needed) it lazily, failing fast if no - # driver-compatible toolkit exists. The prebuilt path never reaches this. + # Finalize the VS generator (gate/fallback below) BEFORE Resolve-CudaToolkit, + # which copies the CUDA .targets into the current generator's dir; a later swap + # would strand them. The CMake 4.2 gate for VS 2026 is checked only here, in the + # source-build path, so a VS 2026 + cmake < 4.2 host can still use the prebuilt. (#6473) + if ($CmakeGenerator -match 'Visual Studio 18\b') { + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + $cmakeVerObj = Get-CmakeVersion + $cmakeVerStr = if ($cmakeVerObj) { $cmakeVerObj.ToString() } else { '0.0' } + substep "CMake $cmakeVerStr cannot drive the Visual Studio 2026 generator (need 4.2+ or a VS-bundled cmake) -- updating via winget..." "Yellow" + if ($null -ne (Get-Command winget -ErrorAction SilentlyContinue)) { + # upgrade first (fast if Kitware.CMake is already a winget app), then + # prepend the default dir so the new cmake wins over an older one on PATH + try { + Invoke-SetupCommand { winget upgrade Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "CMake winget upgrade failed: $($_.Exception.Message)" "Yellow" } + Add-DefaultCmakeToPath | Out-Null + # upgrade no-ops if the cmake came from Scoop/Chocolatey/VS, not the + # Kitware winget package; install it so a 4.2+ cmake is available + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + try { + Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null + Refresh-Environment + } catch { substep "CMake winget install failed: $($_.Exception.Message)" "Yellow" } + Add-DefaultCmakeToPath | Out-Null + } + } + if (-not (Test-CmakeCanDriveGenerator -Generator $CmakeGenerator)) { + # cmake still cannot drive VS 2026; before failing, fall back to an + # older installed VS whose generator it can drive (e.g. VS 2022 + old + # cmake on an offline box keeps building) + $fallback = Get-FallbackVsGenerator + if ($fallback) { + substep "CMake cannot drive $CmakeGenerator; falling back to $($fallback.Generator)" "Yellow" + $CmakeGenerator = $fallback.Generator + $VsInstallPath = $fallback.InstallPath + } else { + Write-Host "[ERROR] CMake 4.2+ is required to build llama.cpp with the Visual Studio 2026 generator, and no older Visual Studio toolchain was found to fall back to." -ForegroundColor Red + Write-Host " Upgrade CMake from https://cmake.org/download/ and re-run, or use a prebuilt llama.cpp bundle." -ForegroundColor Red + exit 1 + } + } + } + substep "CMake can drive the $CmakeGenerator generator" + } + + # CUDA resolved here (fail fast if none), after the final VS generator so its + # .targets land in the toolset cmake actually uses. if ($HasNvidiaSmi) { Resolve-CudaToolkit -RequireOrExit } + Write-Host "" if ($HasNvidiaSmi) { substep "building llama.cpp with CUDA support..." @@ -3447,7 +3711,8 @@ if (-not $NeedLlamaSourceBuild) { Write-Host " Copy contents of:" -ForegroundColor Yellow Write-Host " \extras\visual_studio_integration\MSBuildExtensions" -ForegroundColor Yellow Write-Host " into:" -ForegroundColor Yellow - Write-Host " \MSBuild\Microsoft\VC\v170\BuildCustomizations" -ForegroundColor Yellow + $hintCustomizations = if ($VsInstallPath) { Get-VcBuildCustomizationsDir -VsInstallPath $VsInstallPath -Generator $CmakeGenerator } else { "\MSBuild\Microsoft\VC\v170\BuildCustomizations" } + Write-Host " $hintCustomizations" -ForegroundColor Yellow } } } diff --git a/tests/studio_setup_ps1/Get-FunctionSource.ps1 b/tests/studio_setup_ps1/Get-FunctionSource.ps1 new file mode 100644 index 0000000000..5317cde3a8 --- /dev/null +++ b/tests/studio_setup_ps1/Get-FunctionSource.ps1 @@ -0,0 +1,55 @@ +<# +.SYNOPSIS + Extracts a single `function NAME { ... }` block from a PowerShell script by + brace-matching, WITHOUT executing the script. + +.DESCRIPTION + studio/setup.ps1 is a top-level executing installer (it runs install steps at + load), so it cannot be dot-sourced directly in a test. This helper pulls just + the requested function's source text out of the file so a test can dot-source + ONLY that function. + + Brace matching is naive (it counts '{' / '}' without a full tokenizer). It is + safe for the pure helper functions targeted here because their bodies contain + only balanced braces (e.g. `${env:ProgramFiles(x86)}` is self-balanced) and no + here-strings/comments with stray unbalanced braces. + +.EXAMPLE + $src = Get-FunctionSource -Path studio/setup.ps1 -Name Get-VcBuildCustomizationsDir + . ([scriptblock]::Create($src)) # defines the function in the current scope +#> +function Get-FunctionSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name + ) + + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $text = Get-Content -Raw -LiteralPath $Path + if ([string]::IsNullOrEmpty($text)) { return $null } + + # Match "function " at the start of a line (multiline, case-insensitive). + $pattern = "(?im)^\s*function\s+$([regex]::Escape($Name))\b" + $m = [regex]::Match($text, $pattern) + if (-not $m.Success) { return $null } + + # Locate the opening brace at/after the match. + $braceStart = $text.IndexOf('{', $m.Index) + if ($braceStart -lt 0) { return $null } + + # Walk braces to the matching close. + $depth = 0 + $end = -1 + for ($i = $braceStart; $i -lt $text.Length; $i++) { + $c = $text[$i] + if ($c -eq '{') { $depth++ } + elseif ($c -eq '}') { + $depth-- + if ($depth -eq 0) { $end = $i; break } + } + } + if ($end -lt 0) { return $null } + + return $text.Substring($m.Index, $end - $m.Index + 1) +} diff --git a/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 new file mode 100644 index 0000000000..35c18770a5 --- /dev/null +++ b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 @@ -0,0 +1,368 @@ +<# + Pester v5 unit tests for the Visual Studio 2026 completion helpers in + studio/setup.ps1: + - Get-VcBuildCustomizationsDir : derive the VC MSBuild BuildCustomizations + folder (v160 / v170 / v180) from the detected VS generator. + - Test-CmakeSupportsGenerator : gate the "Visual Studio 18 2026" generator + on CMake >= 4.2 (no-op for older VS generators). + + Both are pure functions (no GPU, no Visual Studio, no CUDA, no network), so the + suite runs on a stock windows-latest runner - and on any pwsh host. + + The real functions are extracted from setup.ps1 and dot-sourced (the script is + a top-level installer and cannot be loaded wholesale). Path resolution honors + $env:SETUP_PS1_PATH (set by the PR-validate workflow) and falls back to the + repo-relative path. If a target function cannot be found, the suite FAILS + loudly rather than silently passing. +#> + +BeforeAll { + . (Join-Path $PSScriptRoot 'Get-FunctionSource.ps1') + + $candidates = @( + $env:SETUP_PS1_PATH, + (Join-Path $PSScriptRoot '..\..\studio\setup.ps1') + ) | Where-Object { $_ } + $script:SetupPs1 = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $script:SetupPs1) { throw "Could not locate studio/setup.ps1 (set SETUP_PS1_PATH)." } + Write-Host "setup.ps1 under test: $script:SetupPs1" + + foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools', 'Get-VcBuildCustomizationsDir', + 'Test-CmakeSupportsGenerator', 'Get-CmakeVersion', 'Test-CmakeListsGenerator', + 'Test-CmakeCanDriveGenerator', 'Get-FallbackVsGenerator', + 'Ensure-BuildToolsForLlamaSourceBuild', 'Test-VCRedistInstalled')) { + $src = Get-FunctionSource -Path $script:SetupPs1 -Name $fn + if (-not $src) { throw "Function '$fn' not found in $script:SetupPs1 - cannot test the real code." } + . ([scriptblock]::Create($src)) + } +} + +Describe 'Resolve-VsGeneratorFromLabel (vswhere/dir label -> generator)' { + # Guards that detection accepts both '18' (the internal major vswhere reports + # for VS 2026) and the year form. + It 'maps the VS 2026 internal major "18" to the VS 2026 generator' { + Resolve-VsGeneratorFromLabel '18' | Should -Be 'Visual Studio 18 2026' + } + It 'maps the VS 2026 year label "2026" to the VS 2026 generator' { + Resolve-VsGeneratorFromLabel '2026' | Should -Be 'Visual Studio 18 2026' + } + It 'maps the VS 2022 year "2022" and major "17" to the VS 2022 generator' { + Resolve-VsGeneratorFromLabel '2022' | Should -Be 'Visual Studio 17 2022' + Resolve-VsGeneratorFromLabel '17' | Should -Be 'Visual Studio 17 2022' + } + It 'maps 2019/2017 (year and major) to their generators' { + Resolve-VsGeneratorFromLabel '2019' | Should -Be 'Visual Studio 16 2019' + Resolve-VsGeneratorFromLabel '16' | Should -Be 'Visual Studio 16 2019' + Resolve-VsGeneratorFromLabel '2017' | Should -Be 'Visual Studio 15 2017' + Resolve-VsGeneratorFromLabel '15' | Should -Be 'Visual Studio 15 2017' + } + It 'trims whitespace (vswhere output can carry a trailing newline)' { + Resolve-VsGeneratorFromLabel " 18 `n" | Should -Be 'Visual Studio 18 2026' + } + It 'returns null for unknown or empty labels' { + Resolve-VsGeneratorFromLabel '2015' | Should -BeNullOrEmpty + Resolve-VsGeneratorFromLabel '' | Should -BeNullOrEmpty + Resolve-VsGeneratorFromLabel $null | Should -BeNullOrEmpty + } +} + +Describe 'Find-VsBuildTools (VS 2026 generator discovery)' { + # Exercises the real discovery entry point. Windows-only: Find-VsBuildTools builds + # backslash candidate paths that only resolve as directories on Windows. + BeforeAll { + # Define in BeforeAll, not the Describe body: Pester 5 runs the body only at + # discovery, so body-level functions are not visible in the run-phase It blocks. + function New-FakeVsTree { + param([string]$Root, [string]$VersionDir, [string]$Edition = 'BuildTools') + $clDir = Join-Path $Root "Microsoft Visual Studio\$VersionDir\$Edition\VC\Tools\MSVC\14.50.00000\bin\Hostx64\x64" + New-Item -ItemType Directory -Path $clDir -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $clDir 'cl.exe') -Force | Out-Null + } + } + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + } + + It 'detects a filesystem-only VS 2026 BuildTools install (dir "18")' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF' + New-FakeVsTree -Root $root -VersionDir '18' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86' # no vswhere here -> filesystem fallback + $r = Find-VsBuildTools + $r.Generator | Should -Be 'Visual Studio 18 2026' + } + + It 'detects a filesystem-only VS 2026 install under the year dir ("2026")' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF2026' + New-FakeVsTree -Root $root -VersionDir '2026' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86b' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 18 2026' + } + + It 'still detects VS 2022 (no regression)' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF2022' + New-FakeVsTree -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86c' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 17 2022' + } + + It 'detects an older VS installed under the Preview edition dir' -Skip:(-not $IsWindows) { + # Preview installs under a "Preview" edition folder; the fallback must include it. + $root = Join-Path $TestDrive 'PF2022prev' + New-FakeVsTree -Root $root -VersionDir '2022' -Edition 'Preview' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86d' + (Find-VsBuildTools).Generator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Get-VcBuildCustomizationsDir (CUDA to VS MSBuild integration path)' { + # Use TestDrive as the root so Join-Path resolves on any OS; assertions accept + # either path separator. + + It 'derives v180 for the VS 2026 generator' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 18 2026' | + Should -Match 'VC[\\/]v180[\\/]BuildCustomizations$' + } + + It 'derives v170 for VS 2022 (unchanged behavior)' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 17 2022' | + Should -Match 'VC[\\/]v170[\\/]BuildCustomizations$' + } + + It 'derives v160 for VS 2019' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 16 2019' | + Should -Match 'VC[\\/]v160[\\/]BuildCustomizations$' + } + + It 'falls back to v170 when the generator is empty/unparseable (backwards compatible)' { + Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator '' | + Should -Match 'VC[\\/]v170[\\/]BuildCustomizations$' + } + + It 'roots the path under the supplied VS install path' { + $p = Get-VcBuildCustomizationsDir -VsInstallPath "$TestDrive" -Generator 'Visual Studio 18 2026' + $p.StartsWith("$TestDrive") | Should -BeTrue + } +} + +Describe 'Test-CmakeSupportsGenerator (CMake 4.2 guard for VS 2026)' { + + It 'rejects CMake 3.31.0 with the VS 2026 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.31.0' -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'accepts CMake 4.2.1 with the VS 2026 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '4.2.1' -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'accepts CMake exactly 4.2 with the VS 2026 generator (boundary)' { + Test-CmakeSupportsGenerator -CmakeVersion '4.2' -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'rejects CMake 4.1.0 with the VS 2026 generator (boundary)' { + Test-CmakeSupportsGenerator -CmakeVersion '4.1.0' -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'is a no-op (accepts any CMake) for the VS 2022 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.20.0' -Generator 'Visual Studio 17 2022' | Should -BeTrue + } + + It 'is a no-op (accepts any CMake) for the VS 2019 generator' { + Test-CmakeSupportsGenerator -CmakeVersion '3.10.0' -Generator 'Visual Studio 16 2019' | Should -BeTrue + } +} + +Describe 'Test-CmakeListsGenerator (probe cmake --help)' { + # Mock cmake as a function (resolved before any on-PATH exe): PowerShell caches + # its app-path table, so a $env:Path shim would not reliably beat a real cmake. + + It 'returns true when cmake --help lists the generator' { + Mock cmake { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files.`n Visual Studio 17 2022 = Generates VS 2022 project files." } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'returns false when cmake --help does not list the generator' { + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } + + It 'returns false when cmake produces no help output' { + Mock cmake { $null } + Test-CmakeListsGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } +} + +Describe 'Test-CmakeCanDriveGenerator (probe OR version floor)' { + It 'accepts a sub-4.2 cmake that lists the VS 2026 generator (bundled cmake)' { + # 3.31.0 is below the 4.2 floor but lists the generator, so the help-probe accepts it. + Mock cmake { + if ($args -contains '--version') { 'cmake version 3.31.0' } + else { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files." } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'accepts a 4.2 cmake via the version floor when the help probe misses it' { + # Help omits the generator but 4.2.0 meets the floor, so the version branch accepts it. + Mock cmake { + if ($args -contains '--version') { 'cmake version 4.2.0' } + else { 'Generators' } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeTrue + } + + It 'rejects a sub-4.2 cmake that does not list the VS 2026 generator' { + Mock cmake { + if ($args -contains '--version') { 'cmake version 3.31.0' } + else { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + } + Test-CmakeCanDriveGenerator -Generator 'Visual Studio 18 2026' | Should -BeFalse + } +} + +Describe 'Get-FallbackVsGenerator (older VS the cmake can drive)' { + BeforeAll { + function New-FakeVsTree2 { + param([string]$Root, [string]$VersionDir, [string]$Edition = 'BuildTools') + $clDir = Join-Path $Root "Microsoft Visual Studio\$VersionDir\$Edition\VC\Tools\MSVC\14.39.00000\bin\Hostx64\x64" + New-Item -ItemType Directory -Path $clDir -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $clDir 'cl.exe') -Force | Out-Null + } + } + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + } + + It 'returns the VS 2022 generator when VS 2022 is installed and cmake lists it' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_fb' + New-FakeVsTree2 -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_fb' + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + $r = Get-FallbackVsGenerator + $r.Generator | Should -Be 'Visual Studio 17 2022' + } + + It 'returns null when the cmake cannot drive any installed older VS' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_none' + New-FakeVsTree2 -Root $root -VersionDir '2022' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_none' + # cmake lists only VS 2026 (not 2022/2019/2017), so no older fallback is usable. + Mock cmake { "Generators`n Visual Studio 18 2026 = Generates VS 2026 project files." } + $r = Get-FallbackVsGenerator + $r | Should -BeNullOrEmpty + } + + It 'falls back to an older VS installed under the Preview edition dir' -Skip:(-not $IsWindows) { + $root = Join-Path $TestDrive 'PF_prev' + New-FakeVsTree2 -Root $root -VersionDir '2022' -Edition 'Preview' + ${env:ProgramFiles} = $root + ${env:ProgramFiles(x86)} = Join-Path $TestDrive 'PFx86_prev' + Mock cmake { "Generators`n Visual Studio 17 2022 = Generates VS 2022 project files." } + (Get-FallbackVsGenerator).Generator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Deferred build tools (prebuilt path needs no VS/CMake)' { + # Phase-1 detection must be non-fatal (prebuilt path never blocked) and the + # deferred installer must no-op when VS was already detected. The install + + # exit-1 path is covered by studio-windows-no-vs-smoke.yml. + BeforeEach { + $script:OrigPF = ${env:ProgramFiles} + $script:OrigPFx86 = ${env:ProgramFiles(x86)} + } + AfterEach { + ${env:ProgramFiles} = $script:OrigPF + ${env:ProgramFiles(x86)} = $script:OrigPFx86 + $script:VsInstallPath = $null + $script:CmakeGenerator = $null + } + + It 'Find-VsBuildTools returns null when no VS is present (probe stays non-fatal)' { + # Empty discovery roots so no VS is found; the probe must return null + # (then log and continue, never exit). + ${env:ProgramFiles} = (Join-Path $TestDrive 'EmptyPF') + ${env:ProgramFiles(x86)} = (Join-Path $TestDrive 'EmptyPFx86') + New-Item -ItemType Directory -Force -Path ${env:ProgramFiles}, ${env:ProgramFiles(x86)} | Out-Null + Find-VsBuildTools | Should -BeNullOrEmpty + } + + It 'Ensure-BuildToolsForLlamaSourceBuild no-ops when VS is already detected' { + # With $VsInstallPath already set, the deferred installer must return without + # re-scanning or installing. + $script:VsInstallPath = 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools' + $script:CmakeGenerator = 'Visual Studio 17 2022' + { Ensure-BuildToolsForLlamaSourceBuild } | Should -Not -Throw + $script:VsInstallPath | Should -Be 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools' + $script:CmakeGenerator | Should -Be 'Visual Studio 17 2022' + } +} + +Describe 'Source-build ordering invariant: CUDA integration runs AFTER the VS generator is finalized (#6473 review)' { + # Resolve-CudaToolkit copies the CUDA .targets into the current generator's dir, + # so it must run after the VS 2026 gate/fallback; otherwise a fallback to VS 2022 + # builds v170 while the .targets went to v180 ("No CUDA toolset found"). + It 'the source-build Resolve-CudaToolkit call appears AFTER the Get-FallbackVsGenerator fallback' { + $text = Get-Content -Raw -LiteralPath $script:SetupPs1 + $idxFallback = $text.IndexOf('$fallback = Get-FallbackVsGenerator') + $idxResolve = $text.IndexOf('Resolve-CudaToolkit -RequireOrExit') + $idxFallback | Should -BeGreaterThan 0 + $idxResolve | Should -BeGreaterThan 0 + $idxResolve | Should -BeGreaterThan $idxFallback + } +} + +Describe 'Get-FallbackVsGenerator discovery is symmetric with Find-VsBuildTools (#6473 review)' { + # The fallback must also query vswhere, else a VS in a custom location is found + # as primary but missed as fallback -> avoidable hard exit. + It 'queries vswhere as part of fallback discovery' { + $src = Get-FunctionSource -Path $script:SetupPs1 -Name Get-FallbackVsGenerator + $src | Should -Match 'vswhere' + } +} + +Describe 'Test-VCRedistInstalled (VC++ 2015-2022 runtime needed by the prebuilt llama.cpp + PyTorch)' { + # The prebuilts link the VC++ runtime DLLs (which the Universal CRT lacks); + # detection is System32\vcruntime140_1.dll with a registry fallback. + BeforeEach { $script:OrigSysRoot = $env:SystemRoot } + AfterEach { $env:SystemRoot = $script:OrigSysRoot } + + # Probes Test-Path once (System32 DLL), then the registry; mock both. + It 'returns true when vcruntime140_1.dll is present in System32' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $true } + Test-VCRedistInstalled | Should -BeTrue + } + It 'returns true via the registry when the DLL is not found (Installed=1, >= 14.20)' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { [pscustomobject]@{ Installed = 1; Major = 14; Minor = 29 } } + Test-VCRedistInstalled | Should -BeTrue + } + It 'returns false when neither the DLL nor a >= 14.20 registry entry exists' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { throw 'no key' } + Test-VCRedistInstalled | Should -BeFalse + } + It 'returns false for an old 2015-only redist (Installed=1 but < 14.20)' { + $env:SystemRoot = 'C:\Windows' + Mock Test-Path { $false } + Mock Get-ItemProperty { [pscustomobject]@{ Installed = 1; Major = 14; Minor = 0 } } + Test-VCRedistInstalled | Should -BeFalse + } +}