Commit graph

140 commits

Author SHA1 Message Date
Daniel Han
2e3cc032f6
Record the venv own requirement digests, not the installers (#9263)
* Record the venv's own requirement digests, not the installer's

Every fresh desktop install on Linux and macOS came up ManagedStale and
repaired itself before it would run. From the app's own log:

  05:44:22 Managed preflight: install probe result Stale { reason: "studio_install_requirements_changed" }
  05:44:22 desktop_preflight completed disposition=ManagedStale
  05:44:22 start_managed_repair command called
  05:44:24 studio install incomplete -- forcing dependency pass to repair...
  05:44:32 Managed preflight: install probe result Ready

The manifest recorded digests from REQ_ROOT, which is the requirements
directory next to whichever install_python_stack.py ran. A desktop bundle
carries its own copy. verify_install reads the INSTALLED package's copy,
because at verify time install_manifest.py is imported out of the venv.
Two different trees, compared to each other.

They agree until a tracked requirement file changes upstream, and then
every install performed by that bundle is stale for ever. v0.1.800-beta
was cut 2026-08-14 and installs unsloth 2026.8.18; #9148 pinned openai in
extras.txt in between. Windows was unaffected only by luck of layout.

The digests now describe the tree the verifier will read. A source or
editable install has no copy under site-packages and still uses the root
it was given, which is what keeps an edited studio.txt invalidating the
manifest on the --local path.

Also: the CI gate reported "saw: ManagedStale" and nothing else on all
four platforms. The reason is logged on its own line, and tail -60 scrolls
it away the moment the backend starts logging, so the one fact that
explained this had to be recovered from an uploaded artifact by hand. All
three log dumps now grep for it.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 01:31:09 -07:00
Nilay
9d1dcfe58a
Studio: fail fast when the installed llama.cpp prebuilt has no kernels for this GPU (#8841)
* Studio: fail fast when the installed llama.cpp prebuilt has no kernels for this GPU

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

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

* Studio: skip the CUDA SM gate for deliberate CPU-only loads

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

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

* Studio: apply the CUDA SM gate at launch placement and fix its remedy

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

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

* Fix CUDA SM gate device order handling

* Studio: gate CUDA SM on the oldest compiled arch, not exact SM overlap

* Add the OS x GPU matrix and two installer invariants for PR #8841

The ROCm arch gate has test_gpu_arch_gate_os_matrix_7624.py asserting it is
inert off its one supported host shape; the CUDA gate had no equivalent, and
inertness is exactly what output alone cannot show since a gate that ran and
passed returns None like one that never ran. Spy the marker reader and the
nvidia-smi probe across [Windows, Linux, WSL, macOS] x [NVIDIA, AMD, CPU-only].

Also pins two properties the design rests on but nothing checked: supported_sms
must stay out of expected_install_fingerprint, or every pre-existing install
compares stale and reinstalls on upgrade, and a non-CUDA bundle must record an
empty list, which is the only reason the call site needs no Vulkan guard.

Covers the floor decision (sm_121 PTX cannot JIT down to sm_120, but sm_120 to
sm_121 can), unreadable and failing probes, and the visibility masks: a numeric
mask without PCI_BUS_ID ordering fails open, an empty one hides everything, and
a UUID or MIG mask is dropped so the whole host is weighed.

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

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

* Tighten the new code comments for PR #8841

* Shorten the comments added by the CUDA SM gate

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-18 05:40:52 -07:00
Daniel Han
6c124b6dd8
Run the repo CPU test suite on all four runner cores (#9019)
* Fix a 500 on dictation Unload before any backend is resident

stt_unload passes expected_model positionally:

    _, unload_stt = _stt_lifecycle()
    failed = await asyncio.to_thread(unload_stt, engines, model)

_stt_lifecycle() returns the orchestrator's unload_stt_model when a backend is
resident and stt_registry.unload when one is not. Only the first takes
expected_model positionally; on the registry it sits behind a `*`:

    def unload(engines = None, *, wait = True, expected_model = None)
    def unload_stt_model(self, engines = None, expected_model = None)

So with nothing loaded yet, which is what a fresh process is, Unload raises
TypeError and the route answers 500. Reproduced outside pytest:

    peek_inference_backend() fresh process: None
    stt_unload -> TypeError: unload() takes from 0 to 1 positional arguments
                             but 2 were given

Pass it by keyword, which both callables accept.

Found while profiling CI, not by the tests, and the tests are why: this file's
two unload tests passed only because an earlier test in the full suite had left
a backend resident. Standalone they failed, and in their own file they failed;
they went green only inside the full serial run. That order dependence hid a
live bug. The new test drives the no-backend path directly, so neither the
signature nor the call site can drift back.

    before: 2 failed, 53 passed   (file alone)
    after:  56 passed

* Run the repo CPU test suite on all four runner cores

Backend CI is the most expensive workflow in the repo at 126.7 runner-minutes
per push. Install is cached at ~1.2 minutes, so almost all of it is pytest.
ubuntu-latest has 4 vCPUs and this suite is CPU-bound and GPU-free, so it was
running on one core of four.

On a real runner the step goes 17.75 minutes to 7.67, and the failure set is
unchanged: the same single pre-existing failure, serial and parallel, on the
same tree in the same staging repo. Locally, on the dependency shape the
workflow installs, 806.1s to 219.7s with an identical 2 failed / 8066 passed /
178 skipped / 40 subtests result.

The three small pytest steps in this job stay serial: the hardware-spoof step
exists because those files mutate hardware.py module globals, and all three
already measure under 0.1 minutes.

The four-interpreter matrix is deliberately left alone. It is the bigger prize
at ~79 runner-minutes, but measured under -n 4 on a real runner it fails tests
serial does not, in more than one way and not the same way twice, so it needs
its order dependencies found and fixed first rather than a flag.

* Pin the two unload signatures against the route's one call site

The bug fixed in the previous commit exists because _stt_lifecycle returns two
different callables and the route has a single call site:

  def unload(engines = None, *, wait = True, expected_model = None)   # stt_registry
  def unload_stt_model(self, engines = None, expected_model = None)   # orchestrator

A call that suits one is a TypeError on the other, and which one runs depends on
whether a backend happens to be resident, so the broken half only appears on a
fresh process. That will recur the next time either signature is edited.

This binds BOTH real signatures against the arguments the route actually passes.
It does not demand they be identical, only that one call site can serve both.

Checked against two mutations:

  registry grows a keyword-only param  -> passes (benign, and it should)
  registry drops expected_model        -> fails, "got an unexpected keyword
                                          argument 'expected_model'"

Paired with the test beside it: that one covers the call site (route -> registry
on a cold process), this one covers the two callees staying compatible.

57 passed.

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

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

* Give each node harness run its own script file

Reported on this PR, and reproduced before changing anything. Every _run in
test_chat_preset_builtin_invariants.py wrote the same TEMP/run.mts and then
executed "node run.mts" from TEMP. Under pytest-xdist's default load
distribution two of this file's nine tests can land on different workers, so one
worker executes the script the other just wrote over the top of it.

Pinned to four cores, running the file at -n 4:

  before: 5 failed / 6 failed / 5 failed / 5 failed / 6 failed   (of 9)
  after:  9 passed, six runs out of six

Serial is unchanged at 9 passed either way, which is why the full-suite runs
that back this PR did not show it: at ~8000 tests across four workers these nine
rarely collide, so it is a latent flake rather than a reliable failure. That is
worse, not better, and enabling -n 4 is what would have started rolling the dice.

A unique NAME rather than a per-call directory, which is what
tests/studio/_node_harness.py::run_harness does for every other node harness
here. Those scripts reach the frontend sources by a relative path counted from
TEMP, so an extra directory level breaks every import with ERR_MODULE_NOT_FOUND
(tried it: 9 failed). register.mjs and loader.mjs stay shared because their
contents are fixed, so a concurrent rewrite writes identical bytes.

Swept the other five fixed TEMP roots under tests/studio: all of them already
allocate per call through tempfile.mkdtemp, directly or via run_harness. This
file was the only one rolling its own.

* Shorten the harness comment

* Stop two more shared-state races the parallel repo job would expose

Both reported on this PR, both real, both confirmed by reading the code.

1. tests/studio/test_chat_preset_builtin_invariants.py still rewrote the shared
   register.mjs and loader.mjs on every _run. Only run.mts became unique in the
   previous commit. write_text truncates before it writes, so a worker rewriting
   one of those while another worker's node process is importing it can hand that
   process an empty or partial module. The contents are constant, which is why I
   wrongly called a concurrent rewrite harmless: identical bytes still arrive
   after a truncation. They are now written through a temp file and os.replace,
   so every reader sees one whole version.

2. tests/python/test_no_torch_filtering.py::TestRealRequirementsFiltering had an
   autouse fixture that snapshotted the requirements directory and, at teardown,
   deleted every filtered file that had appeared since. Those files land in the
   REAL requirements directory, not tmp_path, so under xdist one test's teardown
   removes a file another worker is still reading. It now records the paths it
   creates and removes only those.

  test_chat_preset_builtin_invariants.py:  9 passed serial, 9 passed on four -n 4 runs
  test_no_torch_filtering.py:             65 passed serial, 65 passed on four -n 4 runs
  no filtered files left behind

* Run the event-loop latency tests outside the parallel invocation

Reported on this PR. tests/studio/load_freeze asserts upper bounds on real
elapsed time -- a /health burst under 250 ms while a 600 ms blocking probe runs,
and a 100-request burst under 350 ms -- and those bounds ARE the contract, so
they cannot be loosened without the tests ceasing to test anything. A pytest
worker descheduled by the other three inflates them.

Measured before changing anything, under 3x CPU oversubscription (8 spinners
pinned to the same 4 cores): 8 ms against the 250 ms bound, 38 ms against the
350 ms one, 3 runs, no failures. So the margin is wide on this box. It is not
wide enough to leave alone: a runner core is several times slower, which puts
the 350 ms bound within reach of an unlucky schedule. The directory is now
ignored from the -n 4 run and runs in its own serial step, 20s against the
~10 minutes -n 4 saves on this job.

The ignore and the step are two edits held together by nothing, and losing the
step is silent -- the job stays green while 23 tests stop running. New guard
tests/studio/test_backend_ci_parallel_isolation.py fails if an isolated path is
missing from the parallel run's ignores, or ignored with no serial step running
it, for load_freeze and for the three hardware-spoof files that were already in
this shape. Deleting either half of the load_freeze change fails it; a third
test pins the command scan so it cannot pass by matching nothing.

  load_freeze:            23 passed in 20.14s serial
  new guard:               9 passed, and 1 failed under each of the two mutations
  test_ci_shell_suite_coverage.py + test_xpu_spoof_pipeline.py: 58 passed

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

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

* Raise the matrix hang limit past the suite it was cancelling

Not a review item; found while triaging why every PR in this stack carries a red
check. The Backend CI matrix limit was 30 minutes, set when the step measured
14.1 to 15.0. It now measures 22.5 to 29.2, and it is cancelling: across the six
open PRs on this stack, five had exactly one leg cancelled at 30m9s while its
siblings passed at 25 to 29 minutes. Which leg loses is luck, so the red says
nothing about the PR, and a cancelled leg burns the full 30 minutes for no
signal.

45, which is what the Windows UI job already uses. This is a hang guard, not a
budget, and it costs nothing until a job needs it. The real fix is running this
matrix on all four cores, worth ~79 runner-minutes a push, and it stays blocked
on the order dependencies this stack is working through one at a time.

* Give each PowerShell probe its own script path

Reported on this PR, real, and reproduced directly. _run_capturing_bytes wrote to
tests/python/_{stem}_probe_{shape}.ps1, a name several tests share, so under
xdist one case unlinks the script another has written before that one's pwsh
child opens it. pwsh is installed on ubuntu-latest, so these do not skip there.

Reverting to the shared name and running -n 4 four times: 1 failed each time.
With a uuid in the name:

  35 passed, 8 skipped serial
  35 passed, 8 skipped on each of four -n 4 runs
  no probe scripts left behind

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

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

* Stop the pin scanner reading a generated filtered file as a second pin source

Reported on this PR and reproduced. test_no_torch_filtering.py exercises
install_python_stack._filter_requirements, which writes
`.{stem}-filtered-XXXX.txt` BESIDE the source on purpose, so relative -r/-c
includes still resolve. test_diffusers_pin.py::test_only_the_pin_file_names_diffusers
scans REQ_ROOT.rglob("*.txt"), so under xdist it can see one worker's copy of
diffusers-pin.txt and report it as a second source of the pin.

Calling _filter_requirements on diffusers-pin.txt and then running that test
fails exactly as described. It is not only an xdist problem: the function passes
delete=False, so the file also survives a real install on any machine.

The scan now skips dotfiles. That matches what it means -- no CHECKED-IN
requirements file other than the pin may name diffusers -- rather than narrowing
it: a real second source still fails it (verified with a probe file), and the
production write path is untouched, since writing beside the source is the
behaviour that makes includes resolve.

  5 passed with a filtered file present, 1 failed with a real second source

* Exempt only the generated filter files, not every dotfile

Reported on this PR and right: a checked-in hidden requirements file such as
.constraints.txt is a real place the pin could be overridden from, and the
blanket dotfile skip took it out of the scan.

Matched by the shape _filter_requirements actually writes now: a dot, the source
stem, "-filtered-", and NamedTemporaryFile's 8-character suffix.

  generated filtered file present:      5 passed
  checked-in .constraints.txt naming diffusers: 1 failed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-16 21:46:01 -07:00
Daniel Han
93225592c0
Tighten the comments on the versionless torch probe tests (#8811) 2026-08-14 05:40:53 -07:00
Nilay
083d613b95
Exclude studio/backend/tests from the wheel (#8501)
* exclude studio/backend/tests from the wheel

* Exclude nested studio backend test packages from the wheel

* Actually keep the backend test suites out of the wheel

Dropping studio.backend.tests from packages.find only stops setuptools
treating it as an importable package. include-package-data then hands every
tracked file to the nearest parent package that survived, so the 505 files
under studio/backend/tests plus the 11 under studio/backend/hub/tests came
straight back as package data of studio.backend and the wheel was byte for
byte the size it was before (43.95 MB, 2708 entries, both refs identical).

exclude-package-data has the highest precedence of the file selection
options, so repeat the veto there. Measured: 43.95 MB / 2708 entries ->
40.71 MB / 2192 entries, and the 516 entries removed are exactly the two
test trees, nothing else. The patterns are source layout relative, so the
sdist is unchanged (3666 entries either way) and editable installs still
see the suites.

Extend test_package_discovery.py so this cannot regress: it now replays
setuptools' nearest-parent-package rule over the tracked file list and
asserts no backend test file would ship, and checks a built wheel directly
when dist/ has one.

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-14 05:36:50 -07:00
Daniel Han
a0c1723bd1
Studio tests: pin the empty-version torch probe distinction at the repair paths (#8803)
* Studio tests: pin the empty-version torch probe distinction at the repair paths

_probe_torch_runtime draws a deliberate line between "" and None: an empty
__version__ is a torch the pins repair, while None is a probe that told us
nothing and must leave the venv alone. TestProbeParsing covers that at the probe.
Nothing covered it where it decides something.

That gap matters because the per-path probes classified inside the subprocess,
from torch.version.cuda and .hip as well as the version string, so a consumer
that gates on the version alone silently stops repairing a wheel whose runtime
fields say plainly what it is. Collapsing the distinction to `if not _version`
makes three of these four fail: a CPU pin stops replacing a versionless CUDA or
ROCm build, and an XPU pin stops repairing an unidentifiable one. The fourth
records the other side, where the two readings agree.

Test-only.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-14 05:09:30 -07:00
Daniel Han
cb07e02d7b
Studio tests: pin the torch probe invalidation contract to both installers (#8795)
* Studio tests: pin the torch probe invalidation contract to both installers

#8779 made pip_install_try() drop the memoized torch classification, because it
installs the Windows AMD ROCm trio and a memo it does not clear can answer for
the build it just replaced. That fix shipped with no test: only pip_install()
was covered, so reverting the pip_install_try() line kept the suite green.

Three tests, each of which fails with that line removed:

- pip_install_try() drops the memo, mirroring the pip_install() case
- a real reinstall is really reclassified: two real torch packages on disk and a
  real probe subprocess either side of a real call into each installer, so the
  answer that replaces the memo is shown to come from the venv as it is now
  rather than from the mock that dropped it
- every function that builds a pip or uv install command invalidates. Read out
  of the module with ast rather than listed here, so a third installer helper
  cannot be added without either invalidating or failing this test

No production change.

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

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

* Pin the torchao consumer to the reinstalled torch as well

Folds in the one test #8796 had that this branch did not. The other two
there duplicate what is already here, so #8796 closes in favour of this.

_select_torchao_spec reads _probe_installed_torch_version() between the
two repair points, which makes it the consumer that would actually read
a memo left over from before a reinstall, and pin torchao against the
torch that was just replaced. The existing cases assert on
_probe_torch_runtime directly; this one goes through the accessor that
the torchao path really calls.

Fails with the _invalidate_torch_runtime_probe() line removed from
pip_install_try, along with the three siblings, and passes with it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-14 04:46:23 -07:00
Daniel Han
7fd3eeba75
Studio: share one torch classification probe across the repair paths (#8779)
* Studio: share one torch classification probe across the repair paths

_ensure_cuda_torch, _ensure_xpu_torch, _ensure_rocm_torch and
_ensure_cpu_torch each spawned their own `import torch` subprocess to
derive the same handful of facts about the installed wheel, and the
installer runs all four at both the post-base-requirements repair point
and the final one. A Linux update therefore started up to nine
interpreters just to classify torch: four repair paths at each of the
two points, plus the torchao version probe.

Route them all through one memoized probe returning
(ran, importable, version, hip, cuda). pip_install() invalidates it,
since a pip operation is the only thing here that can change what is
installed, so a repair that swaps torch is still followed by a fresh
classification.

The timeout is the part that actually bit: each probe was bounded at 90s
independently, so a stalled GPU driver (exactly the host these repair
paths exist to rescue) could hang an update for many minutes before the
first on-disk fallback ran. It is now one 90s wait per repair point.

`ran` and `importable` stay distinct so every caller keeps its existing
behaviour: a probe that could not run falls back to the on-disk
classifier, while torch that is present but un-importable still forces
the reinstall.

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

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

* Tighten the comments around the shared torch probe

Same explanations, fewer lines. The four repair paths each restated the
same 'un-importable means missing or broken' point, and the probe docstring
retold the nine-interpreter story at length; each now makes its point once.

Comments and docstrings only, AST-verified per file (3/3 code unchanged).
702 tests pass, ruff clean.

* Add the AGPL-3.0 header to the new probe test

Matches the SPDX two-line form the other new files in this tree use.

* Keep the replacing decoder on the shared torch probe

The five probes this consolidated each read their subprocess with
errors="replace". The shared one kept text=True and lost that, which decodes
strictly, and UnicodeDecodeError is a ValueError, so one undecodable byte in
torch's import chatter escapes the except (OSError, TimeoutExpired) below and
takes the installer down instead of falling back to the on-disk classifier.
That fallback exists for exactly the hosts that reach this code.

Reachable wherever the console code page and the child's output disagree, which
is the Windows case this repo already carries an encoding suite for.

The regression test runs a real subprocess rather than a mock, since which
decoder was used is invisible to a mock. Reverting the one-line fix fails it
with the UnicodeDecodeError.

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

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

* Studio: harden the shared torch probe against the awkward venvs

Three fixes from simulating the shared probe against the pre-probe installer over
[Linux, WSL, macOS, Windows] x [NVIDIA, AMD, Intel, CPU] x 23 installed-torch
states, plus one memo-invalidation gap. All four are behaviour the per-path
probes had and this one had lost.

torch.version is reached through getattr now. A torch without that module made
the child exit non-zero, which reads as "torch cannot import" and
force-reinstalls a working venv. The old version probe only touched
torch.__version__ and answered fine.

The answer is printed behind a marker and parsed by that marker. "Last non-empty
line" is only correct while nothing chatters AFTER the answer, and an atexit
handler or a CUDA teardown notice does exactly that.

version is None when no marked line came back, distinct from "" for an empty
__version__. The XPU and CPU guards keyed off the empty string, so a venv with an
unreadable version silently stopped being repaired where it used to be.

pip_install_try() clears the memo too. It installs the Windows AMD ROCm trio, so
"only pip_install changes what is installed" was not true, and a stale
classification could answer for the previous build.

Tests: the mocked probe stdout in test_cuda_repair.py and test_rocm_support.py
carries the marker, and test_torch_probe_memoization.py gains the marker cases
plus a real-subprocess one for the missing torch.version module, since that path
crashes the installer rather than misrouting it.

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

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

* Prove the classification is a translation, and drop a leftover parser

Two things the hardening pass left behind.

The old "last non-empty line" parser is still sitting after the return in
_probe_torch_runtime, unreachable. Removed; asserted by a test so a future
in-place replacement cannot leave one again.

More importantly, nothing checked the part of this PR most able to be quietly
wrong. Moving the five probes out of subprocess `-c` strings and into ordinary
Python is meant to be a translation, but every existing test feeds the repair
paths a MOCKED probe answer, so they exercise the new derivation only and would
pass just as happily if it disagreed with the old one.

test_torch_probe_classification_parity.py compares the two directly. The old
expressions are reproduced verbatim from the merge base as reference
implementations, cited by line. The new derivations are pulled out of the live
module with ast rather than copied, so they cannot drift from what ships, and a
rename fails extraction loudly instead of silently passing. 27 torch states,
covering cu118 through cu130 tagged and untagged, rocm by hip and by version
string, rocmsdk, +xpu in and out of range, CPU, empty, and hip plus cuda both
set.

Mutation-tested rather than assumed, since a parity test that cannot fail is
worse than none. Dropping the rocm check from the CUDA marker, widening the XPU
bound, dropping .lower() from the CPU predicate or the ROCm marker, and swapping
the hip/cuda fields in the probe are each caught.

The .lower() mutations initially passed. The matrix had no state where a
substring test was the deciding factor, so the uppercase rows are there to make
case actually matter rather than for completeness.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-14 00:00:47 -07:00
Daniel Han
99bcfd3d93
Keep the #8577 AMD peer guards message-only, and fix the table drift they exposed (#8689)
* Keep the peer guards message-only, and stop the shell Polaris arms over-matching

Adversarial verification of the "message-only" claim found two ways this branch had
started changing what gets installed, both introduced by my own review fixes.

studio/setup.ps1: widening the adapter scan gate from $HasROCm to the arch let CIM
fill $script:ROCmGpuLabels on the amd-smi path. That variable feeds $gpuNames and so
the arch inference, and the existing unpinned "borrow another adapter's arch" rule
then resolved an arch where none was resolved before. On a host where amd-smi
confirms a runtime but reports no gfx token, an RX 9070 XT went from CPU torch to
gfx1201, and an RX 5700 beside an RX 7900 went to gfx1100, with --rocm-gfx forwarded
to the llama.cpp and whisper installers.

install.ps1: the Get-WmiObject to Get-CimInstance swap had the same effect on
PowerShell 7, where the old call threw and the catch swallowed it. A host CIM can see
but the ROCm tools cannot went from CPU torch to a repo.amd.com index.

Both scans are now exactly what they were before this branch, byte for byte, and the
peer names live in their own variable that only the uncovered-card verdict reads:
$wmiAmdNames in install.ps1, $script:ROCmPeerLabels in studio/setup.ps1. Neither
block writes a label or an arch. The pwsh suite asserts that through the AST rather
than asserting the gate, which is the property that actually matters; appending a
label or arch assignment to either block fails it.

The PowerShell 7 Get-WmiObject defect is therefore still there. Fixing it changes
what every pwsh 7 AMD host without a HIP SDK installs, so it belongs in its own PR.

Separately, the five copies of the unsupported table were not pinned to each other,
only the supported ones were, and they had already drifted: the regex copies carry
(?!0) so "RX 5800" is not Polaris, while the shell globs matched it through "RX 580".
The shell arms now carry the same guard, and a new test compares all five tables by
EVALUATING them in their own languages over a shared name corpus. Adding a row to one
file alone fails it.

Also pinned: a blank UNSLOTH_TORCH_INDEX_URL / _FAMILY must not read as a pin in the
Python path, which dropping the .strip() previously passed.

* Yield the peer suppression to an AMD visible-device mask

The peer check walked every adapter without looking at HIP_VISIBLE_DEVICES or
ROCR_VISIBLE_DEVICES, so on a mixed host a masked-out supported card could
silence the verdict about the card the user had actually selected. Both
installers now skip the suppression under a mask, which is the rule
studio/setup.ps1 already applies to its arch-borrowing branch: the mask names
the card, so the verdict is about that one.

HIP and ROCR only, deliberately, not Test-VisibleDevicesPinned's set:
CUDA_VISIBLE_DEVICES masks NVIDIA devices and says nothing about which Radeon
was chosen, and counting it fired the verdict beside a covered Radeon on every
host that sets it, which two of the pwsh cases caught immediately.

Three assertions per file pin it. Emptying the mask list fails one, adding
CUDA_VISIBLE_DEVICES back fails another.

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

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

* Scan adapters with CIM and drop the masked verdict when it may name another card for PR #8689

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 07:03:06 -07:00
Daniel Han
5a5bf64130
Reduce antivirus false positives in the desktop installers (#8586)
* Windows setup: install uv from a pinned release instead of running remote script text

studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.

Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.

* Windows: stop pairing a hidden window with a bypassed execution policy

The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.

The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.

Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.

* Installers: keep download-and-run command lines out of the shipped script text

AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.

Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.

Same treatment for scripts/uninstall.ps1's header.

* Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.

* Desktop: say who blocked the install when AMSI stops the script

PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".

Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.

Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.

* Desktop: ship each bundle only the installer it can run

resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.

Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.

The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.

* POSIX installers: install uv from a pinned release before falling back

install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.

Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.

Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.

* tests: pin the installer shapes antivirus heuristics score

One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.

The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.

Runs on the existing discovery-based pytest step, no workflow list to update.

* release: emit a false-positive submission packet for whatever gets flagged

The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.

The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.

The gate stays advisory; this only makes acting on it take seconds.

* Revert "Windows: resolve process image paths with one Win32_Process query"

This reverts commit 7897865c9.

tests/python/test_windows_installer_concurrency_guard.py bans Get-CimInstance
and $process.Path from Get-RunningStudioVenvProcesses outright, and requires the
native image-path lookup. That contract came out of #7764, which closed a set of
races where the installer inferred "in use" from something other than a confirmed
executable identity and blocked installs that should have proceeded.

Win32_Process.ExecutablePath does answer the same question, but a wrongly blocked
install costs far more than the heuristic weight of three native imports. Record
the imports in the AV-shapes allowlist with that reasoning instead, and keep the
ban on the process-memory APIs, which the installer has no use for.

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

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

* Tighten the comments added by this branch

Opening comment-reduction pass over the PR diff: same intent, fewer lines. Cut
hardest on the prose that restated the PR description rather than explaining the
code next to it. Comments and docstrings only, verified with comment_tools.py
check --strip-docstrings across every Python file in the diff.

* Drop an unused helper from the uv pinned-release test

* Fix three review findings on the installer hardening

Stray-resource check aborted the step it was meant to assert. grep exits 1 when
it selects nothing, and under this step's set -o pipefail plus the runner's
bash -e that kills the assignment outright, so every correctly split .deb failed
clean-machine CI before reaching the check. Both lookups take || true now: no
match is the passing case for the stray one, and for install.sh it was swallowing
the explicit annotation in favour of a bare exit 1.

studio/setup.sh skipped astral's XDG_DATA_HOME/../bin destination tier, which
install.sh, install.ps1 and studio/setup.ps1 all honour. A host that configured
an XDG location got uv under ~/.local/bin instead, where no later shell looks for
it. The session PATH prepend hid it at install time.

The AMSI guidance claimed nothing was changed even when the block landed on the
nested studio/setup.ps1, which install.ps1 launches through the same inherited
pipes after the venv, PyTorch and the packages are already on disk. Split the
wording on whether a [TAURI:STEP] marker has been seen: a pre-start block
produces none, so the reassurance is only given where it is true.

* Key the submission packet on the flagged count, not the engine list

stats and results are separate fields of the same VirusTotal response, so an
asset can carry a flagged count with no readable results map. The summary table
reports that asset and the packet skipped it, which is exactly the one that needs
a packet. Select on stats.flagged and keep the engine list for the Flagging
engines section, which is correctly keyed on having engines to name.

* Drop the bundle stray-resource assertion from clean-machine CI

That job downloads a published release, never a bundle built from the branch, so
asserting the new resource split there turns every run red until a release ships
with it. The split is a property of the Tauri config, and
tests/studio/test_tauri_installer_resource_contract.py already enforces it at the
right layer.

The || true on the install.sh lookup stays: it is what lets the explicit
annotation print instead of the step dying on grep's exit 1 under pipefail.

* Cover the uv host matrix and repeat application in the pinned-release test

The pinned path picks an archive per host triple, and a wrong pick installs a
binary that cannot execute, which is worse than not installing at all. Drive
_uv_pinned_asset over 20 host combinations and require each one to return its
own triple or decline to the fallback.

Also run the installer three times over one HOME and require an identical tree,
and require a stale uv at the destination to be replaced rather than joined by a
second copy: the installer is re-run on every upgrade and every repair.

* Pick the pinned uv archive off a positive libc check, not the absence of musl

An independent audit pass found the Linux selector accepts any host whose ldd
output does not say musl. That is not the same question astral's installer asks:
it checks a minimum glibc and drops to its musl-static archive below it, so
three hosts that worked before this branch now get a GNU binary that cannot exec,
and the helper reports success so the fallback never runs.

  aarch64 with glibc below 2.28 (Ubuntu 18.04)
  x86_64 with glibc below 2.17 (RHEL 6)
  a musl image with no ldd at all, where the probe simply finds nothing

Read the version instead, from ldd or getconf, and require it to clear astral's
floor for the triple. Anything unreadable declines to the fallback. Also ask the
userland for its bitness rather than trusting uname on a 64-bit kernel running a
32-bit userland, and follow astral in reading hw.optional.arm64 so a translated
shell under Rosetta 2 still gets the native macOS build.

Three more from the same pass:

Report success only when the destination uv is executable. A copy onto a busy or
read-only destination could leave a file that is not, and reporting success there
skipped the fallback. Nothing is unwound on the failure path on purpose: the
fallback installs over whatever is at the destination, and deleting there would
take out a working uv the host already had.

Clear the mark of the web on the launcher we author. WriteAllText replaces the
unnamed data stream and leaves other NTFS streams alone, so a launch-studio.ps1
that somehow carried one would keep it across the rewrite, and RemoteSigned
refuses a marked unsigned script.

Store the security-block kind and resolve its wording in message(). stdout and
stderr are read by independent threads, so a [TAURI:STEP] written before a block
can be observed after it, and freezing the wording at observation time could tell
a user nothing was changed on a run that had already installed PyTorch. Also
require the error id to appear as the value of a FullyQualifiedErrorId field, so
a scanner log that merely names it cannot attach antivirus guidance to whatever
fails next.

The host matrix in the shell test grows to 28 rows covering every case above, and
removing the new gate fails 8 of them. install.rs gains two tests: 37 pass.

* Replace a symlinked uv destination instead of writing through it

Three from the review on the previous head.

cp onto a destination that is a symlink follows the link, so installing over
`~/.local/bin/uv -> /opt/homebrew/bin/uv` rewrote the Homebrew binary in place
and left the link pointing at a file another package manager owns. Stage next to
the destination and rename over it: rename replaces the link itself, and it is
atomic, so a concurrent reader never sees a half-written uv either. The staging
file is removed when the rename fails, so a failed run leaves no debris.

Verify the Windows copy the same way the shell scripts now do. Copy-Item is
non-terminating under the caller's ErrorActionPreference, so a locked or
ACL-denied destination let execution reach `$haveUv = $true` and the function
reported success over whatever was already there. Compare the destination against
the archive we just verified, so a stale uv.exe cannot pass for the one we meant
to install. install.ps1 carried the same shape and gets the same treatment.

Point the header links at the heading that exists. The README has no "Install
Unsloth Studio"; it is "Unsloth Studio (web UI)", whose anchor is
#unsloth-studio-web-ui.

Three test cases cover the symlink: the file behind the link is untouched, the
link itself is replaced, and no staging file survives. Reverting the fix fails
two of them.

* Fail the build when the uv pin drifts from a version floor

Before the pin, astral's endpoint always delivered the newest uv, so raising
UV_MIN_VERSION was safe on its own. It is not any more: a floor above the pin
means a host with no uv gets 0.12.1 installed and then judged too old by the same
script that installed it, on the one path where the pin is what runs.

Two checks. All four installers must name the same uv, or which version a machine
ends up with depends on which script reached it first. And the pin must clear
every floor in the tree (UV_MIN_VERSION, UV_OFFLINE_MIN_VERSION, $UvMinVersion).
Raising a floor past the pin fails the first, bumping one installer's pin alone
fails the second.

* Write the shell profile entry the pinned uv path no longer gets for free

The P1 here is a real regression and it took a second look to see why.

install.sh decides whether to add ~/.local/bin to the user's shell profile with
`case ":$PATH:"`, near the end of the run. By then this process has prepended
that directory twice, once for the uv bootstrap and once for the venv, so the
guard answers yes for a login shell that would answer no and the profile line is
never written. That was survivable while astral's installer ran, because it wrote
its own profile line and its env file. The pinned path writes neither, so on a
fresh account whose login PATH lacks ~/.local/bin the install succeeds, the
current shell works, and the next terminal cannot find `unsloth` or `uv`.

Snapshot the inherited PATH before anything prepends to it and test the guard
against that.

Two more from the same review.

Honour a configured uv mirror exclusively. UV_INSTALLER_GHE_BASE_URL and
UV_INSTALLER_GITHUB_BASE_URL already win outright in both PowerShell installers
and in astral's own; the shell path ignored them and tried the public hosts
first. A restricted network sets one precisely because those hosts are
unreachable, and download() has no timeout, so it would hang rather than reach
the fallback.

Do not let the twin of an earlier clear erase a later AMSI verdict.
Clear-TauriInstallError writes one logical clear to BOTH streams
(install.ps1:198) and independent threads read them, so a block observed between
a clear and its own twin was discarded by the twin. Ignore a clear identical to
the one just processed; a genuine later recovery carries different text and still
clears. Two tests cover both directions, 39 install tests pass.

* Stage the uv copy under a per-process name

An audit pass reproduced a race I introduced with the symlink fix. Both POSIX
helpers staged through a fixed destination-side name, so two installers targeting
one directory shared it:

  A finishes copying the staging file
  B opens the same path with truncation
  A renames that inode into place as uv
  B keeps writing through its open descriptor, which is now the published uv

The published uv was observable at zero bytes until B resumed, which makes the
claim in the comment about a concurrent reader flatly wrong. install.ps1 is
covered by its named mutex, but nothing serialises the POSIX helpers, and
studio/setup.sh runs standalone on every studio update.

mktemp in the destination directory instead. Each rename then publishes a file no
other process can still be writing, which is what the atomicity argument needed
all along. The loser cleans up its own staging file and declines, so the caller
falls back rather than reporting a success it did not achieve.

* Keep a default install as quiet as it was when a uv mirror misbehaves

Two console regressions from the audit pass, both on paths the install still
recovers from.

download() runs curl -LsSf, and -S deliberately prints its own errors. The
fallback ran under run_maybe_quiet, so a failed download printed nothing before;
the pinned attempts run outside that wrapper, so an unreachable mirror now put
two curl: (N) lines on the console of a default install that then succeeded.
Redirect stderr on the speculative attempts only, leaving download() untouched
for every other caller.

[TAURI:WARN] is a marker level install.sh has never emitted, and the app forwards
unknown markers to its progress UI verbatim (install.rs:639), so a digest
mismatch would have surfaced as raw text in the desktop window. Make it a verbose
only stderr line: the next mirror or the fallback still runs, so a default
install has nothing to say here.

Printed-string diff against the merge base is back to additions inside $(...)
capture plus that one verbose-gated line, with nothing removed or changed.

* Ask the installed uv whether it runs before skipping the fallback

The libc gate reads a glibc version from ldd or getconf and treats that as proof
a GNU binary will execute. It is not. A stripped NixOS-derived image without
nix-ld reports a glibc version through getconf while its loader lives in the Nix
store, so the pinned x86_64 uv asks for /lib64/ld-linux-x86-64.so.2 and gets
nothing. Every static check passed, so the helper reported success, the astral
fallback was skipped, and the first real uv call failed with No such file or
directory. astral's installer fails its own glibc probe on that host and ships
the fully static musl archive, which runs. The user went from a working uv to
none.

The archive is digest-verified astral uv by the time it is placed, so ask it:
run --version and require it to succeed. One exec closes the whole class rather
than this one host, covering a wrong triple, a loader that is not where the
binary looks, and a destination we could not really write.

A test drives an archive whose uv cannot execute and requires the helper to
decline; removing the exec check fails it.

* Pair every clear with its twin, not just the previous one

install.ps1 clears after each recovered step, so a lagging reader can be several
clears behind when a block lands. With clears A then B on one stream and A's twin
arriving on the other after the verdict, asking only whether this is the message
just seen answers no, and the delayed twin discarded the verdict the guidance
exists to explain.

Each logical clear emits exactly two markers, so count unpaired ones by message:
the first sighting is the clear, the next pairs with it. A test drives the A, B,
verdict, A', B' ordering; 40 install tests pass.

* Validate the staged uv before it replaces a working one

My own exec check was on the wrong side of the rename. The sequence that bites:
a host has a uv good enough for UV_OFFLINE_MIN_VERSION but below UV_MIN_VERSION,
so the block runs with _uv_present_before true; the pinned path renames over that
working binary; the --version check then fails because the loader is missing or
the destination is mounted noexec; the fallback download also fails. The
installer neither restores the old uv nor reports that none is available, and
every later command runs the broken one.

Test the staging file instead, before the rename. It sits on the destination
filesystem, so it answers the noexec question too, and a binary that cannot run
here never gets to replace one that could.

Two tests: a working incumbent uv survives an archive whose uv cannot execute,
and the rejected staging file is cleaned up. Moving the check back after the
rename fails the first.

* Stop the AMSI guidance claiming more than it knows

Two of these are honesty defects in text a blocked user reads.

"nothing was changed on this machine" is false. Rust starts a diagnostics
attempt and its phase log before PowerShell is ever spawned, and spawn_script can
create ~/.unsloth first, so a pre-start block has already written to disk. The
honest claim is that no installation step ran.

"This is a false positive" is not something the classifier can know. It proves
the output carries a PowerShell error id and nothing about the script's
integrity, and install.ps1 can sit in a user-writable directory, so a locally
modified copy can earn a genuine verdict. Telling someone to report a correct
detection to their vendor is worse than telling them to reinstall from an
official package first and only escalate if an unmodified copy is still blocked.

Two smaller ones from the same pass. The matcher tested for the field name and
the id independently, so a line naming both in prose qualified; it now requires
the id to follow the colon and end at a comma or whitespace, which is what the
comment always claimed. And the clear-pairing map is bounded: legitimate
producers use a small fixed label set, and child output must not be able to grow
it without limit.

42 install tests pass.

* Honour astral's download override, and stop Unblock-File asking

Three from the second audit round.

Unblock-File declares SupportsShouldProcess at the default Medium impact, so a
profile that sets $ConfirmPreference to Medium or Low gets a prompt from the line
I added, even for a launcher that never carried the stream. -ErrorAction does not
suppress a ShouldProcess prompt, and a noninteractive host turns it into an error
that skips shortcut setup entirely. -Confirm:$false.

UV_DOWNLOAD_URL and its older alias INSTALLER_DOWNLOAD_URL outrank the mirror
variables in astral's installer, and the merge-base path inherited that because it
ran astral's script. All four implementations now honour them first and
exclusively. My earlier comment argued they point at a version the pin would
reject, but that reasoning had it backwards: a host sets one because it cannot
reach the public endpoints, so ignoring it meant public egress first and, with no
timeout on the download, a hang instead of a fallback. The pin still applies, so a
source serving a different build fails the digest and the caller falls back to
astral's installer, which honours the same variable.

chmod 0755 on the staging file rather than +x. cp gives it the umask default and
+x then adds execute only where the umask allowed read, so a umask of 077 left uv
unusable for every other account on a shared machine. astral ships them 0755.

Four checks pin the override precedence across all four installers and the mode
across both shell ones, with the behaviour verified against a stubbed downloader.

* Validate uv before it replaces an incumbent on Windows, and bound the probe

install.ps1 and studio/setup.ps1 copied the extracted uv.exe straight over the
destination and only asked whether it ran afterwards. A host with a working older
uv and a policy (AppLocker, WDAC, endpoint protection) that refuses the new one
was left with neither. Run the extracted binary where it landed first, then keep
a copy of the incumbent across the publish and restore it if the published copy
will not run, since Windows has no atomic replace for a file that may be open.

The probe itself is bounded: Start-Process with a 20s WaitForExit and redirected
streams, and on POSIX no stdin plus a 20s ceiling where timeout exists. A binary
this installer just downloaded must not be able to hang an unattended install by
prompting or by never exiting.

install.sh and studio/setup.sh also published the pinned uvx after rejecting the
pinned uv, leaving a pairing that is never built or tested. A uv that fails to
stage, copy or run now abandons the whole placement.

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

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

* Verify each uv mirror, and persist PATH when the account has no rc file

Both PowerShell installers checked the archive digest once, after the download
loop had already broken out. A captive portal or a proxy answering 200 with its
own body is a successful download by every measure Invoke-WebRequest has, so the
first mirror consumed the only attempt and the second, healthy one was never
tried. The digest now decides whether a mirror counts as served.

install.sh picked a shell profile from .zshrc, .bashrc or .profile and did
nothing when none existed. A fresh account has none: astral's installer used to
create its own PATH setup there, the pinned path does not, so the next terminal
resolved neither unsloth nor uv. Fall back to creating ~/.profile, which every
POSIX login shell reads. The existing content guard keeps it written once.

* Remove the install.sh a Windows upgrade would otherwise keep forever

Windows bundles now carry only install.ps1, but NSIS writes the current resource
manifest and deletes nothing, and the uninstaller deletes only what is in that
manifest. An in-place upgrade from a release that bundled both installers left
install.sh in $INSTDIR permanently, which also made the non-recursive
RMDir "$INSTDIR" fail at uninstall. The pre-install and pre-uninstall hooks now
delete it, so the population most likely to upgrade actually gets the split.

Also silence the speculative mktemp -d in the pinned uv path: its failure falls
back to astral's installer, so an unusable TMPDIR printed a line the user could
not act on and that the merge base did not print.

* Remove the pinned uv temporaries when an install is interrupted

The pinned path unpacks a 40 MB archive into a work directory and stages the
binary next to the destination, but only cleaned both up when the helper returned
normally. A Ctrl-C in between left the archive behind and left a staging file
inside a directory that is on PATH. Both paths are now published to the exit and
signal traps as they are created and cleared when the helper releases them.

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

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

* Persist the PATH the way each shell actually reads it, and fail a half-published pair

Four follow-ups from review:

A uvx that the archive carried but that could not be staged or renamed left uv
published next to a stale or missing uvx and still reported success, skipping the
fallback that would have installed both. Either half failing now fails the
placement, in install.sh and studio/setup.sh.

studio/setup.sh had none of the interrupt cleanup install.sh gained: a Ctrl-C
left the unpacked archive behind and a staging file inside a directory on PATH.
It now owns HUP, INT, TERM and EXIT for the duration of the pinned install and
hands them back on the way out.

fish sources none of the POSIX rc files, so the ~/.profile fallback was a no-op
for a fish user. The persistence helper writes a conf.d drop-in with
fish_add_path there, and honours ZDOTDIR for zsh.

UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME can put uv
somewhere other than ~/.local/bin, and astral's installer wrote a PATH line for
whichever it picked. The pinned path now persists its own destination too, with
UV_NO_MODIFY_PATH honoured as astral honours it.

* Make the Windows uv publish a real transaction, and quote persisted paths

The companion copies ran bare: under install.ps1's Stop preference a locked or
ACL-denied destination threw past the rollback and left a mismatched set with the
backups still on disk, and under setup.ps1's Continue preference it kept a stale
companion and reported success. Both now copy under -ErrorAction Stop inside the
transaction, so any failure unwinds like the others.

A failed restore also used to delete the backup anyway, which is the one path in
this block that could leave the host with less than it started with: the two
things that make a restore fail, an open incumbent and a denied ACL, are the same
two that made the replace risky. The backup is now kept and named.

fish takes an unquoted path with a space as two directories, neither of which
exists, so the drop-in single-quotes it; and the rc line is written inside double
quotes, so a uv directory holding a dollar or a backtick is escaped. The second
test caught a doubled backslash in the escaper itself.

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

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

* Tighten the comments added by this branch

Comments only, no code touched: 161 comment lines become 106 across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1 and the NSIS hooks. Each one keeps
the reason it was written for, said once.

Verified with the PowerShell AST parser, sh -n and bash -n, the 50-check uv
pinned release suite and 114 installer tests, and by confirming the diff contains
no non-comment line.

* Tighten the install.rs comments too

Comments only: 35 lines become 27, each keeping the reason it was written for.
42 install tests pass and the diff contains no non-comment line.

* Abort on a companion that cannot be backed up, and pair clears by stream

A uvx.exe that could not be copied aside, because it is locked or its ACL denies
reads, was skipped and the new uv.exe published anyway, so the function reported
success with a mismatched pair and the fallback never ran. Any backup failure now
fails the placement and runs the rollback, in install.ps1 and studio/setup.ps1.

The ERROR_CLEAR pairing keyed only on the message, so two real clears of one label
on one stream were taken for a clear and its twin. That happens:
_install_torch_default_index emits its recovery during the install and again
during the ROCm repair. A verdict landing between them was then erased by the
genuinely later clear arriving on the other stream. The map is keyed by stream as
well, so only the opposite stream's copy can consume a pending marker.

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

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

* Do not fail an install because the uv probe could not get an answer

Three clean-machine CI legs that pass on main failed on this branch: arm64 and
two Windows containers, all three with winget unavailable, which is the only
condition under which the pinned fallback runs. Each downloaded the right asset,
passed the digest, and then failed the probe. Start-Process -NoNewWindow with
redirected streams does not behave in a container or on the arm64 image the way
it does in a desktop session, and a boolean probe reported that as a broken
binary and aborted the install.

The probe is now tri-state. Only the binary answering non-zero is a failure. A
launch that throws or a wait that times out is inconclusive, and since the digest
already proved the bytes are astral's pinned release, an inconclusive probe
publishes as the pre-pin code did. Every path prints why, with the captured
stderr and the exit code, so the next occurrence is not opaque.

Also from review: the POSIX path now stages both binaries and publishes them
together with the incumbents saved aside, so a failed uvx rename restores the
uv it replaced instead of leaving a new uv beside a stale uvx; the Windows
rollback records the destination before the copy that can truncate it; and
UV_UNMANAGED_INSTALL suppresses the profile write, as it does for astral.

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

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

* Give setup.sh the same pair publish and PATH persistence as install.sh

studio/setup.sh published uv and then uvx one after the other, so a failed uvx
rename left a new uv beside the host's stale one, and the remote fallback can be
unavailable. It now stages both, validates uv, and publishes the two renames back
to back with the incumbents saved aside, restoring them if the second fails.

setup.sh is also run directly for local and Colab setup, where astral's installer
used to write the profile line for whichever destination it chose. Without one the
PATH export died with that shell and every later run reinstalled uv. It now
persists its own destination, with fish handled on its own terms and both of
astral's opt-outs honoured.

* Treat an empty uv exit code as no verdict, not as a failure

The arm64 clean-machine leg still failed on the tri-state probe, and the
diagnostic that came with it said why: "uv --version exited ." with no number.
WaitForExit(ms) can return before the exit code is cached, so ExitCode was empty
and an empty value is not 0, which read a working uv as broken.

The parameterless WaitForExit settles it and returns at once because the process
has already exited, and a code that is still missing is inconclusive rather than
a failure, which is the same rule the launch and timeout paths already follow.
Verified against pwsh that a real non-zero exit and a real launch failure still
classify as failed and unknown respectively.

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

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

* Eight review fixes across the uv publish and PATH persistence

The fish escaper in studio/setup.sh reached sed as an invalid expression, so a
fish user running setup directly would have had setup killed under set -e right
after uv was published. It now matches the one in install.sh, and the test runs
both escapers rather than reading them.

An incumbent that cannot be hard-linked or copied cannot be restored either, so
publishing over it would be a one-way move. Both shells now decline. Writing that
test turned up that my own rollback deleted both incumbents when nothing had been
published, since the no-predecessor branch cannot tell the two cases apart; the
rollback is now reached only after a publish was attempted.

A rollback with no predecessor removes the binary it published, rather than
leaving half a pair the host never had.

A signal between the two renames left the undo copy as the only reference to the
incumbent, and the handler deleted it. It restores it now, in both shells.

setup.sh prepended ~/.local/bin unconditionally after a successful pinned
install, so a stale uv there could shadow a custom UV_INSTALL_DIR destination and
the rest of setup would run the wrong one. That prepend is now only for astral's
installer, which is what writes there.

PATH entries are compared literally rather than as case patterns, so a
destination holding *, ? or [ is not mistaken for an unrelated entry.

On Windows, a .unsloth-old left behind by a failed restore is the only copy of a
working uv, and the next run reused that exact name. It takes a distinct one.

* Keep the pinned uv first on PATH, and only count an active profile entry

install.sh prepends ~/.local/bin after the uv bootstrap, and astral's env file
does too, so a custom UV_INSTALL_DIR destination was pushed behind a stale uv
sitting in the home directory and every bare uv below picked the wrong one. The
pinned destination goes back in front. setup.sh had the same shape and was fixed
in defd2292a.

The profile check treated any occurrence of the destination text as proof the
PATH entry was already there, so a commented-out old export, or /opt/uv-old when
the destination is /opt/uv, suppressed the write and left the next shell without
uv. Comments are stripped and the directory has to appear as a whole entry.

* Gate the NSIS tidy-up, and remove an orphan uv on signal

The pre-install hook runs before the user can still cancel, and $INSTDIR can be a
directory they picked in the GUI, so deleting install.sh there could take a file
that was never ours. Both hooks now only act where our own executable already is.

A signal between the two renames restored a predecessor but did nothing when
there was none, leaving a 0.12.1 uv beside whatever uvx the machine had. It now
removes what it published, which is what the ordinary rollback already does.

* Write the uv PATH entry to every startup file astral's installer wired

astral's uv installer wires ~/.profile, each of .bashrc, .bash_profile and
.bash_login that exists, .zshrc or .zshenv under ZDOTDIR, and a fish drop-in
under ~/.config. Replacing that installer with a pinned archive meant the PATH
entry only reached the one file for whichever shell happened to be running, so a
bash user whose .bash_profile does not source .bashrc, a /bin/sh login, or anyone
who later switched shells would have no uv on PATH where they used to.

Both POSIX installers now write the same set, once each, with the existing
whole-entry check keeping a re-run idempotent. Files that do not exist are not
created, apart from ~/.profile, which astral creates too.

* Cut the uv publish back to what the common case needs

The rollback machinery that grew over the review rounds covered cases a user is
very unlikely to meet: an incumbent that cannot be hard-linked, a signal landing
between two renames, a restore that itself fails, a second installer racing the
first. It was 281 net lines, and every finding in the last two rounds was in it
rather than in the hardening.

What stays is what the common case needs. POSIX stages both binaries, runs the
staged uv, and publishes the pair with two renames; a failure anywhere before
them leaves the destination untouched, and the caller falls back to astral's
installer exactly as before. Windows probes the extracted uv.exe before touching
the destination, then copies the three under -ErrorAction Stop and re-checks the
digest at the destination.

The staging files are still removed on a signal, since they live in a directory
that is on PATH. 64 shell checks and 114 installer tests cover the rest.

* Match the exact fish entry, and let a UNC launcher load

The fish drop-in is the only thing that puts uv on a fish user's PATH, since fish
reads none of the POSIX files, and its check treated any occurrence of the
directory as proof: /opt/uv-old suppressed /opt/uv. It now matches the exact
fish_add_path line it would write.

A launcher on a UNC share is a remote script to PowerShell, and RemoteSigned
refuses an unsigned one, so a roaming profile got a shortcut that exits without
starting Studio. That case, and only that case, uses Bypass, and drops
-WindowStyle Hidden with it so the pair the detections key on never appears.

* Wire every startup file on a DEFAULT install too, and give setup.ps1 a fallback

The all-profile PATH write was gated on the uv destination differing from
~/.local/bin, which is exactly where a normal install puts it, so every ordinary
machine still got the single-file write the shim path has always done. Three
independent audits found this. The gate is gone, and the idempotency check now
also matches the $HOME-relative spelling the shim block writes, so the default
case does not end up with two lines for one directory.

studio/setup.ps1 replaced astral's installer with the pinned archive and had
nothing to fall back to. A failed pinned install therefore left UseUv false and
silently ran torch, bitsandbytes, Triton and the rest through pip: a different
resolver, not just a different download. winget is the fallback, as install.ps1
already does, rather than the remote script this branch exists to remove.

* Read the pinned install's real result, and three narrower publish guards

The winget fallback I added last round read Invoke-SetupCommand's return value,
which is [int]$LASTEXITCODE rather than the function's $true, so it fired on
every run: a redundant managed install, and a second copy on a machine that asked
for UV_UNMANAGED_INSTALL. The function records its own success on the script
scope and the fallback reads that.

A directory named uv at the destination looked like a published binary: mv moves
into it and reports success, and a searchable directory passes -x, so the install
reported success and the first later uv call failed instead. Both shells refuse a
directory target, as the installer already does for its own shim.

The PATH idempotency pattern escaped only part of the ERE metacharacter set, so a
destination holding + ( or | did not match itself and every reinstall appended
another block to every profile.

* Restrict the profile duplicate check to PATH lines, and cover mapped drives for PR #8586

* Match only PATH-setting lines in the profile duplicate check for PR #8586

* Tighten the installer comments added by PR #8586

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-13 07:02:18 -07:00
Daniel Han
d5a2160ef8
Say ROCm does not cover RDNA 1 instead of advising a fix that cannot work (#8577)
* Say ROCm does not cover RDNA 1 instead of advising an impossible fix

An RX 5700 XT (Navi 10, gfx1010, RDNA 1) correctly lands on CPU PyTorch:
AMD publishes Windows torch indexes for gfx103X, gfx110X, gfx1150, gfx1151
and gfx120X, and there is no gfx101X index. Because the name-inference table
covers only arches that have wheels, the arch stayed null and the installer
fell into the "arch unknown" arm, which tells the user to install the HIP SDK
or set UNSLOTH_ROCM_GFX_ARCH. Neither can work: UNSLOTH_ROCM_GFX_ARCH=gfx1010
lands on the unmapped-arch path and returns CPU anyway.

Add a separate name lookup for AMD generations ROCm PyTorch does not cover,
read only to word the report. Product names come from LLVM's AMDGPU GFX10.1
processor table. The lookup never sets the arch the installers route on, so
CPU fallback is reached by exactly the same path as before.

Mirrored across install.ps1, studio/setup.ps1, install.sh, studio/setup.sh
and studio/install_python_stack.py so every install path agrees.

* Point pre-RDNA 2 AMD users at the Vulkan llama.cpp path

The previous commit stopped at "ROCm does not cover this GPU", which is true
and still a dead end. There is a working path: llama.cpp's Vulkan bundle drives
these cards, which is how #8458's reporter got an RX 580 running and how LM
Studio drives the same hardware.

Nothing routes these users there automatically. _should_auto_vulkan_for_amd_windows
opens with `active = _active_rocm_gfx_target(host); if not active: return False`,
and a pre-RDNA 2 card resolves to no gfx target at all, so the Windows auto-Vulkan
fallback structurally cannot fire for exactly the cards that need it. The
environment variable is their only route, so the message now names it.

Two things about that advice are load-bearing and both are tested:

- The current spelling, UNSLOTH_LLAMA_CPP_BACKEND=vulkan. The legacy
  UNSLOTH_FORCE_VULKAN still works but force_vulkan_requested() resolves the new
  variable first and consults the legacy one only when the new one is absent or
  unparseable, deliberately, so =hip stays a real opt-out that a stale legacy
  variable cannot overrule. New text must not spread the legacy name.
- WHEN to set it. The variable picks the llama.cpp bundle at install/download
  time; nothing reads it at runtime to choose a binary. #8458's reporter set it
  after installing, saw no change, and only a clean reinstall worked. Advice that
  names the variable without naming the moment is worse than none.

Also adds Polaris 10/20/30 (RX 470/480/570/580/590, gfx803) to the
messaging-only table so #8458's card gets the right message. gfx803 stays out of
_GFX_TO_AMD_INDEX_ARCH and every supported-arch table, and routing is untouched;
a test pins that directly. Polaris 11/12 (RX 460/550/560) is left out because
gfx803 vs gfx804 could not be confirmed for that die, and this table is only
worth having while it never guesses.

"RX 570" is a prefix of "RX 5700" and "RX 550" of "RX 5500". Python and
PowerShell carry (?!0) lookahead guards; POSIX `case` has no lookahead, so in
install.sh and studio/setup.sh correctness rests on arm order and the RDNA 1
arms come first. That order is now documented and asserted, and the shipped
arms are evaluated in a real shell rather than checked by eye.

README leads with the current spelling, since the installer now names a variable
and the README is where users check it.

Finally, test_cpu_index_note_respects_explicit_pin asserted a pin check appeared
within a character window before a note. That is a budget on intervening source,
not the ordering property it is for, and this work had already pushed it from
400 to 1400. It now walks the enclosing if/elif chain by indentation, so it
tests order and has no distance left to re-tune.

* Stop the HIP SDK arm outranking the pre-RDNA 2 message, and fix its advice

An RDNA 1 or Polaris user who already installed the HIP SDK never saw the new
message: the $HipSdkInstalled arm sits earlier in the chain and told them the
ROCm compute driver was missing, which is the impossible remediation this change
exists to remove, and those users installed the SDK because the old advice said
to. Guard that arm, plus the matching CPU-hint arm in install.ps1, on the
unsupported arch.

The Vulkan setter was printed as UNSLOTH_LLAMA_CPP_BACKEND=vulkan by the two
PowerShell installers and by the Windows-only branch in install_python_stack.py.
PowerShell parses that as a command name, so a user who pastes it sets nothing
and the next install picks the same CPU bundle. Print
$env:UNSLOTH_LLAMA_CPP_BACKEND = "vulkan" there, as the README already does.

The arms also said PyTorch training runs on CPU on these GPUs. It does not: with
no CUDA or XPU accelerator, unsloth raises NotImplementedError at import, which
is why studio/setup.sh already tells its other CPU-torch hosts that training and
GPU inference are unavailable. Say the same thing here.

README: Vega 20 (Radeon VII, MI50, gfx906) is older than RDNA 2 and does have a
ROCm PyTorch path (install.sh routes it to rocm6.3), so name Polaris and RDNA 1
instead of every pre-RDNA 2 AMD GPU.

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

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

* Guard the README gfx906 carve-out against any spelling of the cutoff

The ban was on one exact literal, so "every AMD GPU older than RDNA 2"
passed while contradicting the Vega 20 carve-out two sentences later.
Match the phrase family instead, and assert the group is named by its
members (Polaris, RDNA 1) rather than by a generation cutoff.

* Tighten the comments added by this PR

Comments only, no code or user-facing string touched. Every reason a
guard exists is kept, just said in fewer lines.

* Keep the note on why the rationale sits above the arm

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

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

* Studio: initialise the unsupported-arch state outside the AMD detection block

The ROCm summary reads $script:ROCmUnsupportedGfxArch unconditionally, but the
assignment sat inside `if (-not $HasNvidiaSmi)`, so an NVIDIA host never
defined it and a caller's Set-StrictMode turned the summary into an aborting
undefined-variable error. ROCmGfxArch beside it was always initialised at top
level; this one was not.

The guard asks the PowerShell parser whether any assignment is unnested rather
than comparing line numbers: the file has three -not $HasNvidiaSmi blocks, so
an ordering check picks the wrong one and passes for the wrong reason.

* Name the remaining RDNA 1 boards, diagnose the KFD-only host, and keep mixed AMD hosts on the arch-unknown advice

Adds the Navi 10 / Navi 14 professional boards LLVM's processor table omits
(Radeon Pro W5700/W5700X -> gfx1010, Pro W5500/W5500M/W5300M and RX 5300/5300M
-> gfx1012) to all five copies of the unsupported-name table. Each mapping comes
from libdrm data/amdgpu.ids read against pci.ids and the kernel amdgpu PCI table,
not from a guess; the tables still route nothing.

studio/setup.sh's KFD sysfs fallback detects the GPU without rocminfo or amd-smi,
so it left the marketing name empty and the report fell through to a plain
AMD ROCm line on a host with no ROCm. It now reads lspci for that report only,
never writing it back into the name the supported table and --rocm-gfx key on.

install.ps1's WMI fallback classifies adapter 0 only, so a host pairing an
RX 5700 with an RX 7900 was told nothing could enable ROCm, which is false there.
The verdict is now withheld when another adapter is covered, leaving the
arch-unknown advice that does apply. install_python_stack.py and studio/setup.ps1
already scored every adapter.

* Scope the uncovered-arch verdict to the card it names, and name the boards it was missing

A host is not one GPU. On a box pairing an uncovered card with one that has
wheels -- an RX 580 beside an RX 7900 XTX, or beside an Instinct MI210 --
"setting UNSLOTH_ROCM_GFX_ARCH will not enable ROCm PyTorch" was false:
masking to the other card and pinning its arch installs them, and install.sh
routes exactly that host to gfx110X-all a few lines earlier. Every advice site
now says what is true of the card it just named and claims nothing beyond it.

Deciding it at runtime was tried and dropped. Reading "an AMD adapter neither
table names" as a working peer misfires on the Vega-class iGPU (Raven through
Cezanne, Mendocino) that sits beside the dGPU on most Ryzen desktops and has no
ROCm torch path of its own, which would trade a correct dead stop for the
open-ended errand this change exists to remove. Reading only the supported
table misses the Instinct and V620 parts that are routable and appear in no
name table at all. Neither rule is right often enough to speak for a host.

Four real boards are added to all five copies of the message-only table:
Radeon Pro 5700 / 5700 XT (pci.ids 7319 and 731b, Navi 10, gfx1010) are the
only Navi 10 retail parts whose name carries neither "RX 5700" nor a W prefix,
and Radeon Pro WX 7100 / WX 5100 (Ellesmere, gfx803) carry no RX number at all,
so both fell through to the generic advice. Provenance from pci.ids as before,
and the shell case arms are matched case-sensitively, which is now stated where
only the arm ordering was.

Tests:

- The absolute host-wide phrasings are banned from all five sources, with the
  scoped replacements required per site so deleting the sentence cannot pass.
- test_unsupported_arch_routing_guards_8529.py drives the real index resolvers
  rather than asserting table shape: install.sh's get_torch_index_url and
  _amd_arch_index_family_for_gfx under sh, the .ps1 family maps under pwsh
  (including writes after the declaration, and the -contains list), the Python
  resolvers on both platforms, the Strix per-arch reroute, and studio/setup.sh's
  report-only lookup. Positive controls throughout.
- _assert_guarded_by_pin_arm matches the pin arm exactly rather than by prefix,
  binds the message's own enclosing arm, requires the pin arm to still say its
  note, ignores comment lines, and detects inline and re-indented chain closes.

Mutation tested: 13 mutants covering a post-declaration map write in each .ps1,
a single-quoted arch in _rocmWheelArches, a bypass inside get_torch_index_url,
the Strix arm, feeding the unsupported lookup into _setup_gfx directly and one
hop later, an inline-closed pin chain, a re-indented fi, and removing each new
table row. All killed.

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

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

* Say whose wheels are missing: AMD ships RDNA 1 PyTorch, Unsloth does not install it

"No ROCm PyTorch wheels exist for that arch" is no longer true of RDNA 1. AMD's
TheRock lists device-gfx1010, device-gfx1011 and device-gfx1012 as installable
torch extras on its multi-arch index, and SUPPORTED_GPUS.md marks all three
Build Passing, Sanity Tested and Release Ready. gfx803 is absent from that table
entirely, and no GCN4 family appears at all, so the Polaris half stands.

The claim these installers can honestly make is about their own routing, not
about ROCm at large: repo.amd.com publishes gfx103X/110X/1150/1151/120X and
nothing for gfx101X or gfx80X, so UNSLOTH_ROCM_GFX_ARCH=gfx1010 still lands on
the unmapped path and still returns CPU. Every site now says Unsloth has no
wheels for the arch rather than that none exist, and the tables carry a note
saying why the wording is scoped. Routing, the CPU fallback and the Vulkan
advice are all unchanged.

Verified by running the merge base and this branch side by side under identical
stubbed hardware, and diffing:

- POSIX shell, 127 simulated hosts x 2 blocks = 254 rows, each run in both
  trees. Selected torch index URL differed in 0 rows, exit code in 0 rows,
  get_torch_index_url stdout in 0 rows. The 32 rows whose end-of-run summary
  text moved are all AMD arch-unknown hosts resolving to gfx1010/1011/1012/803.
  Covers linux/wsl/macos/aarch64, NVIDIA at five CUDA levels, 15 supported
  gfx arches, multi-GPU lspci mixes, every override, dash and bash, set -eu,
  and lspci absent/failing/hanging. Asserted separately that the new WARN lines
  go to stderr, so TORCH_INDEX_URL=$(get_torch_index_url) is never polluted:
  0 of 127 index rows had anything but one URL on stdout.
- PowerShell, 232 cells over 116 adapter inventories x install.ps1 and
  setup.ps1. Resolved arch, index URL, arch family, routing flag and the
  gfx handoff are identical in every cell. 80 cells changed text, all RDNA 1
  or Polaris. An RDNA 1 card beside an RX 7900 keeps the old wording and still
  resolves gfx1100, which is the covered-peer guard doing its job. Under
  Set-StrictMode the new code passes only because of the variable
  initialisation added outside the detection block; removing it fails.
- Python stack, 23034 value comparisons over 5 platforms x 4 GPU classes x 38
  arch inputs x 99 adapter names x masks x overrides x 6 mirror configs.
  0 value differences. No unsupported arch produced an AMD index URL by any
  path in either tree, and no supported arch changed URL. A negative control
  that adds gfx1010 to the routing map reports 83 differences, so the zero is
  real.
- Existing installs: a manifest written by the old code verifies identically
  under the new code and vice versa, over 12 write/read combinations; a
  manifest poisoned with gfx_arch and index-url keys changes no verdict,
  because nothing arch-shaped is persisted. The legacy UNSLOTH_FORCE_VULKAN
  resolves identically across all 90 combinations with
  UNSLOTH_LLAMA_CPP_BACKEND, including falsey values and =hip overriding a
  stale truthy legacy value.

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

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

* Declare the unsupported-arch variable where its readers can see it

$ROCmUnsupportedGfxArch was declared inside `if (-not $HasNvidiaSmi)` in
install.ps1, but the arms that read it sit outside that gate, so on an NVIDIA
host the read is of a variable that was never assigned. Its five neighbours
(HasROCm, HipSdkInstalled, ROCmGpuLabel, ROCmVersion, ROCmGfxArch) are all
declared above the gate, and studio/setup.ps1 already hoists its own copy for
exactly this reason, so this one was the odd one out.

Harmless as shipped, because Install-UnslothStudio runs with Set-StrictMode off.
Under a caller's `Set-StrictMode -Version Latest` it is a hard stop: driving the
extracted blocks under pwsh, an NVIDIA host that lands on the /cpu leaf (a
pre-CUDA-11 driver, or UNSLOTH_TORCH_INDEX_URL pinned to cpu) throws on the
read. Found by running this branch and its merge base side by side over 240
Windows cells; every resolved arch, index URL, arch family, routing flag and
gfx handoff matched, and this was the only asymmetry that was not message text.

A test pins the declaration above the gate in install.ps1 and at script scope in
setup.ps1. Moving it back inside the block fails the test.

* Let an identified uncovered card outrank the generic ROCm report, and teach export

Two review findings, both reproduced first.

amd-smi can report a GPU with no gfx token anywhere in `list` or `static --asic`
and only a market name. That sets $HasROCm with no arch, so the generic
`} elseif ($HasROCm)` arm fired and called an RX 5700 XT "AMD ROCm (AMD Radeon
RX 5700 XT)" while the wheel note in the same run said gfx1010 has none. Driving
the real detection and step chain under pwsh with a stubbed amd-smi reproduces
it exactly, and the host is not hypothetical: amd-smi is only probed when the
HIP SDK is present, which is what the #8529 and #8458 reporters installed
because the old message told them to. Both scripts now carry the same
`-and -not $ROCmUnsupportedGfxArch` guard the HIP SDK arm below already had.
A supported gfx1100 host and an unmapped Instinct MI210 host are unchanged on
the same harness, since the guard is a no-op when no arch was identified.

The POSIX advice said to `set UNSLOTH_LLAMA_CPP_BACKEND=vulkan` and re-run the
installer. A bare assignment is a shell variable, not an environment entry, so
the installer subprocess never sees it and the user gets the CPU bundle again:

    $ sh -c 'UNSLOTH_LLAMA_CPP_BACKEND=vulkan
      ./installer'      -> installer sees: [<unset>]
    $ sh -c 'export UNSLOTH_LLAMA_CPP_BACKEND=vulkan
      ./installer'      -> installer sees: [vulkan]

That is the #8458 failure mode reintroduced by the fix for it. The README block
has always used export; the three POSIX message sites now agree with it. The
PowerShell sites already used `$env:`, which is the process environment, so they
were correct and are untouched.

Tests pin both: the emitted POSIX setter is now `export ...`, and each generic
ROCm arm must carry the unsupported guard. Dropping either guard fails.

* Guard the ROCm summary chain the same way its two siblings are

The summary at studio/setup.ps1 opens with a bare `if ($HasROCm)` rather than
an `} elseif`, so it was missed when the other two chains were guarded. Its own
third arm names the uncovered card, and that arm is only reached when nothing
outranks it. On a host where amd-smi enumerates an RDNA 1 card with no gfx
token, the "ROCm x.y" arm wins and the arm written for that card never runs,
so the same run reports ROCm here and no wheels below.

The existing check only matched arms opening with `} elseif ($HasROCm`, which
is why it walked past this one. The new test finds the chain by its own body.

* Keep banning the bare POSIX setter in the PowerShell sources

Requiring `export` in the .sh advice was done by redefining _POSIX_SETTER, which
is also the needle two PowerShell bans read. With the export folded in, a .ps1 that
printed a bare UNSLOTH_LLAMA_CPP_BACKEND=vulkan no longer matched either ban: a
mutant adding exactly that line passed all 261 tests in the file. Split the two.
_POSIX_ASSIGNMENT is the bare form the Windows sources must never print, and
_POSIX_SETTER stays the exported form the POSIX ones must teach. The same mutant
now fails, and reverting either export still fails.

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

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

* Tighten the comments on the unsupported-arch path

Final pass over the comments this branch added. The five copies of the table
header each repeated the same four points at four different lengths, so they
are now one block of the same wording everywhere, and the provenance and
scoped-claim notes are folded into it rather than trailing it. Same for the
report arms and the test rationales: no reason removed, fewer lines to read.

Comments, docstrings and wrapping only. AST-checked with comment_tools.py
(4/4 code-unchanged), `bash -n` on both POSIX scripts, and the PowerShell
parser on all three .ps1 files. Suites re-run: 1036 passed, 1 skipped, and
the pwsh behavioural suite green.

* Blame the right card in the CPU summary, and stop promising macOS Vulkan

Two more review findings, both reproduced first.

The end-of-run CPU summary calls the lspci lookup unconditionally, so unlike
the arm in get_torch_index_url it is not covered by the empty-probe gate. On a
host pairing an RX 5700 with an RX 7900, a CPU fallback caused by the 7900's
ROCm being older than 6.0 was attributed to the 5700, replacing the "upgrade
ROCm" advice with advice that is false for the card that actually caused it.
Running the shipped guard under sh with a stubbed lspci, only that host moves:

    lone RX 5700       uncovered-card message   -> unchanged
    lone RX 580        uncovered-card message   -> unchanged
    lone RX 7900       generic message          -> unchanged
    lone MI210         generic message          -> unchanged
    RX 5700 + RX 7900  uncovered-card message   -> generic message

The summary now asks _infer_linux_amd_gfx_arch, which scans every display
adapter, and stays quiet when any of them is covered. Same shape as the peer
guard install.ps1 already carries.

The README's Vulkan paragraph sat under the combined "macOS, Linux, WSL"
heading. macOS has no Vulkan llama.cpp bundle: install_llama_prebuilt.py logs
that the variable is ignored and installs the Metal build, and upstream ships
no macOS Vulkan asset either (Metal is the default there, and Vulkan on macOS
only exists through MoltenVK, which you have to build yourself). An Intel Mac
carrying one of these very cards, the 16-inch MacBook Pro shipped the Radeon
Pro 5300M/5500M/5600M, would follow the command and get nothing. The paragraph
now names Linux and WSL and macOS gets its own sentence. The installer itself
needed no change: get_torch_index_url returns before any AMD probe on Darwin
and the summary is already gated on it, so the advice was never emitted there.

Both guards are pinned by tests that fail when the guard is dropped.

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

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

* Tighten the comments added in the last pass

* Guard the Studio report's peer scan, explicit index pins, and Windows ARM64

Three more review findings, all reproduced first.

studio/setup.sh had the same misattribution I fixed in install.sh last round,
on the KFD path where neither rocminfo nor amd-smi answers and the lookup falls
back to lspci. First match wins, so a host whose RX 5700 enumerates before an
RX 7900 was told no override could help, which is false there. The supported
name table is now a matcher, _setup_supported_gfx_from_name, with its arms
byte-identical to before, so the scan can ask about a peer without touching
$_setup_gfx. Same fixtures as the install.sh guard, run under sh:

    lone RX 5700 / RX 580        named          -> unchanged
    RX 5700 + RX 7900, either order   named     -> quiet
    RX 580 + RX 7900             named          -> quiet

An explicit UNSLOTH_TORCH_INDEX_URL or _FAMILY reaches the ROCm install path
for any gfx*/rocm* leaf, so "torch stays CPU-only and neither the HIP SDK nor
UNSLOTH_ROCM_GFX_ARCH changes that" was false on a pinned run. install.sh's CPU
note already skipped its guidance when pinned; install.ps1, studio/setup.ps1,
studio/setup.sh and install_python_stack.py now agree with it. install.ps1's
second site needed nothing, since it already sits behind -not $ROCmIndexUrl.

studio/setup.ps1 throws on UNSLOTH_LLAMA_CPP_BACKEND=vulkan on Windows ARM64,
where no Vulkan bundle is published, so the advice aborted the next update
instead of enabling GGUF acceleration. Both PowerShell sites now branch on
Get-HostMachineArch and point at a source build there.

The advice-window test had to change with them: the claims now sit in if/else
arms, and a fixed 8-line window either stopped mid-branch or spilled into the
next arm, which is the failure its own docstring warns about. It walks to the
end of the enclosing arm instead, capped. Both bash table-parity tests follow
the matcher's new variable names and still compare the same rows.

Each guard is pinned by a test that fails when it is dropped: the peer loop,
the pin check, and the Get-HostMachineArch call were each mutated and killed.

* Cut the comment lines that restated the message below them

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

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

* Fill the Windows peer list on the amd-smi path, and three smaller corrections

Four more review findings, all reproduced first.

install.ps1's WMI scan sat behind `if (-not $HasROCm)`. amd-smi can report GPUs
with no gfx token and only the first market name, which sets $HasROCm with no
arch, so the scan was skipped and the peer guard added last round saw an empty
list on exactly the multi-GPU host it exists for. It is now keyed on the arch,
which is the condition under which the unsupported lookup can run at all: a
host that already has an arch still does no WMI work here, and amd-smi's label
still wins when it had one. The pwsh suite asserts the gate through the AST.

studio/setup.sh returned the failure of a nonempty market name immediately, so
a generic "AMD Radeon Graphics" from rocminfo ended the lookup before the lspci
scan and the report fell back to the plain "AMD ROCm" line this change is meant
to replace. It now returns only on a hit; a name that maps still short-circuits
without touching lspci, and the peer guard still covers the mixed host.

install_python_stack.py prints the same Vulkan advice as install.ps1 on the
Windows WMI path, and the same ARM64 throw applies to it. Added
_is_windows_arm64(), mirroring Get-HostMachineArch down to the
PROCESSOR_ARCHITEW6432 case an emulated x64 Python needs.

The setup.sh pin check treated a whitespace-only value as a pin, while
get_torch_index_url trims both variables and treats a blank one as unset. It is
trimmed the same way now. The variable also collided with the XPU block's
_setup_pin, which is a global in POSIX sh, so it is renamed _setup_unsup_pin.

One existing test asserted the behaviour the second finding calls a bug, that
an unrecognised reported name claims nothing. Rewritten to the corrected
intent, keeping a case that proves a covered card is still never claimed from
lspci. test_windows_amd_gpu_scan_fallback.py extracts the WMI block by its
literal gate, so its anchor follows the new condition.

Each guard is pinned by a test that fails when it is dropped: all four were
mutated and killed.

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

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

* Cover the Python emitter in the ARM64 Vulkan-offer guard

* Fill the Studio peer list on the amd-smi path too

studio/setup.ps1 carries the same WMI scan install.ps1 does, for its own name
inference, and it had the same gate. amd-smi can report GPUs with no gfx token
and only the first market name, which sets $HasROCm with no arch, so the scan
was skipped, $script:ROCmGpuLabels held one name, $gpuNames was one entry, and
a host pairing an RX 5700 with an RX 7900 was judged entirely on the 5700 even
with HIP_VISIBLE_DEVICES=1 selecting the 7900.

The scan is now gated on the arch, which is the condition the inference block
below already runs under, so the two can no longer disagree about whether there
is anything to infer from. A host that already has an arch still does no WMI
work here, and amd-smi's label still wins when it had one: only the peer list
is new on that path.

The pwsh suite walks the AST from the $script:ROCmGpuLabels assignment to its
enclosing if and asserts the condition names the arch and not $HasROCm, as it
already does for install.ps1. Restoring the old gate fails it.

tests/test_windows_amd_gpu_scan_fallback.py extracts that block with a regex
anchored on the literal gate, so its pattern follows the new condition.

* Run the covered-peer guard before the named hit too

The guard added last round only covered setup.sh's lspci fallback, so a named
hit still walked past it. amd-smi reports one market name, the first device's,
so on a host whose RX 5700 precedes an RX 7900 the name IS the uncovered card
and the false verdict came back through the other door.

The scan now runs first, for both paths:

    lone RX 5700, named            named    -> unchanged
    lone RX 580, named             named    -> unchanged
    RX 5700 + RX 7900, named 5700  named    -> quiet
    RX 5700 + RX 7900, no name     quiet    -> unchanged

It must not become a silencer, so it applies only when lspci can answer: with
no adapter list there is no peer to find, and the single-card host this report
exists for still has to be told. A test drives the lookup with lspci off PATH
and requires the verdict to survive.

Putting the named hit back in front fails the two mixed-host cases while that
no-lspci control still passes, so the ordering is what is pinned, not just the
presence of the guard.

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

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

* Tighten the comments added since the last pass

* Ask CIM for the adapter list, not the cmdlet PowerShell 7 dropped

install.ps1:3331 was the only live Get-WmiObject call left in the file; every
other WMI query in it already asks Get-CimInstance, and the script handles
PSEdition Core explicitly. Get-WmiObject was superseded by Get-CimInstance in
PowerShell 3.0 and removed outright in PowerShell 7, so on pwsh the call threw,
the block's own catch swallowed it, and $wmiAmdNames came back empty. That is
the peer list the guard added two rounds ago reads, so on pwsh the guard could
never fire and the amd-smi path went back to blaming the uncovered card.

Same class, same properties, and the CIM cmdlets ship with 5.1 as well, so the
swap costs nothing downlevel and matches what studio/setup.ps1 already does.

The harness stubbed Get-WmiObject, so it was answering for a cmdlet the
installer no longer calls. It now stubs Get-CimInstance and defines
Get-WmiObject to throw, so a revert fails loudly instead of quietly returning
nothing through that catch. Reverting the call fails four tests.

* Tighten the two comments from the last pass

---------

Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-13 05:21:12 -07:00
Leo Borcherding
5a3e9fc7a6
fix(amd): gate ROCm GPU selection and crash recovery on the build's arch coverage (#7670)
Some checks are pending
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
* fix(studio): gate ROCm GPU auto-selection on the installed prebuilt's built archs (#7624)

The free-memory rank could pick a device the installed llama.cpp binary has
no kernels for (e.g. an iGPU reporting shared system RAM outranking the
dGPU), crashing llama-server with 'device kernel image is invalid'.

- install_llama_prebuilt: record gfx_target + mapped_targets in
  UNSLOTH_PREBUILT_INFO.json for published ROCm bundles
- llama_cpp: drop devices whose gcnArchName is not in the recorded list
  before ranking; unknown coverage or unknown device arch fails open

* fix(studio): retry llama-server on remaining GPUs after a 'device kernel image is invalid' crash (#7624)

Custom-linked llama.cpp builds have no mapped_targets manifest, so the
proactive arch gate cannot see them. Catch ROCm's arch-mismatch crash on
an auto-pinned device and respawn once with that device excluded, letting
an arch-compatible sibling (e.g. the dGPU next to an unsupported iGPU)
take the load. Explicit user device picks keep their error.

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

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

* refactor: reuse existing marker/arch helpers for the GPU arch gate

- _installed_llama_gfx_archs now reads the install marker via
  llama_cpp_freshness.read_install_marker (walks up from the resolved
  binary), replacing a hand-rolled root list that missed custom studio
  homes and duplicated the freshness util
- extract _rocm_arch_by_physical_id from _amd_apu_wants_unified_memory
  and share it with the enumeration gate instead of a second inline
  gcnArchName loop

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

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

* Run the formatter over the touched files

* Studio: pin which callers of the ROCm GPU probe opt into the arch gate (#7624)

* Studio: fail the ROCm arch gate open on non-concrete mapped_targets (#7624)

mapped_targets is remote data: it comes from llama-prebuilt-manifest.json,
which the release pipeline versions independently of this code. The gate is
exact-set membership against the arch a device reports, so a token no device
can ever report matches nothing and drops EVERY GPU, silently forcing CPU.
That is worse than not gating at all, and it is reachable today from a
malformed marker ([null] and [123] both produce such a set) as well as from a
future publish recording a ROCm generic code object (gfx11-generic) or an
umbrella family label (gfx110X).

Treat an arch list that is not entirely concrete gfx tokens as unknown and
keep every device. All-or-nothing on purpose: dropping just the odd token out
of [gfx1100, gfx11-generic] would under-report what the build covers.

Also pin the compatibility contract that had no coverage: a marker corpus
(absent, empty, invalid, truncated, list-not-dict, null/string/dict/int
targets, unreadable, directory-in-place-of-file, huge list) that must resolve
to a sane arch set or None and never raise; that the new keys leave the
install fingerprint byte-identical, so existing installs do not refresh on
upgrade; that non-ROCm bundles record no archs; that the marker rewrite path
preserves the fields; and that the family-label match still records the
concrete list.

* Studio: OS x GPU-vendor matrix tests for the ROCm arch gate (#7624)

* Studio: degrade to CPU when the ROCm arch gate covers no GPU (#7624)

The gate returning an empty pool left gpu_indices None, so the launch took
the --fit on arm, the pin block never ran, and no HIP/ROCR/CUDA mask was
written. The child still enumerated every unsupported card and died exactly
as #7624 reports, and the reactive arch-crash retry could not help because
its guard needs a truthy gpu_indices. Mask the child onto the CPU instead
and say why, on auto placement on a ROCm host only.

Also:
- the arch-crash retry enumerates _detected_gpus, not gpus, matching what
  the region's own comments say to use;
- the retry withdraws GGML_CUDA_ENABLE_UNIFIED_MEMORY when it moves to a
  discrete card, but only when this launch set it, so a user value stands;
- test_single_gpu_host_has_no_retry counts probe calls instead of raising:
  the narrowing branch runs under except Exception, which swallowed the
  raising spy and left the test green with the guard removed;
- correct the justification for gating automatic placement only.

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

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

* Studio: carry the ROCm arch gate into the embed launch and the crash retry (#7624)

Two ways the gate stopped short of the process it was protecting.

The embedding llama-server probe reduced the gated device list to a bool, so
on a mixed host (unsupported iGPU plus supported dGPU) the gate passed on the
dGPU and the child still enumerated every ROCm agent -- and that HSA
enumeration is what dies on an arch the prebuilt has no kernels for. Pin the
survivors with the ROCr-layer mask instead. Inert unless the install marker
reports concrete mapped_targets AND the gate actually narrows the set, so
NVIDIA, CPU-only, Vulkan and macOS write no mask and skip the probe entirely.

The arch-crash retry masked devices out but respawned the planned argv
verbatim. --tensor-split shares are positional over the child's visible
devices (llama.cpp parses them by index, then copies the first n_devices), so
the narrowed set re-indexes the survivors and hands them the crashed set's
weights, which can overcommit a card. Drop the flag for the respawn and fall
back to llama.cpp's free-VRAM split, which is correct for any device set.

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

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

* Studio: pin the arch gate's survivors when the fit owns placement (#7624)

Two more places the gate stopped one step short of the child process.

When the gate narrows a pool without emptying it, and the model is then too
large for the planner, _select_gpus returns (None, True) and gpu_indices stays
None. No branch in the env block writes a visibility mask on that arm, so the
child enumerated the very card the gate had just dropped and died on it, and
the reactive retry could not help either since its guard needs a truthy
gpu_indices. That is the same reasoning the forced-CPU branch already spells
out, one card short of forcing CPU. Mask the survivors in, last in the chain so
a manual --tensor-split sized over the full visible count is not re-indexed
under it. The probes behind it are lazy, so an ordinary pinned launch still
pays for neither, and _arch_gate_survivors now backs the embed pin too.

A forced-CPU launch also left _gpu_offload_active at None, because the gated
probe leaves the detected list empty and the counted classifier answers None
there. routes/training_vram.py spares a server only on 'is not False', so a
server masked onto the CPU with no device visible at all was unloaded before
training even though its death frees no VRAM. Record it as False, the contract
the neighbouring comment already states.

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

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

* Studio: gate the manual-split launch and drop tensor mode on the CPU fallback (#7624)

The manual per-GPU ratio took its own env branch, which re-emits the WHOLE
visible GPU order, so the arch gate's answer was thrown away and the child got
handed the card the build has no kernels for. Manual memory mode is not an
explicit device pick: the probe already opts into the gate on 'not gpu_ids',
and the ratio was sized from the full enumeration, not chosen per device. Fold
that arm into the survivor branch: mask the survivors, and drop the ratio,
which was sized over the full visible count and cannot survive the re-indexing
that masking causes. Same trade the arch-crash retry already makes. A host the
build fully covers keeps both its ratio and its PCI order pin.

The forced-CPU branch could also be reached with --split-mode tensor still in
the argv. Manual mode admits tensor parallelism on the full torch device count,
which still counts the cards the gate dropped, so neither the <2-GPU manual
guard nor the auto planner's zero-GPU guard fires. llama.cpp's
llama_prepare_model_devices then fails the load with 'LLAMA_SPLIT_MODE_TENSOR
needs >= 1 devices' and llama-server exits 1, turning the CPU fallback into a
server that refuses to start. Strip the split flags there. Scoped to the gate:
the zero-offload arm already drops Studio's own flags, and a user --split-mode
there is deliberately overridden rather than stripped.

* Studio: finish the gate's env handoff on the CPU and narrowed arms (#7624)

The embed server's CPU arm only blanked CUDA_VISIBLE_DEVICES. HIP consults
that variable only when HIP_VISIBLE_DEVICES is unset, and clr treats an empty
value as no mask at all, so on ROCm the blank mask hid nothing and a load that
had chosen the CPU still handed the child devices and the VRAM their contexts
cost. Set the -1 sentinel at the HIP layer as well. An inherited ROCR mask is
deliberately left alone: it hides agents below HIP, so clearing it would expose
more of them to the HSA enumeration that dies on an uncovered arch.

GGML_CUDA_ENABLE_UNIFIED_MEMORY is decided against gpu_indices, which is None
on both arms the survivor mask serves, so an uncovered APU anywhere on the host
turned it on and the narrowing then handed the child only discrete cards, where
the same code calls the setting harmful. Withdraw it there, with the arch-crash
retry's ownership check, so only a value this launch set is taken back and a
deliberate user one stands.

* Studio: finish the arch-crash retry's handoff onto the narrowed set (#7624)

The respawn only handled one direction of each transition. A markerless mixed
host can crash on the discrete card and land on the APU, where the first
launch correctly left GGML_CUDA_ENABLE_UNIFIED_MEMORY unset, so the retry now
sets it as well as withdrawing it. And narrowing to a single device makes
--split-mode tensor a no-op that was still reported as active through the
tensor_parallel property, which drives the UI and the MTP tensor watchdog.

* Studio: re-run the APU RAM preflight for the arch-crash retry (#7624)

The unified-memory RAM guard runs once, against the selection the first
spawn used. On the mirror shape -- crash on the discrete card, retry on
the unified-memory sibling -- that set is discrete, so the guard never
fires, and the retry then switches GGML_CUDA_ENABLE_UNIFIED_MEMORY on for
a load that now goes to system RAM. An oversized GGUF was OOM-killed
mid-load instead of getting the actionable refusal the same host returns
when the APU is picked first. Ask the same guard again for the set we are
about to spawn on.

* Studio: leave an unmappable GPU mask alone when the arch gate pins (#7624)

* Studio: finish the proactive arch gate's handoff onto the survivors (#7624)

The gate's survivor pin dropped only --tensor-split, so a manual load that
kept tensor parallelism on the full device count ran one device under
--split-mode tensor while /status still advertised the mode and the MTP
tensor watchdog stayed armed. Drop the mode with the ratio, as the
arch-crash retry already does.

The APU RAM preflight runs with gpu_indices None on an unpinned launch, so
an uncovered APU refused a load the surviving discrete card would have
held. Re-ask it for the survivors, on the refusal branch only.

The forced-CPU mask went through the default HIP arm, which clears an
inherited ROCR mask. HIP -1 hides every device on its own, so the clear
only re-exposed agents the parent hid to the HSA enumeration that dies on
an uncovered arch. Keep the mask, matching the embedding CPU launch.

* Studio: match HIP's other kernel-image error in the arch-crash retry (#7624)

The reactive retry keyed on hipErrorInvalidImage ("device kernel image is
invalid") alone, which is the wording both #7624 and #7669 field logs
happened to show. hipErrorNoBinaryForGpu is the same arch mismatch and is
the code whose documented cause is code compiled for a different GPU
architecture, so a build that raises it fell through to the fit and
flash-attn retries and landed back on the misleading GGUF/memory error.

Match both wordings, case-folded: hipGetErrorString is lowercase but the
layers that reprint it are not consistent about it. Three tests, including
one that drives the whole recovery end to end on the new wording.

* Studio: pin the two arch-gate shapes an existing AMD user would notice (#7624)

Regression tests only, for the two "did this break my working setup"
questions the gate raises and nothing pinned yet.

HSA_OVERRIDE_GFX_VERSION is the long standing workaround for an arch the
ROCm stack does not build for: the user sets it, ROCr reports the spoofed
arch, and code compiled for that arch really does run on the card. The
gate reads the arch through the same device properties HIP acts on, so it
follows the override and the covered-by-spoofing card survives. Worth
pinning because the raw silicon is uncovered and only the presented arch
says otherwise.

An install written before this PR has no mapped_targets in its marker, and
the field is deliberately outside the install fingerprint, so it is never
refreshed for this reason alone. Such a host must behave exactly as it did
before: the probe keeps every device and the launch is not masked onto the
CPU. #7669's mis-pick stays there until the install is refreshed, which is
the intended trade, and the test says so rather than leaving it implied.

Also three more crash strings on top of the ones the previous commit
added: the same error raised during ggml's backend init, which prints
through a different format string, and the CUDA spelling
(cudaErrorNoKernelImageForDevice) since the retry is not ROCm-gated. And a
correction to the comment on the "--alias -ts" case in
test_a_command_without_a_split_reports_nothing_to_do: the scan is
positional, so a VALUE spelled like the flag IS removed. Nothing Studio
builds can reach it, so the limitation is now documented where it lives
rather than described as behaviour that does not happen.

* Studio: restore the Model Memory record for the arch-crash respawn (#7624)

* Studio: backfill the bundle's built-arch list when an install is reused (#7624)

write_prebuilt_metadata only runs on a real install, and mapped_targets is
deliberately outside the install fingerprint so recording it never forces an
existing install to reinstall. The two together meant the runtime arch gate
read whatever the marker happened to hold, indefinitely.

That is worst for the users this PR is for. An install made before the field
existed never gains it, so on an already-up-to-date host the gate keeps
failing open and #7624 / #7669 stay broken until an unrelated llama.cpp
release happens to trigger a real reinstall. A manifest that corrects
mapped_targets for an unchanged asset never lands either, and stale coverage
is worse than none: too narrow forces a supported GPU to CPU, too wide leaves
an unsupported one visible to crash.

sync_marker_arch_coverage follows the ggml_tree backfill exactly, for exactly
that reason, and is called from all three reuse paths beside the rocm_gfx
sync, which has the same reach. A bundle that declares no targets (CUDA,
Vulkan, CPU, source) leaves the marker alone rather than clearing it: reuse
requires the fingerprint to match, so the asset is the one the marker already
describes. It never raises, since the reuse path runs after the install is
already valid and an exception there no longer falls back to a source build.

Five tests, each confirmed by reverting the behaviour it claims.

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

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

* Studio: finish the forced-CPU launch's own bookkeeping (#7624)

Two more places the arch gate's CPU-masked launch had to reach.

holds_no_vram asks for manual mode with gpu_layers 0, so an arch-gated
launch answered False even though it is the stronger case: the gate found
the installed build has no kernels for any card here, the child was masked
with the "-1" sentinel and cannot see a device at all. It arrives through
an automatic request, which is exactly what the caller at
routes/inference.py:8206 already documents as "recovery may turn an
automatic GPU request into a zero-VRAM load". Left as it was, a server
holding no VRAM kept the CHAT claim, blocked an image or video pipeline
from coexisting, and could be unloaded mid-load by an owner it never
competed with. The flag is per-load: set where the launch records its
offload state, cleared on commit and on unload.

llama.cpp reads LLAMA_ARG_DEVICE and LLAMA_ARG_MAIN_GPU as the env
spelling of --device and --main-gpu (common/arg.cpp set_env), and neither
the chat forced-CPU launch nor the embedding CPU launch passes those
flags, so a value exported into the parent environment reaches the child.
Hiding every device while leaving that pick in place is the one
combination llama.cpp cannot serve: parse_device_list rejects a name that
no longer enumerates, so the child exits instead of running on the CPU we
just chose for it. Cleared with the "-1" sentinel and on the embedding CPU
arm. This file already treats an inherited LLAMA_ARG_SPLIT_MODE and
LLAMA_ARG_FIT as live input, so it is the same rule rather than a new one.

Nine tests, each confirmed by reverting the behaviour it claims.

* Studio: keep the gate's narrowed split load deduplicating (#7624)

When manual mode supplies a per-GPU ratio across every visible card and the
arch gate drops one of them, the launch strips --tensor-split from the argv and
leaves self._tensor_split None. The request that asked for the ratio is
unchanged, and the UI re-sends it verbatim on every Apply, so
_runtime_matches_intent compared the live None against the requested tuple and
read it as a different load every time. The result was a teardown and relaunch
of the same already-normalized server on each identical Apply, forever, on the
one host shape the gate exists for.

Record the ratio the gate dropped and accept it in the comparator as this
request normalized. The record is guarded on an actual drop rather than on
None == None, so a launch that dropped nothing still cannot excuse a live split
against a request that asks for none, and any other ratio still reloads. It is
cleared unconditionally at the top of each load, so a drop earned by one launch
cannot excuse a mismatch on the next.

Seven tests, each confirmed by reverting the behaviour it claims.

* Studio: stop the gate's state and env leaking past the launch that set them (#7624)

Three misses, all in the arch gate's own new paths.

The forced-CPU flag was only ever assigned True. load_model phase 1 kills
the old process without running the unload reset, so the value outlived
its launch: a host that gains coverage (a llama.cpp update, or just the
next model) reported a VRAM-holding server as holding none, and the GPU
arbiter left it unclaimed beside a competing workload. That is worse than
before the flag existed. It is now published on every load, and cleared in
the diffusion state block beside the sibling chat fields it already resets
for the same reason. The reset I had put in _apply_cpu_fallback_state is
gone with it: that runs only on the Vulkan CPU-fallback path, which is why
it never covered this.

Stripping --split-mode / --tensor-split from argv cannot remove
LLAMA_ARG_SPLIT_MODE / LLAMA_ARG_TENSOR_SPLIT, and the existing
tensor-to-layer reconciliation only clears the pair when an inherited mode
is present and non-layer. So an inherited ratio survived a narrowing that
re-indexes the survivors under it, and an inherited tensor mode walked
straight back into the abort the forced-CPU strip exists to prevent
("LLAMA_SPLIT_MODE_TENSOR needs >= 1 devices"). Cleared at all three gate
branches, and not gated on the argv strip having found anything, since the
env twin is the hazard on its own.

Residency is a property of the devices, and the arch-crash retry changes
them. The canonical shape crashes on the discrete card and lands on the
unified-memory APU, where the weights are host-backed after all, so the
page-lock the first launch correctly skipped is the one the user asked
for. The respawn ran unlocked and recorded _memory_mlock_applicable False,
which reads the missing lock as deliberate and dedupes away the reload
that would apply it. Recomputed through the same helpers as the first
launch so the two cannot drift.

Also collapses the device-placement env clear onto the existing
_clear_device_placement_env instead of the near-duplicate helper I added
last round.

Eleven tests, each confirmed by reverting the behaviour it claims.

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

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

* Studio: make the unmappable-mask test independent of torch being installed (#7624)

Cross-platform staging CI failed this test on all three runners, and the
cause was the test, not the code: it patches _torch_is_rocm but not the
`import torch` that guards it. _active_gpu_visibility_mask reads the ROCr
mask only inside that try, so on a runner whose dependency set omits torch
the except arm sets is_rocm False, the mask is read from
CUDA_VISIBLE_DEVICES instead, an unmappable ROCr value reads back as "no
mask", and the gate pins the ordinals the test asserts it must not.

So the assertion was exercising the CUDA branch and reporting it as the
ROCm one. A fake torch module and an explicit CUDA_VISIBLE_DEVICES clear
make it deterministic. Verified both ways: with torch importable, and
under a meta_path finder that makes `import torch` raise, which is what the
runners do and what reproduces the CI failure against the previous commit.

* Studio: keep the arch-crash retry's memory recompute additive (#7624)

The recompute swept every --mlock / --no-mmap / --load-mode off argv before
re-appending its own. That dropped hand-written user memory flags the policy
deliberately preserves, and since --mlock and --no-mmap take no value in
llama.cpp's parser, the value-consuming scan also ate the argv entry that
followed them.

Restrict it to the lock-adding direction, which is the only one where argv
carries no policy-emitted lock to take back off, and append the flag so
llama.cpp's last-wins parse gives it the final say. The reverse direction
leaves the crashed launch's lock alone; the child really does hold it, so
the record stays truthful.

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

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

* Studio: tighten the comments added by this PR (#7624)

Comments and docstrings only; no code changes.

* Studio: record the gate's tensor normalization and recheck placement-dependent state (#7624)

Four review items, all the same shape as ones already fixed here.

Record the dropped split MODE alongside the dropped ratio, and record the
ratio on the reactive retry and the forced-CPU arm as the proactive gate
already does. Without it the duplicate-load check reads a normalized server
as a different one and tears down and reloads a multi-GB model on every Apply.

Treat a forced-CPU launch as host-resident for Model Memory: the child runs
entirely from host RAM, but _weights_in_host_memory answered for the original
placement, so the page-lock was skipped and recorded as deliberate.

Require real arch evidence before forcing CPU. _get_gpu_memory turns any probe
error into an empty list, so "gated empty, ungated not" also describes a
one-shot failure of the first probe on a host the gate never filters; the
branch now re-derives the filter's own verdict instead.

* Studio: cover the tensor-mode record and the forced-CPU rechecks with tests (#7624)

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

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

* Studio: tighten the comments added by the last review round (#7624)

Comments and docstrings only; no code changes.

* Studio: stop the old-install probe test depending on the host's free RAM (#7624)

test_the_probe_keeps_every_device asserted an exact APU figure of 11152MiB,
which is the shared pool minus the host reserve. The pool is capped by the
machine's real free system RAM unless _available_system_memory_mib is stubbed,
so the expectation silently assumed the runner had more than 12176MiB free.

It passed on the Linux, macOS and Windows runners and failed inside a WSL VM,
which computed 7340->6316MiB. Stub the host RAM the way the sibling test in the
same class already does.

* Fix the arch-crash marker set and the survivor probe's torch import for PR #7670

Two follow-ups on the ROCm arch gate.

_KERNEL_IMAGE_INVALID_MARKERS only matched hipErrorInvalidImage and
hipErrorNoBinaryForGpu. clr raises hipErrorInvalidKernelFile and
hipErrorInvalidDeviceFunction from the same fat-binary load in hip_fatbin.cpp,
hip_platform.cpp propagates all of them out of the launch path, and ggml
reprints hipGetErrorString verbatim, so a build that surfaces either of the
missing two never armed the retry and was left on the misleading GGUF error.
Both NVIDIA spellings match too, since the retry is not ROCm gated.

_arch_gate_survivors called _host_torch_is_rocm before the coverage check, so a
non-ROCm host paid a cold torch import (measured 1.35s and about 636MiB peak
RSS) only to return []. Coverage is a cached marker read and a CUDA, Vulkan,
CPU or macOS bundle records no mapped_targets, so checking it first answers []
without the import. Pure reordering, same result on every host.

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

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

* Studio: tighten the comments added by the ROCm arch gate (#7624)

Comment-only pass over the files this PR touches: the multi-line blocks and
docstrings say the same things in fewer lines, with the reasoning that records a
review finding kept intact.

Preserved verbatim in shorter form: why _CONCRETE_GFX_ARCH fails open on
non-concrete tokens and why its trailing hex is load-bearing (a digits-only
pattern would reject gfx90a / gfx90c); why the crash markers cover both
hipErrorInvalidImage and hipErrorNoBinaryForGpu and why the match is case-folded;
why the -archfallback respawn restores the memory-policy snapshot; and why an
unmappable visibility mask is left alone.

Two comments were also corrected where they had drifted from the code: the
marker block still said "two spellings" after the set grew to four, and the
regex comment did not say that the trailing hex is what admits gfx90a / gfx90c.

No code changed, verified with comment_tools.py check --strip-docstrings.

* Studio: tighten the arch-gate test comments for PR #7670

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-13 03:37:28 -07:00
oobabooga
5426a78c39
Studio: switch llama.cpp backends from the UI (#8520)
* Studio: add llama.cpp backend selector

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

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

* Fix llama.cpp backend selection edge cases

* Fix llama.cpp backend selection review issues

* Fix remaining llama.cpp backend review issues

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

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

* Fix llama.cpp backend switch consistency

* Re-pair whisper after llama runtime changes

* Re-pair whisper when llama runtime identity changes

* Unify llama backend selection invariants

* Preserve llama backend fallback constraints

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

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

* Publish llama planning job state

* Handle llama frontend job transitions

* Simplify the llama.cpp backend selection contracts

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

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

* Address the open Codex findings on backend precedence and job ownership

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

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

* Drop the unused llama backend imports the hoist check rejects

The Source lint job fails on studio/install_llama_prebuilt.py: scripts/verify_import_hoist.py
reports INSTALL_KIND_BACKENDS and marker_backend as hoisted but unused.

marker_backend is genuinely dead here, so it goes. INSTALL_KIND_BACKENDS is not: the
installer is meant to share one vocabulary with the marker readers, and two tests assert
that through this module. Give it a real use instead of a bare re-export by deriving
VULKAN_INSTALL_KINDS from the map rather than spelling the same two names out again, which
also removes a mirrored definition of the kind this file warns about elsewhere.

* Treat an install marker that is not a JSON object as no marker

read_install_marker returns whatever json.loads produced, so a marker holding [] or 123
reaches callers as something without .get. Every caller assumes a mapping, and the new
backend picker adds one more: get_backend_status calls marker_backend(marker) and raises
AttributeError, so GET /api/llama/backend answers 500 and Settings > System shows a load
error for what is only a corrupt file.

This is not new on this branch (get_update_status raises the same way on main), but the
picker makes it reachable from a page users open. Guard it where the file is read, so the
update planner, the picker and crash recovery all degrade to the source-build path
together, exactly as they already do for unparseable JSON.

* Disable Apply when the environment pins the backend

The Select is disabled whenever env_backend is set, but Apply is gated only on dirtiness,
and the two are computed independently. An automatic install whose detection has since
drifted reports selection_applied false, so the row is dirty while the Select is disabled
and Apply becomes the only live control. POST /api/llama/backend then refuses it with
environment_override, which is correct, but the button should not have offered it.

Also fills in the status shapes the payload tests did not reach: every unsupported reason,
a macOS install reporting metal, and the terminal job states.

* Run the setup.ps1 exit routing under pwsh instead of matching its text

The Windows half of the fail-closed change was asserted by comparing source strings, which
cannot catch a branch that reads the same and behaves differently. Extract the routing
block the way the bash harness already does, run it under pwsh, and require the same
decision from both: identical exit code, and identical answer on whether a source build
was queued.

Covers exit codes 0/1/2/3/4/5/137 against each explicit backend and both install states,
70 pairs, and pins the two branches the picker depends on: exit 5 fails closed everywhere,
exit 2 stays the one automatic path allowed to fall back to a compile. Skipped where pwsh
is absent, like the other PowerShell tests here.

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

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

* Let a failed whisper re-pair be retried from the same selection

The llama phase runs first and records the new backend, so a retryable whisper failure
(a dropped download, an install that was busy) ends with llama.cpp on the requested
backend and dictation still hardlinked to the old runtime. Retrying that selection is
then already_selected, which skips the llama phase, and the whisper planner refused to
run without one. The reported failure was unfixable except by switching away and back.

Allow a repair-only job for that one refusal, gated on the pairing actually being stale
so an ordinary already-selected request stays a refusal instead of becoming a no-op job
that reports success. slim_pairing_is_stale exposes the comparison run_repair_phase
already makes before it does any work.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-13 02:33:20 -07:00
Daniel Han
8502cf84c2
Detect the Radeon AI PRO R9700 (gfx1201): it carries neither 9070 nor 9080, so name inference found nothing (#8573)
* Detect the Radeon AI PRO R9700 (gfx1201) in every GPU-name arch table

The name-to-gfx tables matched RDNA 4 Navi 48 on 9070|9080 only. The
workstation card is branded Radeon AI PRO R9700, which contains neither
token, so the first-match-wins table returned nothing, no arch was
inferred and the installer fell back to CPU torch. On a host with the HIP
SDK present the gcnArchName probe answers first and the gap is invisible,
which is why the R9700 reporters in #7624 and #7307 never hit it. On a
plain Windows 11 box with a single R9700 and no SDK, name inference is
the only path left and the card is simply reported as not detected.

Add R9700 to all six copies of the table (install.sh twice, install.ps1,
studio/setup.sh, studio/setup.ps1, studio/install_python_stack.py). The
token is R9700 rather than a bare 9700 so the 2002 ATI Radeon 9700 PRO
cannot pick up RDNA 4 wheels.

* Tighten the R9700 detection comments

* Run the AMD GPU-name arch table test in cross-platform CI

---------

Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-12 17:35:41 -07:00
Daniel Han
e468965b43
Pin the ROCm-on-WSL bootstrap to immutable refs (#8540)
* Pin the ROCm-on-WSL bootstrap to immutable refs

The WSL AMD bootstrap runs unattended and installs with sudo, so both pieces
of code it pulls in should be addressed by commit, not by a moving branch:

- install.sh fetched the helper from raw.githubusercontent .../unsloth/main,
  so anything landing on main became root code on affected WSL hosts. It now
  fetches a pinned commit SHA.
- the helper cloned ROCm/librocdxg at develop, then cmake/make/sudo make
  install. It now builds tag v1.2.2 and verifies the clone resolves to that
  tag's commit SHA before anything is built or installed.

v1.2.2 is what librocdxg develop points at today and the pinned helper commit
is the current main copy, so the installed result is unchanged. An explicit
UNSLOTH_LIBROCDXG_REF with no UNSLOTH_LIBROCDXG_SHA still builds whatever the
operator asked for.

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

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

* Point the helper pin at current main and forward the librocdxg pin

Two follow-ups so WSL users keep getting the current helper and the pinned
third-party source applies immediately:

- _ROCM_WSL_HELPER_REF now points at current main (a23951b), which carries the
  helper byte for byte, instead of the older commit that last touched it.
- install.sh forwards UNSLOTH_LIBROCDXG_REF/SHA to the helper, so the pin holds
  even when the fetched helper predates it, and a test keeps the forwarded pin
  equal to the helper's own defaults. A user-set ref still wins.

librocdxg v1.2.2 confirmed as the latest non-prerelease (2026-08-05) and its
tag resolves to 4955d128, which is also develop HEAD.

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

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

* Make the librocdxg pin a commit so older fetched helpers honour it

The forwarded ref was the v1.2.2 tag, which an older fetched helper clones
without any SHA check, so the curl | sh path still depended on that tag not
moving. Forward the commit itself instead: the older helper's --branch attempt
fails, its full clone follows, and its checkout resolves exactly this revision.
The helper defaults to the same commit and skips the doomed --branch attempt
when the ref is a 40 character commit.

* Forward an explicit librocdxg SHA as the ref as well

An operator who pins a branch or tag plus its expected commit was still exposed
on the piped path: a helper old enough to ignore UNSLOTH_LIBROCDXG_SHA clones
the symbolic ref and installs its head unverified. Whenever a SHA is known,
ours or theirs, forward it as the ref too, so every helper vintage resolves the
one revision that was authorised.

* Refuse a helper without the pinned-source check, and fail a lost checkout

A helper predating this change resolves the forwarded ref through
'git checkout ... || true', so if the pinned commit ever stopped existing
upstream it would fall through and build the repository's default HEAD as root.
install.sh now runs only a helper that carries the check, whatever the pin or a
user's older checkout supplies, and the pin moves to a revision that has it. In
the helper, a failed checkout with no SHA to verify against is now fatal rather
than silently building the default branch.

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

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

* Declare a helper capability contract

install.sh gates on a content marker, which only proves a string is present, not
that the guarantees behind it are. Declare them explicitly instead so the gate
can require a level rather than infer one.

* Gate on the declared contract, not on a string being present

The marker only proved LIBROCDXG_SHA appeared somewhere, so it accepted a helper
that verifies the clone but still swallows a failed ref-only checkout and builds
default HEAD. Require the helper to declare contract 2, which stands for both
guarantees, and pin a revision that declares it. A test keeps the level install.sh
requires equal to the level the helper declares.

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

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

* Tighten the comments on the ROCm-on-WSL pins

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-12 06:37:05 -07:00
Daniel Han
5714530f27
Route spoofed Strix Halo GPUs to the AMD per-gfx index (#8480)
* Route spoofed Strix Halo GPUs to the AMD per-gfx index (#7331)

HSA_OVERRIDE_GFX_VERSION=11.0.0 is the widely circulated Strix Halo
workaround, and ROCr applies it in userland, so rocminfo hands the
spoofed gfx1100 to every consumer. The installer believed it: the
correct gfx1151 inferred from the product name in /proc/cpuinfo was
discarded, the Strix reroute intersected {gfx1151, gfx1150, gfx1152}
against ["gfx1100"] and got nothing, and torch came from
download.pytorch.org/whl/rocm6.3 as 2.9.1+rocm6.3. The first real
allocation then ran gfx1100 kernels on gfx1151 silicon and segfaulted.

The runtime-visible arch still outranks the product name everywhere it
did before. The correction fires only when HSA_OVERRIDE_GFX_VERSION is
set, the probe saw exactly one device, that device's arch differs from
an inferred RDNA 3.5 APU arch, and a source the override cannot reach
agrees with the product name: KFD topology sysfs first, since amdkfd
writes gfx_target_version from the kernel's own table, then rocminfo
re-run with the variable stripped, and only if neither can answer, the
variable statically naming the arch that was reported.

A mixed Strix APU plus discrete AMD GPU host stays on today's path. Two
devices in the probe declines outright, and so does a kernel that sees a
second GPU the spoofed probe had collapsed away, so the existing
precedence test needed no change.

install.sh carries the same three helpers and the same decision, and the
tests execute both copies over the same eight host shapes rather than
grepping for them.

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

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

* Require corroboration before undoing an HSA_OVERRIDE_GFX_VERSION spoof

Three problems with the correction as it stood.

install.sh never fired. The Strix reroute passes `_gfx_all` in raw, and that
is `rocminfo | grep -oE 'gfx[1-9][0-9a-z]{2,3}'` output, which repeats the
token once per Name / ISA line. A single GPU therefore arrives as two or three
lines, the device count read 2 or 3, and the helper returned early on every
real host, #7331's own included. Count DISTINCT tokens instead. The Python
side already splits on agent headers and was unaffected, so the two paths
disagreed on exactly the host the fix exists for: `studio update` repaired the
machine and a fresh `curl | sh` install re-broke it.

The static fallback could fire when it should not. "The variable names the
arch that was reported, so assume a spoof" is indistinguishable from a host
telling the truth: a real RX 7900 XTX in a Ryzen AI Max chassis reports
gfx1100, infers gfx1151 from the CPU product name (the inference reads
/proc/cpuinfo and never looks at the GPU), and presents the identical
fingerprint the moment its owner has the override set for an unrelated reason.
Same for a correct HSA_OVERRIDE_GFX_VERSION=10.3.0 on an RDNA2 card. Both were
rerouted to Strix wheels whenever KFD sysfs was unreadable. Drop the fallback:
corroboration from the kernel or from an unspoofed re-probe is now required,
and a re-probe that still answers the probed arch is read as evidence FOR the
probe rather than as a failure to disprove. Rerouting a working machine to the
wrong wheels is worse than the bug being fixed.

The Python re-probe left the visible-device masks in place while install.sh
cleared them, so a mask pinned to the dGPU hid the second GPU whose presence
is the only thing that vetoes the correction on a mixed host. Clear all three
in both, and add the arch the variable names as a required precondition rather
than a sufficient one: ROCr can only rename an agent to the target the
variable names, so any other reading is real silicon.

Tests: the parity check now builds each side's probe input the way its own
call site does instead of handing both a pre-shaped list, which is what let
the install.sh defect through. Over the resulting matrix the previous code
diverges on 91 of 3360 shapes with install.sh correcting none of them; it is
clean now. Adds the 7900-XTX-in-a-Ryzen-AI-Max cases, executes the shell KFD
parser against a fabricated topology tree rather than grepping it, and passes
create = True where a helper is patched so a run against an older tree fails
on the assertion instead of on AttributeError.

* Say why the spoof check declined, and count arches without wc

The correction printed "Checking whether the ISA is being spoofed." and then,
on every path that decides there is no spoof, said nothing further. That is
the CORRECT outcome for a real gfx1100 card in a Ryzen AI Max chassis, and it
is the outcome a user is most likely to see, so silence reads as a failure.
Report the conclusion in both implementations, naming the source that declined
to corroborate and the arch being kept.

Also count distinct probe tokens inside the existing awk instead of piping to
`wc -l | tr -d`. wc was the one tool the change added to install.sh's
dependency set (0 pre-existing uses); this drops it along with two process
spawns per call.

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

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

* Clear a corroborated HSA_OVERRIDE_GFX_VERSION spoof before Studio launches

* Clear a contradicting HSA_OVERRIDE_GFX_VERSION at every Studio launch

install.sh unsets the corroborated spoof for the one launch it performs
itself, but that unset dies with the installer: studio/setup.sh runs
install_python_stack.py as a child, so the `unsloth studio update`
repair, the generated launch-studio.sh and a hand-typed `unsloth studio`
all still start with the variable set.

Clear it at the CLI chokepoint the exec, the Windows Popen and the
in-process paths all pass through, keyed on the install rather than on a
hardware probe: AMD's per-gfx index ships rocm_sdk_libraries_<arch>
beside single-arch wheels, so an override naming a different arch is
provably asking for kernels this install does not contain. A generic
multi-arch index brings no such distribution and is left alone, since
there the override is often the only thing making the GPU usable.

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

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

* Read the active ROCm family, clear the spoof on every launch entry point

* Pin the amd-smi re-probe parity between install.sh and the Python stack

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

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

* Keep the override when --no-torch installs no per-gfx wheels

Clearing HSA_OVERRIDE_GFX_VERSION is only sound because native per-gfx wheels
are going in on that branch. The guard checked only that the spoof had been
corroborated, not that anything was actually being installed.

--no-torch, and the Intel Mac auto-detection that sets the same SKIP_TORCH,
reach the reroute and then install no torch at all. Clearing there left the
host with the generic wheels it already had and no override, which is strictly
worse than either alone: on a spoofed Strix host the override was the only
source of usable kernels.

The block's own comment already stated the invariant (native wheels are going
in); it just did not enforce it. It does now.

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

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

* Let torch's own requirements decide whether the rocm metadata is live

The launch-time arbiter read the bare rocm meta-package's Requires-Dist to
name the family the install carries kernels for. That is authoritative only
while torch actually resolves through it.

Switching from AMD's per-gfx index to a generic pytorch.org ROCm one does not
uninstall it: the generic wheels vendor their own ROCm libraries and depend on
no meta-package, so rocm is orphaned outright, and pip has no autoremove. The
stale metadata then named the OLD family and cleared an override that the
generic wheels may be the only reason the GPU works at all, which is the
opposite of what this code is for.

torch's Requires-Dist is now the first discriminator. An unknown shape answers
no, so a tree this cannot read is left alone rather than arbitrated on a
guess. The venv fixture grows the same dependency edge, since one that never
carried it could not tell a live meta-package from an orphaned one.

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

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

* Condense comments in the HSA_OVERRIDE_GFX_VERSION spoof detection

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-12 02:02:02 -07:00
Daniel Han
e696d3328a
Windows installer: fix single-AMD-GPU detection and the unrecoverable "needs repair" loop (#8398)
* Windows installer: fix single-AMD-GPU detection and the "needs repair" loop

Two defects that met on the same host and made the installer unable to
converge on a single-Radeon Windows box (#8335).

The WMI fallback picked between the healthy adapters and the full adapter
list with a bare if-expression, which unrolls a one-element array to a
scalar. A scalar's .Count is $null under Windows PowerShell 5.1, so a host
with exactly one AMD GPU read as having none: no $script:ROCmGpuLabels, no
inferred gfx arch, "gpu none" in the hardware report, and the installed
+rocm venv judged stale against a required "cpu". Wrapped in @(), matching
the Intel scan in the same file and in install.ps1.

The stale-venv branch under $InstallerManagedSetup then aborted with
"re-run install.ps1 so it can replace the environment safely with
rollback". install.ps1 is the caller on that path and had already done
exactly that earlier in the same run, and its failure path moves the
previous environment straight back, so every attempt ended on the state it
started from and the next attempt reached the same verdict. That abort has
now been reported from four unrelated triggers. It is replaced by the
in-place torch repair the index-pin and CUDA-family changes already use,
which needs no delete: install.ps1 invokes setup through the venv's own
unsloth.exe, so python.exe is locked by the process running the script.

Also:
- Test-VenvTorchIsRocm, the AMD counterpart of Test-VenvTorchIsXpu. A
  faulted Adrenalin or HIP runtime makes `import torch` raise at the DLL
  load or hang, and the venv then read as "torch could not be imported"
  and got deleted. version.py on disk still names the wheel, so trust it
  and point at the driver.
- Invoke-BoundedPythonProbe keeps the stderr it used to drain and discard,
  in both installers, and the stale-venv message prints it. A dead driver,
  a half-written wheel and a missing torch were previously one sentence.

tests/studio/test_amd_venv_repair_loop.ps1 covers the ROCm disk read, the
probe error plumbing against a real child process in both installers, and
the source shapes. pwsh 7 answers 1 to a scalar .Count, so it cannot
reproduce the 5.1 half of #8335 and does not claim to; that leg is the
windows-latest row of cross-platform-parity-ci.

Closes #8335.

* Windows installer: correct the 5.1 diagnosis, make the test reproduce it, and
close the one path the repair loop fix made worse

Validation follow-up to the previous commit.

The stated root cause was over-general and, as written, wrong. "A scalar's
.Count is $null under Windows PowerShell 5.1" is not true: a String or an
Int32 answers 1 on 5.1, exactly as on 7, so anyone checking the claim that
way concludes there is no bug. $null comes back only for objects whose
PSObject carries no Count of its own -- [pscustomobject], which Microsoft
documents, and CimInstance, which it does not. Get-CimInstance returns the
second kind, which is what the WMI fallback assigns. Measured on
windows-latest, PowerShell 5.1.26100.33158 against a real CIM instance, with
pwsh 7.6.4 on the same runner:

  'a'                         5.1: 1      7: 1
  [pscustomobject]@{...}      5.1: $null  7: 1
  CimInstance (one instance)  5.1: $null  7: 1
  @(if (...) { ... })         5.1: 1      7: 1

The conclusion and the fix are unchanged; the reason is now the right one.

That also means the test could always have been real, rather than a source
shape apologising for pwsh. Win32_OperatingSystem returns exactly one
instance on any Windows host, which reproduces "the machine has exactly one
AMD GPU" with no AMD GPU present. The suite now adapts to its host: under 5.1
it reproduces #8335 and regression-tests the @() wrap for real, under pwsh
the unroll still runs and the consequence is reported as a shape. 93 checks,
green on both, verified on a windows-latest runner under
powershell.exe 5.1.26100.33158 and pwsh 7.6.4.

The claim that the 5.1 leg is covered by cross-platform-parity-ci was wrong:
every step in that workflow is shell: pwsh, which is PowerShell 7. A
shell: powershell step needs adding beside the pwsh one -- 5.1 is the runtime
the CLI actually launches setup with (unsloth_cli/commands/studio.py). Left
out of this commit only because the push token has no workflow scope; the
exact step is on the PR.

The in-place repair had one path it made worse than the abort it replaced. A
venv directory with no Scripts\python.exe is incomplete, not stale, and has
no interpreter to force-reinstall torch through. The abort used to catch it
by catching every stale verdict. Without it the run reaches the activation --
a dot-source, which is NON-terminating at the "Continue" the pip section runs
at -- so setup carried on with the venv unactivated, resolved every later
python and uv pip against PATH, installed the whole stack outside the
environment, and could still exit 0. Narrow to reach, since install.ps1
launches setup through that venv's own unsloth.exe, but silent when reached.
Now refused, before the activation, without deleting anything.

Also:
- The surfaced probe error no longer indexes [0] into a pipeline that a
  whitespace-only stderr empties; fatal under a caller's Set-StrictMode, and
  studio/setup.bat does not pass -NoProfile. Confirmed to throw on 5.1 too.
- $script:PinChangedForceReinstall is hoisted beside $installedTorchTag. Four
  install arms read it to decide --force-reinstall and a fresh install never
  reaches the assignment.
- Test-VenvTorchIsRocm's comment claimed AMD's Windows wheels are labelled
  +gfx1151. They are not. repo.amd.com/rocm/whl/<arch>/torch/ publishes
  torch-2.11.0+rocm7.13.0-cp312-cp312-win_amd64.whl and keeps the arch in the
  URL only -- the same filename is a different binary under gfx1151,
  gfx110X-all and gfx120X-all. download.pytorch.org publishes the
  two-component +rocm6.4, Linux only. +rocm7.13.0 is what the #8335 reporter
  ended up on, and is now a case. The +gfx arm stays as deliberate defence.

* Run the AMD repair loop tests under Windows PowerShell 5.1 as well

Every step in cross-platform-parity-ci is shell: pwsh, so the whole job runs
under PowerShell 7. 7 returns .Count = 1 for the single CimInstance that 5.1
returns $null for, which is the entire mechanism of #8335, so the existing pwsh
step passes just as happily against the unfixed code. The suite had no leg on
the shell the CLI actually launches setup.ps1 with.

Adds the same test file under shell: powershell on the Windows row only.

* Windows installer: keep the installer's GPU wheel through an in-place repair, refuse a venv with no activation script, and bound the AMD fast-path torch probe

* Windows installer: keep the installer's GPU wheel through a GPU-to-GPU rescan, not just a CPU one

* Windows installer: verify the venv activation actually took effect, not just that Activate.ps1 exists

A present Activate.ps1 is not an activated venv. The script prepends the venv to PATH in its
last statement, so a copy truncated by an interrupted or out-of-disk python -m venv runs to its
last complete statement and returns without raising anything at all, and an unparseable one is
a ParserError, which is non-terminating at the Continue the pip section runs at. Either way the
dot-source succeeds with the ambient interpreter still first on PATH, which on a real install
is the system or Microsoft Store python because install.ps1 keeps the venv Scripts directory
off PATH on purpose. Fast-Install hands exactly that to uv pip install --python, every
Exit-SetupFailure after it keys off an exit code from the wrong interpreter, and setup exits 0,
at which point install.ps1 commits over its rollback copy.

Assert the post-condition instead of adding a third existence check: the python now in effect
must live under $VenvDir. VIRTUAL_ENV would not do, since Activate.ps1 sets it before the line
that matters. Both sides are normalised through Get-Item, the same call Activate.ps1 uses to
build the PATH entry, so a short 8.3 path, a substituted drive, a junction or a differently
cased drive letter cannot read as outside, and every branch that cannot prove the interpreter
is wrong returns rather than refusing a working install.

* Windows installer: only preserve the GPU wheel install.ps1 actually selected

The guard that keeps an installer-placed GPU wheel through a rescan treated any
non-cpu wheel under UNSLOTH_INSTALL_ROLLBACK_MANAGED as the installer's own
choice. That is not true on an upgrade: install.ps1's migrated-venv arm installs
unsloth alone and never touches torch, and its flavor repair no-ops whenever its
own expected tag is cpu or unrecognised, so a legacy ~/.unsloth/studio/.venv can
hand setup a +cu118 wheel left by a previous install on different hardware.
Preserving that pinned the whole dependency pass onto a cu118 index, and on a
mapped AMD host the kept cu* tag also blocked the ROCm reroute, which needs
$CuTag -eq "cpu" -- so the Radeon never got a ROCm wheel and setup still exited 0.

install.ps1 now reports the family it settled on in UNSLOTH_INSTALLER_TORCH_TAG
and setup preserves only a wheel that matches it. Absent or blank means the
installer did not say (an older cached install.ps1, or --no-torch) and falls back
to preserving, so an older installer keeps exactly its previous behaviour. A wheel
the installer did not choose repairs in place, as it did before the guard existed;
nothing aborts.

* Windows installer: tighten the comments added across the AMD repair-loop rounds

Four review rounds each re-explained the same mechanisms, so the exit-0 hazard,
the @() unroll and the preserve guard were each written out in several places.
Keep the full reasoning at one site per mechanism and cut the restatements.
Comments and whitespace only; no executable change.

* Scope ErrorActionPreference around the corrupt-activation child process

The Windows PowerShell 5.1 leg of the parity workflow failed at the corrupt
activation script case. Every assertion passed, then the run ended with a
NativeCommandError.

5.1 wraps a native command's stderr in a NativeCommandError and applies
$ErrorActionPreference to it, so the parse error the corrupt script writes to
stderr became terminating under the Stop set at the top of the file. PowerShell
7.1 stopped applying the preference to native stderr, which is why the same
script passes under pwsh and failed only on 5.1.

Set the preference to Continue for the duration of the child invocation. The
exit code is what the case actually judges and it is still read and asserted.

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-11 12:02:13 -07:00
Daniel Han
26a91bb65d
Studio: pin the remaining unpinned requirements (#8408)
* Studio: pin the remaining unpinned requirements

Every entry in studio/backend/requirements that had no version specifier now
carries an exact pin, resolved per interpreter where the newest release
dropped an older Python.

* Align trailing comments to the existing column, note the certifi trade-off

* Fix review findings on the requirement pins

- test_torchao_select asserted the verbatim pre-split einx line, so the
  einx split broke it. Assertion updated to both new lines.
- pytorch_tokenizers is capped rather than pinned: 1.4.1 has no musllinux
  wheel and its arm64 wheel is macosx_14_0, so an exact pin pushed musl
  hosts and macOS 13 onto the one release with an sdist and a cmake build.
- sentence-transformers in no-torch-runtime.txt now matches the 5.2.0 that
  extras-no-deps.txt installs later; the higher pin could never take effect.
- Corrected the MeCab and numpy comments, and widened the certifi note to
  the rest of the TLS and HTTP request path.

* Mirror the pins into the studio extra, and bump lxml off PYSEC-2026-87

The `studio` extra claims to mirror studio.txt but kept bare requirements,
so `pip install "unsloth[studio]"` resolved pandas 3.x where the managed
installer resolves 2.3.3. The existing sync test compared normalised names
only, which is why the drift was invisible; it now also compares specifiers
and markers, and fails if either side moves alone.

lxml was pinned at 6.0.2, which pip-audit reports as PYSEC-2026-87 (fixed
in 6.1.0). Pinning is what surfaced it: a bare entry always resolved to
whatever was current, so the nightly audit never had a version to flag.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 06:27:40 -07:00
Daniel Han
510c69d35a
Studio: pair slim whisper with ROCm targets hipBLASLt has no kernels for (#8364) (#8400)
* Studio: pair slim whisper with ROCm targets hipBLASLt has no kernels for (#8364)

* Pin the on-disk catalog check the marker relaxation must not touch

Membership plus required replaced set equality on the marker's
linked_runtime_directories, but the per-directory "exists and holds a
file" check on disk is what makes a genuinely broken runtime fail
closed. Nothing covered an install whose wired catalog went empty, so a
future refactor could drop that loop and read a broken install as
current, which is exactly the failure the guard exists to prevent.

Adds the empty-catalog-on-disk case on both sides (existing_install_matches
and the sidecar launch guard) for hipblaslt and rocblas alike, plus the
missing runtime_wiring_version case: the version floor is a positive
test, not a default.

* Tighten the ROCm catalog pairing comments

---------

Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-11 05:55:40 -07:00
Daniel Han
3fc67dcad6
Linux: resolve the ROCm version from every source, not the first that answers (#8414)
* Linux: resolve ROCm version from every source, not the first that answers

install.sh took the ROCm version from the first source that responded, in the
order amd-smi, /opt/rocm/.info/version, hipconfig, dpkg rocm-core, rpm
rocm-core. Debian 13 (and Linux Mint on top of it) packages hipconfig at
5.7.31921-0 next to a 6.1.x rocminfo/HSA runtime, so detection settled on
rocm5.7, the "PyTorch ROCm wheels require ROCm 6.0+" gate fired, and a working
RX 7900 XTX got torch 2.10.0+cpu. The same install log then reported
"AMD ROCm (gfx1100) ROCm: /opt/rocm", so the installer held evidence against
its own conclusion.

Detection now reads every source and takes the highest reading. A source
reporting lower than another on the same host is stale packaging, not a
downgrade, so the highest is the one that describes the runtime the GPU uses.
The inline || chain is split into one helper per source plus a highest-wins
resolver; each helper returns 0 unconditionally, so the case where every source
is missing still reaches the actionable no-version warning instead of dying
under set -e.

The sub-6.0 warning also names a way forward. It now says the reported version
was the highest any source gave, and names UNSLOTH_TORCH_INDEX_FAMILY and
UNSLOTH_TORCH_INDEX_URL, both of which return from get_torch_index_url before
any GPU probing and are forwarded through the WSL reroute, so they are a real
override for a host with split ROCm packaging.

Tests: tests/sh/test_rocm_version_source_disagreement.sh covers the reported
Debian 13 shape, a stale reading in each source position, a genuine ROCm 5.x
host still falling back to CPU, every source missing not killing the installer
under set -e, and the unchanged tag normalisation (patch levels, 6.5+ clip,
7.3+ cap). test_rocm_support.py's set -e guard is rewritten against the new
shape and gains a per-position highest-wins check.

* Linux: stop a removed-not-purged rocm-core from choosing the ROCm wheels

Resolving the ROCm version by highest reading fixed the undershoot in #8402 and
opened the symmetric question: a source reading HIGHER than the host now picks
the wheel family instead of being shadowed by its position in the chain. Most of
that is fine and was already bounded. PyTorch's ROCm wheels vendor their own
ROCm userspace into torch/lib, so what they need from the host is the
amdgpu/KFD driver, and AMD documents driver/userspace compatibility as +/- 2
releases, widening to about a year's span from 6.4 on. The normalisation below
can only emit an index leaf PyTorch actually publishes. A host that reads high
because it genuinely carries a newer ROCm tree is therefore usually right, or at
worst harmless.

What is not bounded is a reading taken off something that is not installed at
all, and dpkg could produce one. `dpkg-query -W` lists every package in the
status database except purged ones, so a rocm-core taken out with `apt remove`
and never purged sits in state "deinstall ok config-files" and still reports the
version it had. A host that ran ROCm 7.0, went back to 6.1 and never purged
therefore offered a 7.0 reading off a package that is gone. Fourth in the old
first-answer chain it was usually shadowed; under highest-wins it beat every
live source. Detection now asks for ${Status} alongside ${Version} and takes the
version only when the status word is "installed". ${Status} is a documented
showformat field with no dpkg version floor, and dpkg-query renders an
unrecognised field as empty rather than failing, so on a dpkg that lacked it
this source would go silent rather than over-report.

The other four were audited for the same class of state and none has one. rpm
has no equivalent of config-files: `rpm -e` drops the header record, and `rpm -q`
then exits 1, which the helper already treats as no answer. amd-smi resolves
librocm-core.so and hipconfig reads its own tree's version file, so both report a
ROCm that is installed. /opt/rocm/.info/version describes whatever tree
/opt/rocm resolves to. What those three cannot do, on a host carrying several
ROCm trees, is say which runtime a process will actually load; none of them
knows, and neither does this installer.

So disagreement is now on the record. When the sources do not agree, resolution
names every reading and which one won. That is the cheap part of the answer to
the multi-version case: when a too-high pick does bite it surfaces as an HSA
error at the first HIP call, or as a GPU silently missing from torch, and
neither points back at wheel selection without the readings in the install log.
Agreeing sources stay silent. The call site stops discarding stderr, which only
ever carried this line: each source's own noise is suppressed inside the
resolver.

Tests: the shell suite gains the config-files 7.0 entry on a 6.1 host, its
installed twin at the same versions so the assertion cannot pass on the version
alone, the other non-installed dpkg states, and a stale-high reading in each of
the five source positions. Its dpkg-query mock now renders the showformat string
it is handed, so the tests cover how install.sh asks for the version and not
only how it parses the answer; test_rocm_support.py gains the same mock and the
config-files/installed pair. Both new cases fail against the previous commit and
pass here.

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

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

* Stop amd-smi reporting the amdgpu driver version as the ROCm version

amd-smi prints one pipe-delimited line, so the ROCm field is followed on the
same line by the amdgpu driver version. Stripping every non-digit from the
field glued the two together, and when amd-smi cannot detect a ROCm userspace
it prints "ROCm version: N/A", which left only the driver version behind:

  ... | ROCm version: N/A | amdgpu version: 6.10.10 | ...  ->  rocm6.10

While detection took the first source that answered this was mostly hidden,
since a later real reading could not be reached anyway. Under highest-wins a
fabricated reading competes, and this one wins: on a host with a genuine ROCm
6.1 the N/A line resolved rocm6.4 instead of rocm6.1.

The field is now cut at the separator and required to start with digits, so an
N/A or empty field contributes nothing instead of a wrong number.

* Extract the ROCm version helpers in the no-version endpoint test

The test builds a shell script out of get_torch_index_url plus the two gfx
helpers, but the version chain now lives in its own helpers, so the call to
_detect_rocm_version_tag hit a command that does not exist. The guarded
assignment swallowed the 127 and the no-version endpoint was reached for the
wrong reason, which made the test pass whatever the resolver did. Mutation
confirms it: returning a real tag from _detect_rocm_version_tag left the test
green before this change and fails it after.

The /opt/rocm/.info/version rewrite moves onto the helper text for the same
reason. The read moved out of get_torch_index_url, so rewriting the function
body alone had quietly become a no-op.

* Bound the rpm probe highest-wins made unconditional

Resolving the ROCm version from every source instead of the first that
answers moved `rpm -q rocm-core` from last in a first-answer-wins chain to
a probe that runs on every AMD host. On RHEL/SLES with a normal ROCm
install /opt/rocm/.info/version answered at position two, so rpm was never
invoked; now it always is.

rpm -q is not a lock-free read. On the BerkeleyDB backend (rpm < 4.16, so
RHEL 8 and SLES 15, both supported ROCm platforms) a leftover
/var/lib/rpm/__db.00* from any killed rpm or yum leaves plain queries stuck
in futex until the files are removed, and rpm 6.0.x reintroduced a read
lock that deadlocks rpm --query against a running dnf transaction. Route it
through the existing _run_bounded helper so a wedged rpmdb makes the source
decline to answer instead of hanging the installer. _run_bounded no-ops
where timeout is absent, so this adds no new dependency.

The other four sources are left alone: amd-smi ran first in the old chain
so it is not newly reachable, the version file is a plain read, hipconfig
--version reads a version file and is not linked against HIP or HSA, and
dpkg-query takes no dpkg lock at all.

* Tighten the ROCm version resolution comments

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 05:55:17 -07:00
Lee Jackson
86e02ebc13
Fix Windows Whisper slim pairing on GPU llama runtimes (ROCm, CUDA, Vulkan) (#8379)
* Fix Windows ROCm Whisper slim pairing

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

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

* Extend the Windows libomp exemption to CUDA and Vulkan for PR #8379

* Tighten the Windows libomp exemption comments for PR #8379

* Scope the Windows libomp exemption to GPU bundles and require ggml-hip.dll for PR #8379

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

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

* Decide the Windows libomp exemption from the runtime files, not bundle_profile, for PR #8379

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

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

* Tighten the Windows libomp exemption comments for PR #8379

* Pin the Windows ROCm module glob with a decoy-only regression test for PR #8379

* Let the whisper prebuilt tests run on Windows for PR #8379

* Cover the cpu backend on a GPU llama runtime and note the arm64 libomp dependency for PR #8379

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-11 02:29:26 -07:00
Daniel Han
3a58fa5c41
Studio: apply base.txt on the install.sh and install.ps1 paths (#8195)
* Studio: apply base.txt on the install.sh and install.ps1 paths

install.sh and install.ps1 install unsloth and unsloth-zoo inline, then
export SKIP_STUDIO_BASE=1 so setup.sh / setup.ps1 do not install the same
two packages a second time. install_python_stack.py read that flag as
"skip base.txt" and short-circuited the whole step:

    if skip_base:
        pass

That was the same thing only for as long as base.txt held nothing but
those two names. Add a third, pinned entry to base.txt and it reaches no
fresh install on any platform: neither installer reads the file, and the
one branch that does was skipped. It would only land later, if the user
happened to run `unsloth studio update`.

Every install.sh and install.ps1 path was affected, on every platform:
CUDA, ROCm, XPU, CPU, macOS, local and non-local, fresh and migrated.

Keep skipping the two core packages, which is all the flag was ever
meant to avoid repeating, and apply whatever else base.txt asks for.
When base.txt holds only the core packages, as it does today, there is
nothing left to install and no extra subprocess runs. No-torch mode is
untouched: it has its own list in no-torch-runtime.txt, which the
installers do apply inline.

The core-package filter parses the project name rather than matching on
a prefix, so a future unsloth-<something> pin is not swallowed too.

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

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

* Reconcile base requirements with current main

* Preserve relative requirements includes across filters

* Fix filtered requirements test cleanup

* Separate core and shared base requirements

* Preserve shared base requirement resolution

* Keep the filtered-requirements and uv alias paths from aborting an install

The adjacent temp copy raised PermissionError on a read-only requirements dir, and a symlink failure handed uv back the spaced path it cannot read. Fall back to the temp dir and to a copy respectively, and stop the real-extras tests leaving filtered files in the tree.

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

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

* Count the MLX slot and survive an unusable base.txt

Simulating every install path showed two gaps. base_total never counted the Apple Silicon MLX step, so `studio update` there ran 13 steps out of a declared 12 and recorded the wrong steps_total. And the new base.txt read happens before the manifest is dropped, so a missing or unreadable file aborted with a traceback where the old code reached pip; a BOM also read as content and scheduled an empty step. Progress coverage now spans both core paths on all four platforms.

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

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

* Make the unreadable base.txt case independent of the mode bits

chmod(0o000) denies nothing as root, which containerized test jobs run as, and Windows does not implement POSIX modes at all, so the case asserted None against a file it could still read. Raise from a patched read instead.

* Tighten the comments this PR adds

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-08-11 00:06:23 -07:00
Daniel Han
88c54835a7
CLI: stop verify-install describing an unrelated venv around an editable checkout (#8308)
`unsloth studio verify-install` resolves which prefix owns
studio/install_manifest.py by walking up from the module file to the first
pyvenv.cfg. After `./install.sh --local` that module lives in the repo, so the
first pyvenv.cfg above it is whatever venv the clone happens to sit inside,
which is frequently unrelated to the managed install.

It then walked that venv's site-packages and reported every managed dependency
as missing, so a healthy install printed

    Unsloth Studio install is incomplete (studio_install_incomplete).
      missing packages: matplotlib, nest_asyncio, datasets, huggingface-hub, ...
      repair with: unsloth studio update

and exited 1, even though all of those import fine in the managed venv.
setup.sh / setup.ps1 gate their "already up to date" fast path on that exit
code, so every install re-ran the full dependency pass, and the desktop
preflight reads the same reason string.

A prefix now only owns the module when the module actually lives in that
prefix's site-packages. An editable checkout resolves to no owner, which falls
back to sys.prefix: the venv the CLI is running in, which is the managed one.
2026-08-10 01:24:31 -07:00
oobabooga
495ba21ebd
Studio: add MiniMax H3 video generation (#7989)
* Add MiniMax H3 video generation

* Improve MiniMax H3 memory routing

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

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

* Address MiniMax H3 review feedback

* Keep H3's VAE off the CPU path, so low_vram stops aborting

low_vram maps to the `model` policy, and offload_flags emits --vae-on-cpu for it
unconditionally. On H3 that kills the process:

    ggml/src/ggml-cpu/ops.cpp:6321: GGML_ASSERT(src0->type == GGML_TYPE_F16) failed

deterministically, SIGABRT, exit 134. Bisected on the flags: --vae-on-cpu with
--audio-vae aborts, the same command without --audio-vae renders in 95.87s, and an
fp16-converted audio VAE aborts too. So the trigger is the audio VAE, not the video
one, and the F32 type is imposed inside stable-diffusion.cpp rather than by the
file: ggml_conv_1d hardcodes an F16 im2col destination (ggml/src/ggml.c),
ggml_compute_forward_im2col_f16 then asserts the KERNEL is F16, and
audio_conv_weight_type (src/model/vae/ltx_audio_vae.hpp) maps only BF16 to F16 and
lets F32 through. It cannot be fixed by shipping a different checkpoint.

low_vram is the one mode a small-card user reaches for, so this drops the flag
rather than the mode. offload_flags takes vae_on_cpu, defaulting True so no other
family changes, and the H3 native path passes False. --offload-to-cpu and
--clip-on-cpu still apply, which is where the saving actually is: the denoiser
dominates, and with --offload-to-cpu the whole model peaks at 13.14 GiB.

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

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

* Point the H3 GGUF pick at the unsloth mirror

Main added test_curated_gguf_repos_are_unsloth_mirrors, which requires
every curated video gguf_repo to live under unsloth/ so a one-click pick
cannot 404 when a community repack is renamed or taken down. H3 was the
one family still on a community repo.

This was meant to be part of the merge commit but was left in the
worktree, so CI on that commit still saw the old value.

unsloth/MiniMax-H3-GGUF is still private and has to be made public
before this merges, or the pick will 401. No CI check reads it.

* Pin H3's native cfg-scale under test

H3 is distilled and CFG-free: its empty unconditional prompt encodes to
zero tokens, and the transposed tensor that produces trips
GGML_ASSERT(!ggml_is_transposed(a)) in ggml.c. SIGABRT, exit 134.
Measured: cfg 1.0 renders, cfg 1.5 and cfg 4.0 both abort. sd.cpp
defaults cfg-scale to 7.0, so this is a crash a plausible refactor
reintroduces by forwarding guidance the way every other family does.

The native path already hardcodes 1.0 and the family sets
supports_cfg = False, but nothing held either in place. supports_cfg
only gates the diffusers path; the native path builds its own params.

Checked the test fails when cfg_scale is changed to forward guidance,
so it is not passing vacuously.

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

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

* Pin diffusers by source archive instead of git, so macOS installs work

The macos-15-intel leg fails deterministically, not flakily:

  process didn't exit successfully: `/usr/bin/git init` (exit status: 1)
  --- stderr
  xcode-select: note: No developer tools were found, requesting install.

That runner has no Xcode, so /usr/bin/git is the developer-tools shim and
exits 1 for everything. uv needs a working git to resolve a git+https
dependency, so the diffusers pin this branch added cannot install there
at all. Main is unaffected because it depends on plain diffusers.

GitHub serves the same commit as a source archive, which uv installs with
no git involved. Verified by stubbing git to fail exactly the way the
macOS shim does: the git+ form reproduces the CI error, the archive form
installs diffusers 0.40.0.dev0 from the same SHA with
MiniMaxH3Transformer3DModel present and exported.

Also drops a full clone of diffusers from every install.

* Allow the diffusers source build on the clean-machine legs

With the archive pin the macOS leg gets past `git init` and installs, but
then trips the nobuild guard:

  built from source: diffusers -- these must resolve to wheels on a clean machine

There is no wheel to resolve to. MiniMax-H3 support is not in any
diffusers release, so this branch has to pin a commit, and neither a git
URL nor a source archive can produce a wheel from an index.

Added to the same allowlist that already carries triton-kernels for the
same reason. Checked against the bar the comment there sets: diffusers
builds with plain setuptools, declares no ext_modules, and its tree has
zero .c/.cpp/.pyx/.rs/.cu files and no shipped binaries, so the PEP 517
build is a pure-Python copy step and needs no toolchain. The separate
compiler-invocation check in both scripts is untouched and still fires if
one is ever needed.

Verified the allowlist logic still rejects a non-allowlisted source build
(a log building both diffusers and numpy reports only numpy).

Remove this entry once a diffusers release carries H3 and the requirement
goes back to a version specifier. Noted in both scripts.

* Baseline the hf-hub retry loop reopened by the 1.x upgrade

`pip scan-packages :: hf-stack` fails on this branch with 1 unbaselined
CRITICAL:

  C2 polling/beaconing loop detected
  Package: huggingface-hub
  File:    huggingface_hub/utils/_http.py
  L461:    while True:

This is on us, not upstream drift. Main pins huggingface-hub==0.36.2;
this branch needs >=1.23.0,<2.0 because diffusers at the pinned commit
requires it, so the resolved version moves 0.36.2 -> 1.27.0.

The baseline already carries this exact file and check at L462 and L298,
from earlier versions. It did not carry over because the key hashes the
matched code, not the line number, and the surrounding code changed
across the major version. That is the guard behaving correctly: changed
code in a baselined file reopens for review rather than staying
suppressed.

Reviewed it rather than just re-suppressing. L461 is the retry loop in
`http_backoff`: bounded by `nb_tries > max_retries`, the URL comes from
the caller, there is no hardcoded endpoint and nothing is exfiltrated.
Same benign construct as the entries it replaces.

Entry generated with the scanner's own _evidence_hash rather than
hand-written, and inserted beside its siblings so the diff stays 8 lines.
Verified: with the baseline the scan is exit 0 with 4 suppressed, without
it exit 1, so the guard still bites.

* Lift the macOS-arm huggingface-hub cap that this branch made unsatisfiable

`mac macos-15 / trace / file` fails on this branch and passes on main. uv
reports:

  No solution found when resolving dependencies:
  Because you require huggingface-hub>=0.34.0,<1.0 and
  huggingface-hub>=1.23.0,<2.0, your requirements are unsatisfiable.

This branch moved base.txt, no-torch-runtime.txt, studio.txt and
constraints.txt to hub >=1.23.0,<2.0, because diffusers at the pinned
commit requires it, but left the flat <1.0 cap in
overrides-darwin-arm64.txt. That file is macOS-arm only, which is why
only the mac legs see it and Linux and Windows stayed green.

The failure then presents as something else entirely: uv gives up, the
installer falls back to pip, and the clean-machine trace fails on
"installer invoked toolchain: rustc" rather than on the resolution.

The cap's own comment explains it exists so the resolver can never pair
hub 1.x with a pinned transformers 4.57.6 / hub 0.36.2 stack. That is
still true below python 3.10 and still capped there. At 3.10 and above
this branch is on transformers 5.5.0 and hub 1.x, so the premise is gone,
and mlx-audio's own >=1.0 floor is satisfied by the 1.x window anyway.

Checked by collecting every hub specifier that applies per Python version
across all five files: py3.9 resolves to 0.36.2 as intended, py3.13 to
1.23.0/1.27.0. Before this change py3.13 resolved to nothing.

* Make H3's native download use the repo the family advertises

The curated-mirror test main added only inspects VideoFamily.gguf_repo.
H3's native path does not read that field: video_minimax_h3.py has its
own H3_GGUF_REPO constant, used for both the transformer and the Qwen3-VL
encoder. So pointing the family at the unsloth mirror in the previous
commit satisfied that test while the actual one-click download still came
from a community repack, which is the exact failure the test exists to
prevent.

Pointed the constant at the same mirror and added
test_the_h3_native_repo_matches_the_family_gguf_repo to pin the pair, so
the two cannot drift apart again. Verified it fails when the constant is
put back to leejet, so it is not passing vacuously.

The mirror now carries the Qwen3-VL encoder quants alongside the
denoisers, byte-identical in size to the community ones, so this repo
alone satisfies both of h3_native_hub_files' hub entries. The encoder is
part of MiniMaxAI/MiniMax-H3 itself (FL2VA/text_encoder) which we already
mirror publicly at unsloth/MiniMax-H3 under the same licence, so shipping
a quantization of it beside the denoisers is the same act.

Also checked the encoder-tier routing survives the dynamic rung names:
-UD-Q2_K_XL selects the Q2_K_M encoder and -UD-Q3_K_XL the Q4_K_M one,
asserted in the new test.

Updated the download-plan test, which hardcoded the old repo id.

NOTE: unsloth/MiniMax-H3-GGUF is private. Unlike before, that now really
does gate this: the native path downloads from it. It has to be public
before this merges.

* Pin H3's companion-checkpoint guard under test

validate_h3_transformer_filename had no test. That mattered less when the
denoisers lived alone; the mirror now ships the Qwen3-VL encoder quants in
the same repo, so the picker lists both and a user can name either. Loading
a 12-17 GB encoder as the transformer would fail deep inside sd-cli instead
of at the boundary.

The accept cases include the dynamic rung names on purpose. The guard is a
prefix/suffix check and `-UD-Q2_K_XL` is a shape it had never seen when it
was written; it happens to pass, and now that is asserted rather than
assumed.

Checked the test fails when the prefix check is dropped, so it is not
vacuous.

* Record why H3 drops --vae-on-cpu, now that the abort is fixed

The comment justified the drop entirely by an sd.cpp abort. That abort is fixed
in the Unsloth fork, which would have made this look like a stale workaround to
revert once the fix reaches the pinned prebuilt.

Measured on a build carrying the fix, 640x384, 25 frames, 4 steps, q4_K, with
--offload-to-cpu --clip-on-cpu already applied: adding --vae-on-cpu moved peak
VRAM 12.42 -> 12.42 GiB and wall time 20.9s -> 100.4s. Under --offload-to-cpu
the peak is set by the streamed denoiser, so the flag saves nothing and costs
4.8x. It stays off on its own merits.

* Pin the sd.cpp prebuilt that actually renders MiniMax-H3

The pin was master-812-ea7f0c8, a stock upstream build, and on a stock build H3
does not work: it aborts on the default --cfg-scale, aborts again on
--vae-on-cpu, and its 1-D norms are quantized into an output uncorrelated with
its own bf16 reference (LPIPS 0.981). The Studio side worked around the first by
pinning cfg to 1.0 and the second by dropping the flag; the third had no
workaround on the consumer side at all.

All three are fixed in unslothai/stable-diffusion.cpp and open upstream as
leejet/stable-diffusion.cpp#1861, #1862 and #1863. The mirror's prebuilt
pipeline now applies them on top of the aged upstream tag it already builds, and
marks such a build with a -u<id> suffix naming the patch set, so
master-813-bfbef5b-u0665242 is upstream master-813-bfbef5b plus those three
patches and nothing else. The patches are deleted once upstream releases them,
at which point this pin goes back to a plain tag.

Verified on the published Linux x86_64 asset, not on a local build: both new
error strings are in the shipped sd-cli, and running it on a q4_K H3 denoiser
without --mode vid_gen now exits 1 with the instruction instead of core dumping
on a ggml assert.

test_video_backend's fake engine returned the old tag as its version string,
which read like a second pin; it only needs a non-None value, so it now says so.

* Close two gaps the H3 mirror switch opened

Both are consequences of the two preceding commits, found in review.

The prebuilt pin is now mirror-only (master-813-bfbef5b-u0665242), and
_resolve_with_fallback still asked leejet for it. That request is a guaranteed
404 by construction, since the -u<id> suffix marks a build only the mirror
makes, so it was a wasted round trip on every install. Worse, when the mirror
genuinely cannot serve a host the fallback lands on leejet's latest, which has
none of the H3 fixes. For every other model that is the right trade, better a
stock native engine than none. For H3 it is not visible: it aborts on the
default cfg-scale, aborts on --vae-on-cpu, and a blanket --type renders a broken
video rather than failing. A user who saw only the generic 'falling back to
leejet' line had nothing connecting that to the output. It now says so.

Second, hub/utils/gguf.py filtered H3 companion GGUFs by the old community repo
id only. The mirror the family and catalog now advertise carries the Qwen3-VL
encoder quants beside the denoisers, so a 12 GB text encoder was being
aggregated as if it were a selectable transformer quant. Both bundle repos are
now recognised, case-insensitively, and the cache-dir match follows.

Tests are mutation-verified rather than assumed: restoring the upstream 404
attempt fails the ordering test, removing the H3 warning fails the fallback
test, and dropping the mirror from the bundle set fails three. The ordering test
deliberately makes the mirror serve nothing, because with the mirror serving the
first attempt succeeds and the upstream attempts are never reached, which made
an earlier version of it pass under its own mutation.

A third test pins the native loader's H3_GGUF_REPO to the bundle set, since
those live in different files and a future repo move that updated only the
loader would silently reintroduce the same aggregation bug.

* Exclude MiniMax-H3's small-M projections from int8

_INT8_FAMILY_EXCLUDE_NAME_TOKENS has entries for qwen-image and hunyuanvideo-1.5
but none for minimax-h3, and H3 needs one for the same reason they do.

H3's adaLN projection is named adaln_proj, which no token in the generic list
matches: 'norm' is the closest and does not appear in the name. On the dense
checkpoint that projection is Linear(2688 -> 96768), so it clears
min_features = 512, gets quantized, and then runs at M = 1. Inductor lowers int8
matmul to _int_mm, which requires M > 16, so it raises 'self.size(0) needs to be
greater than 16, but got 1' at the first denoise. The offline prequant builder
bakes it in happily, which is exactly the drift the exclude list exists to
prevent for Flux and Qwen.

The pruned-modulation form hides this rather than fixing it: there adaln_proj is
Linear(8 -> 96768) and falls under min_features anyway. So this exclusion is what
makes the DENSE path correct and is a no-op on the pruned one.

context_embedder and token_refiner are added for the same reason hunyuanvideo-1.5
excludes its text stream. Measured at M = 10 text tokens against the video
stream's thousands, they are 3.47% of GEMM time even in the slow eager int8 path,
so leaving them bf16 costs nothing measurable.

This is what made an earlier measurement conclude int8 does not work on H3. It
does: on the pruned form int8 compiles and is 4.24% +-0.54% faster than fp8 at
identical memory, paired over 12 renders.

* Say which H3 component could not be downloaded, and why

H3 pulls four files from two repos, and the Hub returns the same
'Repository Not Found ... make sure you are authenticated' for a repo that does
not exist, one that is private, and one your token does not cover. A user
reading that has no way to tell which of the four failed, and the wording points
away from the real cause whenever the repo exists but is not public.

That is the state the GGUF mirror is in today: it is unpublished, so picking H3
fails with a message suggesting the user fix their token, which will not help.
This replaces it with the repo, the component, and the actual remedy, and says
the other components are unaffected so the failure is not read as total.

Gated repos get different wording, since accepting a licence is a different
action from waiting for a repo to be published.

Anything that is not a recognised access error is passed back unchanged rather
than reworded, so a timeout or a full disk still reads as itself. The helper
returns the exception instead of raising, so the caller keeps raise-from and the
original traceback survives.

Mutation-verified three ways: rewording every error (timeouts included), fixing
the component name to 'denoiser', and giving gated repos the private wording
each fail the test.

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

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

* Stop MiniMax-H3 holding two copies of its video VAE

Two thirds of an H3 render's peak is not activations. Measured at
640x384 across 124 frames, a 20.25 GB int8 denoiser peaks at 36.96 GB,
and the gap is almost all weights: the video VAE alone is 10.42 GB
because diffusers pins it to float32, and a further 4.91 GB is
autocast's own float16 copy of those same weights. A memory snapshot
puts 92.9% of the transient in 437 blocks allocated from nn.Linear,
largest 67.1 MB, which is the decoder's [2048, 16384] SwiGLU projection
in float16.

MiniMaxH3VideoDecodeStep wraps vae.decode in torch.autocast(float16),
and autocast caches every weight it casts for the lifetime of the
region, so the float32 original and its float16 twin are both resident
through the whole decode. Storing those weights as float16 up front
makes the cast a no-op and removes both. This is not an approximation:
x.to(float16).to(float16) is x.to(float16), and the four regression
tests check that on a real matmul rather than on the reasoning. The
audio VAE decode is not under autocast, so it keeps float32.

t2va starts from noise and never encodes, so vae.encoder and
vae.quant_conv go too. That part is gated on the workflow name rather
than dropped unconditionally, because an image-conditioned workflow
needs them.

Measured 36.96 -> 28.37 GB peak with the encoder drop and the pre-cast,
28.27 with expandable_segments as well, over 5 prompts x 2 seeds. Speed
is unchanged (-0.05% +-0.86% eager, -0.34% +-2.23% compiled), and every
arm hashes identically to its control on latents, audio_latents, frames
and audio.

The estimator's base still reads 68.5 GB. That figure was measured on
the bfloat16 modular components, not the int8 arm above, so it stays put
until it is re-measured in the same configuration rather than adjusted
by arithmetic.

* Pad MiniMax-H3's small-M int8 linears instead of leaving them dense

torch._int_mm asserts self.size(0) > 16. torchao's eager path never trips
it (safe_int_mm falls back to a widened matmul), but inductor lowers the
same quantized linear straight to _int_mm, so any quantized Linear invoked
at a small activation row count crashes under torch.compile. Until now the
fix was to leave those linears dense bf16, which on H3 meant excluding
context_embedder and both token_refiner blocks: 13 linears, 798M
parameters, 0.80 GB of weights the int8 checkpoint was not allowed to
touch.

Pad instead. diffusion_quant_pad.PadToMinM pads the flattened row count up
to 32, runs the GEMM and slices the result back, so the module compiles
with no change to the quantization config and the caller's rows come back
bitwise unchanged. Verified bitwise on all 65 (module, M) cases across
H3's 13 linears at M = 10, 13, 14, 17, 19, on real torchao-quantized
weights; compiling those same modules unpadded raises the _int_mm assert
at M = 10, 13 and 14.

Two properties carry that exactness and both are asserted rather than
assumed. The pad rows replicate row 0, not zeros: an all-zero row has amax
0, so the activation quantizer divides by zero. And the activation scale
must be per row, so each kept row's scale comes from that row alone; a
quantized Linear whose granularity cannot be proven per row raises instead
of being quietly skipped, because a half-padded transformer compiles on
the wrapped modules and crashes on the rest.

Everything below pad_to normalises to pad_to rather than only what is
below the floor, so one inductor graph covers every prompt length in the
range. H3's seven eval prompts run at M = 10..19, which straddles the
floor, and padding only to 17 would leave three shapes behind.

The wrapper reparents the Linear, so it runs after quantize_ on the
runtime path and after load_state_dict on the prequant one. The offline
builder drives quantize_ directly and saves the state dict, so it never
sees a wrapper; PadToMinM is also state-dict transparent as a second line
of defence, saving and loading under its own prefix so a wrapped
transformer still writes context_embedder.weight.

Scoped to minimax-h3. qwen-image, qwen-image-edit and hunyuanvideo-1.5
have the same small-M shape but published int8 prequant checkpoints whose
metadata bakes the current exclusion set, and _validate_checkpoint
compares that set against exclude_tokens_for_scheme, so they move only
together with a rebuild. adaln_proj stays excluded for a different reason:
on the dense checkpoint it is Linear(2688 -> 96768) and runs at M = 1,
while on the pruned form it is Linear(8 -> 96768) and falls under
min_features anyway (verified: the filter rejects all 51 for min_features).

Measured on B200, 640x384 x 124 frames, 4 steps, 7 prompts x 2 seeds, the
two arms alternated within each cell so drift on a shared box cancels:

  checkpoint      21.052 -> 20.254 GB  (-0.798, -3.8%)
  transformer     21.051 -> 20.253 GB  (-0.798)
  load peak       21.137 -> 20.336 GB  (-0.801)
  render peak     37.766 -> 36.966 GB  (-0.800, -2.1%)
  step time       +0.0597 s +-0.0057 eager, +0.0533 s +-0.0086 compiled

The time is torchao's un-fused eager activation quantization on 13 modules
that run ONCE per render, so it is a fixed cost rather than a per-step
one, and it does not scale with steps or resolution. Compiling those
modules alongside the blocks removes it: +0.0034 s +-0.0040, no detectable
difference. Quality is unchanged as far as n = 14 can resolve: against the
same bf16 twin the padded arm sits +0.0070 +-0.0151 LPIPS from the
excluded one, which rules out a degradation larger than 0.022.

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

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

* Classify the MiniMax-H3 GGUF bundle as video in the cached inventory

The H3 GGUFs are stable-diffusion.cpp conversions and carry no metadata
keys at all (kv_count 0), so general.architecture is absent where the
LTX-2 and Wan video GGUFs declare ltxv/wan. _arch_to_task therefore left
the downloaded repo with no task, which drops it from the Video picker's
On Device list and hands it to chat as a plain GGUF. Key the two bundle
repo ids before the arch is consulted.

* Release the VIDEO GPU claim when H3 native falls back to the CPU build

On a CUDA/ROCm host /video/load acquires the VIDEO arbiter owner because the
resolved device target is not CPU. _run_load_h3_native then asks for an
accelerator-matched sd-cli, and the pinned prebuilt release publishes no Linux
CUDA/ROCm archive, so ensure_sd_cpp_binary returns None and the load commits the
CPU build with native_device = cpu. Nothing dropped the VIDEO claim, so the next
chat or image acquire evicted and unloaded an H3 runtime that holds no VRAM.

Release the claim once the CPU fallback is committed, through release_if so the
token check is atomic against a newer load that already took ownership. Mirrors
the CPU-only native release /images/load already does.

* Video: protect the native H3 companion repos, cancel the modular denoise, forward the hub token

- VideoBackend.loaded_repo_ids() publishes the repos the committed native H3 runtime
  re-reads every generation (Qwen encoder + both VAEs), and the delete-cached guard
  consults it, so deleting a companion under a loaded model is refused.
- The H3 modular workflow no longer falls back to a null progress context: the denoise
  loop drives pipe.scheduler.step once per step, so the existing wrapper gives it the
  same per-step progress and cancellation the other callback-less pipeline gets.
- load_components() gets the Settings token, so gated/private component loads are not
  issued anonymously.

* Reject stable-diffusion.cpp builds that predate MiniMax-H3 support

ensure_sd_cpp_binary hands back whatever find_sd_cpp_binary locates and only
probes that it runs, so an install upgraded from an older Studio kept serving
its pre-H3 managed sd-cli. The H3 load's only gate is SdCppEngine.version(),
which that binary passes, so the load reported ready and the failure surfaced
on the first generation, after the whole bundle had downloaded.

Gate the H3 path alone on the capability instead of a version string: upstream
added --ref-video and the other H3-only options in the same commit that added
MiniMax-H3 (leejet/stable-diffusion.cpp#1854, master-812-ea7f0c8), and the
release prebuilts report 'version unknown, commit unknown' because they are
built without a .git directory, so --help is the only usable signal. Image
generation keeps accepting any user-supplied build.

A stale copy under the installer-owned root is removed so the pinned prebuilt
reinstalls; a build the user supplied is left in place and the load fails
naming it, the same ownership split _usable_or_discard_managed makes. A --help
that cannot be read means 'cannot tell', never 'no H3'.

* Distinguish a reused CPU sd-cli from an accelerator build on an H3 load

On a Linux CUDA host the first H3 load installs the CPU prebuilt through the
fallback and correctly commits native_device = cpu, because the pinned tag
publishes no Linux CUDA, ROCm or Vulkan asset. Every later load then calls
ensure_sd_cpp_binary(accelerator = cuda), which finds that same CPU binary and
returns it without looking at what it was built for, so the fallback was
skipped, native_device stayed cuda, and Studio applied GPU offload policy and
retained the VIDEO gpu_arbiter claim while sd-cli ran wholly on the CPU. A
later chat or image acquire then evicted an unrelated GPU model.

Fall back on what the binary offers rather than on whether one was returned:
sd-cli --list-devices prints one name/description line per available ggml
backend device, so a CPU-only build answers with CPU alone. The second load now
reaches the same cpu conclusion as the first, which is what lets the existing
release_if drop the stale claim. Unreadable output, or an older build that
rejects the flag, keeps the GPU: neither says the accelerator is missing.

* Guard the H3 companion repos while a native video load is downloading

_run_load_h3_native downloads from repo_id, the H3 GGUF companion and the H3
component repo, but the in-flight state only carried repo_id and base_repo. The
cached-model delete guard reads loading_repo_ids(), so it allowed deleting
Comfy-Org/MiniMax-H3, and the GGUF companion when the load comes from another
mirror or a local file, while those files were still downloading, which fails
the load.

Carry the companions on the loading state, the way the image backend's
_SdLoading already does, and publish them from loading_repo_ids(). This is the
in-flight twin of loaded_repo_ids() and covers the same repos.

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

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

* Keep curated Recommended rows searchable and give them their metadata

Two things went wrong with the curated video models the Recommended list
paints from the catalog rather than from a live Hub listing.

Search dropped them. The Recommended search matched the query against
`recommendedIds`, which filters out every id already on disk because a
downloaded model gets its own On Device row. The unfiltered Recommended
list does not filter that way: it renders the curated seeds and badges the
downloaded ones. So a curated model was visible in the list and unfindable
by typing its name the moment it was downloaded, and only a live listing
row could bring it back, which a repo the listing does not return never
gets. `searchableRecommendedIds` unions the seed ids with the listing ids,
seeds first, deduped case insensitively (the HF cache lowercases repo ids),
so both lists agree on what exists.

Rows rendered bare. Everything past the id came from the listing alone, so
a curated row the listing never returns showed no parameter chip and no
capability glyph while its neighbours showed both. The catalog now carries
the two facts nothing else can supply: `totalParams` per artifact and
`capabilities` per group, read through `curatedTotalParamsFor` and
`curatedCapabilitiesFor`. Both are fallbacks only. A listing row wins
wherever there is one, because real tags and a Hub-reported total outrank
anything hand written here.

The parameter counts are measured, not guessed. The MiniMax-H3 figure is
the sum of the tensor shapes in the BF16 GGUF of the FL2VA denoiser that
repo publishes; the LTX-2.3 figure is what the Hub reports for its repo,
carried so the row looks the same offline or rate limited.

One more inconsistency fell out of the same place: `searchRowFits` hides
anything it cannot size (`requireKnown`), and it could not size a curated
repo with no listing row and no "<n>B" token in its id, so turning on
"Fits on device" hid from search a row the unfiltered list still showed.
It now falls back to the curated total the same way it already fell back
to the curated size.

Covered by studio/frontend/tests/recommended-curated-row-metadata.test.ts:
15 assertions over the search pool, the two catalog lookups, the fit check,
and the four picker call sites that read them.

* Load MiniMax-H3 from a hosted pre-quantized denoiser

Video families had no way to reach a hosted pre-quantized DENOISER. VideoFamily
carried gguf_repo and te_prequant_repos (the text encoder) only, and nothing in
video.py consulted a denoiser table, so a MiniMax-H3 load either pulled the full
66.3 GB bfloat16 DiT or nothing at all. The image side already solves this with
DiffusionFamily.prequant_repos plus family_prequant_repo(), so this follows that
shape rather than inventing a second one.

Four parts.

VideoFamily gains prequant_repos, prequant_variant_repos and prequant_subfolder,
resolved by video_family_prequant_repo() / video_family_prequant_schemes(),
mirroring the image resolver. The registries stay separate, as the module header
requires, so the base-id normaliser is local rather than imported.

The shared resolver learns an optional subfolder. The hosted video checkpoints
nest theirs one level down instead of keeping it at the repo root, and the prefix
has to reach BOTH candidate names or the primary 404 is followed by a second one
and the load silently falls back to the dense download. Always a literal forward
slash: these are Hub repo paths, and a Windows join would miss the cache. The
cache and download plumbing already handled a nested name, so only the filename
builder changed, and every existing call site is byte-identical.

The modular workflow builds its denoiser through its own component loader, so
there is no dense module to quantise in place. A hosted checkpoint is therefore
the only way to run that transformer quantized, and pre-seeding it with
update_components() before load_components() is also what stops the dense
download: load_components(names=None) skips a component whose attribute is
already set. Passing names= instead would have forfeited the workflow's own block
pruning, which is what already avoids the 61.7 GB Ref2VA transformer.

Those checkpoints carry the pruned adaLN, where the modulation is a rank-8 affine
factorization of the time-embedding curve plus a shared table, and roughly 40% of
the released model's parameters go. Against the base repo's dense config the
model is four keys short, one over and fifty-one shapes wrong, so the strict load
fails and the checkpoint is unloadable by every route. video_minimax_h3_adaln.py
reshapes the model between from_config and load_state_dict: table lookup with
interpolation instead of the timestep MLP, no SiLU (the table already holds the
activation's own output projected onto the basis), and the modulation cast to the
block stack's dtype, without which the first quantized matmul dies on mismatched
dtypes. Bound per instance, so a dense load in the same process is untouched.

Finally the refusal becomes honest. A single-file pick on a modular family used
to reach the loader only after ~98.7 GB had downloaded AND after the resident
pipeline had been evicted to make room for it, because download-plan returned 200
and validation passed. Both refusals now run in validate_load_request, ahead of
the diffusers availability probe so they still fire where diffusers cannot be
imported, and each names what to pick instead. download-plan forwards
transformer_quant to validation and to the plan, without which the quant-keyed
refusal never fires on the route that stages the download and the plan stages
shards the load never opens.

* Point both MiniMax-H3 schemes at one hosted pre-quantized repo

The two hosted pre-quantized denoisers were split across two repos with the
checkpoint nested one level down, so reaching them needed a mechanism the image
side has never had: a VideoFamily.prequant_subfolder field, a
prequant_subfolder_prefix() normaliser, and a subfolder keyword threaded through
both resolve_prequant_source() and usable_prequant_source().

Both schemes now live in ONE hosted repo, at the root, named <Model>-<SCHEME>.pt.
That is the layout every image-side prequant repo already uses, and it is exactly
what prequant_repo_filename() builds unaided, so the whole mechanism goes. Match
an existing convention and the code should shrink, not grow: -22 lines in
diffusion_prequant.py, -6 in video_families.py, -6 in video.py, with no new
concept to carry.

Landing on the primary name also fixes a memory-planning under-credit.
cached_checkpoint_path() deliberately credits only the PRIMARY filename, so that
a cached legacy artifact cannot pin a stale name once a repo ships the real one.
While these checkpoints were published as transformer_<scheme>.pt the primary
never existed: every hit came through fallback_filename, and planning therefore
read an already-cached checkpoint as "this would have to download" and handed the
pick to GGUF. The primary is now the published name, so the probe hits it.

fallback_filename stays. It still covers repos that have not been renamed, and
dropping it is a separate decision from this one.

Tests: the subfolder-prefix cases are replaced by the naming they now guard --
both schemes resolving to one repo, the primary resolving to a root-level
<Model>-<SCHEME>.pt with no directory component on any platform, the cache probe
being asked for that primary name, and the repo's own scheme suffix being
stripped and replaced rather than carried through. Five mutations run, each
caught by the named test and reverted: two repos again (M1), the primary nested
under prequant/ again (M2), the suffix strip removed (M3), the cache probe keyed
on the fallback (M4), the fallback name dropped (M5).

* Add MiniMax-H3 image and reference video conditioning

* Improve H3 finalization progress

* Report real sd.cpp progress on the Video page instead of a frozen 0 of 30

A native (GGUF) video generation reported phase "denoise", step 0/30 for its
entire run and then flipped straight to "completed". Two separate things were
wrong, and the progress endpoint could not move until both were fixed.

The parser looked for r"(?:step|sampling)\D+(\d+)/(\d+)". sd-cli's sampling
bar contains neither word. It prints

    |=========>          | 7/30 - 21.50s/it

so nothing ever matched. Anchor the pattern on the bar and on the trailing
speed unit instead. A bare "n/m" is deliberately not enough: an unrelated
ratio in some other log line must not drive the progress bar.

The reader also delivered every redraw one step late. sd-cli redraws in
place, and its carriage return LEADS the record while the newline only
arrives on the final step, so a reader keyed on CR/LF cannot produce step 1
until step 2 has been flushed. Treat the erase-to-end-of-line that closes
each redraw as a terminator too, and read the pipe with buffer.read1 so a
record that carries no newline is not stuck behind a blocking readline.
Escapes are stripped before a record reaches on_log or the error tail.
Streams without a raw .buffer keep the old line iteration.

The same bar shape is printed by three different things, so the parser now
tells them apart rather than reporting whichever came last. Weight load
prints it with a byte rate, and tiled VAE decode prints an identical s/it bar
counting TILES: without a guard a run finished sampling at 30/30 and then
jumped backwards to "step 1/16". Load and decode are real work with no
sampling step, so they report their own phase and a null step rather than a
fake 0 of 30. ETA is measured from the first step, not from job start, so the
one-off weight load is not charged to every remaining step.

Verified end to end against a real CUDA sd-cli MiniMax-H3 generation: the
step advances 1..6 over 6.2s..9.7s of wall clock, load and decode are
reported as themselves, and the ETA tracks.

* Read sd-cli's in-place progress redraws so the Video bar moves during sampling

The native H3 progress bar had two independent causes and fixing either alone
changed nothing observable. The bar pattern is now correct, but the reader still
was not.

sd-cli redraws its sampling bar in place: one printf per step shaped
"\r<bar> <n>/<total> - <speed>\033[K", with a newline only on the final step of a
phase. The drain loop did `for raw in proc.stdout`, which terminates on LF only,
so every redraw sat in the buffer until the next one's carriage return arrived
and the last one until sampling was already over. The Video page saw nothing.

Split the raw pipe into records on CR, LF, or the trailing erase-to-end-of-line,
reading through buffer.read1 with an incremental UTF-8 decoder so a multi-byte
character straddling two reads survives, and strip the CSI escapes before the
record reaches on_log or the error tail. Streams without a raw .buffer (test
doubles, non-pipes) fall back to line iteration.

The new backend test drives the real byte stream through both halves, one flush
per read, and asserts each step is visible on the read that carried it rather
than one redraw later.

* Give MiniMax-H3 first and last frame conditioning in the video backend

MiniMax-H3's released transformer is the FL2VA one: text-to-video is the same
checkpoint run with no keyframes. Studio only ever ran it text-only, so the
Video generate request had nowhere to attach a reference frame.

The load used to prune the block graph to t2va. That argument prunes STATICALLY,
so an fl2va-pruned pipeline runs the keyframe blocks on every request and cannot
serve a text-only one at all: it raises packing an empty conditioning list.
The load now keeps the whole auto graph, which selects per request, and bounds
load_components to the keyframe workflow's component set instead, so the 61.7 GB
Ref2VA partition is no more loaded than before. Measured against the released
checkpoint: a text-only request through this pipeline is bit-identical, video
and audio, to the same request through a t2va-pruned one.

A keyframe is a geometry anchor, not just conditioning, so the canvas comes from
its aspect ratio through the released arithmetic (768 short edge, area capped at
768x1344, both axes rounded to 32) rather than from whatever resolution preset
was selected. An arbitrary size produces a garbled clip rather than an error.

sd.cpp already implements the same conditioning, so that side is the existing
--init-img / --end-img flags with the frames staged as PNGs.

Only MiniMax-H3 declares the capability, and status reports it, so Wan and LTX
do not grow a control that does nothing.

* Cover the MiniMax-H3 keyframe path with tests

Registry (which families declare it, and the canvas a keyframe resolves to),
the load wiring (whole block graph, component set still bounded, VAE encoder
kept), request handling (decode, refusal, canvas override, what reaches the
pipeline call) and the sd-cli argv.

* Add the reference-frame controls to the Video page

First and last frame pickers, shown only for a family whose status declares
keyframe conditioning, so Wan and LTX are unchanged. The Images page's source
picker moves to a shared component rather than the Video page growing a second
upload path; both send the same data URL to the same backend decoder.

While a frame is attached the Resolution preset is disabled and says why: the
frame's aspect ratio resolves the clip's size, the way the model itself does.
The gallery recipe records which ends were pinned.

* Check the keyframe canvas against the pipeline's own resolver

The canvas rule is a checkpoint contract, so pin it to the released
implementation rather than only to hand-written expectations. Skipped where
diffusers does not ship MiniMax-H3, which is most runners.

* Revert the standalone H3 keyframe implementation

oobabooga/unsloth#121 covers first frame, last frame, first-and-last, Ref2VA and
the canvas rule, and it reached the same load construction independently. Two
implementations of the same feature on one branch is worse than either, so this
takes mine back out and leaves the branch ready for that work to land whole.

The one finding worth keeping from it is already reflected there: passing
workflow= to ModularPipeline.from_pretrained prunes the block graph statically,
so the pipeline must be built unpruned and only load_components bounded.

* Keep the pre-quantized MiniMax-H3 denoiser resident so a generation can run

Loading H3 with a hosted pre-quantized denoiser worked, but every generation died
on its first denoise step:

  Attempted to set the storage of a tensor on device "cuda:0" to a storage on
  different device "cpu". This is no longer allowed; the devices must match.

ComponentsManager.enable_auto_cpu_offload parks every component on the CPU and
moves each one onto the accelerator inside its own pre_forward, that is from
within the block that is already executing. The text encoder and the VAEs survive
that; a torchao-quantized denoiser does not, because the device change reaches
return_and_correct_aliasing, which tries to alias a CPU storage to an accelerator
tensor. Moving the same module at load time, outside any executing block, works.

So place it once at load and take it out of the offload rotation: drop its hook,
unlist it from the other components' eviction candidates, and move it. Everything
else is unchanged, and the encoder and VAEs still offload around it. Keeping it
resident is what asking for a quantized denoiser buys in the first place: the
hosted checkpoint is about 20 GB against 66.3 GB dense.

Verified end to end on a B200: MiniMaxAI/MiniMax-H3 loaded with the hosted fp8
denoiser, then a 1280x768, 124-frame clip generated from a start frame in 167s.
The clip's first frame matches the supplied image and the motion is coherent.

* Apply the pinned Diffusers revision on a fresh install, not just an update

MiniMax-H3 needs a Diffusers revision newer than any published release, and
Studio refuses to load it otherwise. The pin was in
studio/backend/requirements/base.txt, and a clean install.sh run still ended up
on diffusers 0.39.0 from PyPI, every time, with nothing in the log to say so.

base.txt is never installed by install.sh. install.sh installs unsloth itself,
whose own metadata pulls a diffusers release in transitively, and then runs
install_python_stack.py with SKIP_STUDIO_BASE=1, where the base-packages step is
a bare `pass`. So the pin applied on `unsloth studio update` and on the no-torch
path (install.sh installs no-torch-runtime.txt directly) and was dead on exactly
the path a new user takes. Reproduced on a clean install into a throwaway prefix
before and after: 0.39.0, then 0.40.0.dev0 with MiniMaxH3Transformer3DModel
present.

The revision now lives in its own diffusers-pin.txt, installed by a step that
sits outside every skip_base / NO_TORCH branch and after every other
requirements file, so nothing left in the run can re-resolve diffusers back to a
release behind it. No forced reinstall is needed: a direct URL requirement is not
satisfied by a resident registry install, so the step is a no-op once the
environment is already on the pin.

tests/studio/install/test_diffusers_pin.py holds the shape in place: exactly one
requirements file may name diffusers, the pin must be a full commit sha rather
than a moving ref, the install step must sit at function top level rather than
under a conditional, and it must come after every other requirements install.

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

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

* Studio: let the Video and Images pickers see a local diffusers pipeline

A model already on disk never reached the pickers' On Device list unless it
happened to keep a weight file beside a root config.json. Every image and video
model downloaded as a pipeline keeps its weights in component subdirs under a
root model_index.json instead, so the hub inventory scan behind /api/hub/local
rejected it. Pointing Custom Folders at one was worse than empty: the LM Studio
publisher walk descended into the pipeline and offered vae, transformer,
text_encoder and audio_vae as four separate models, none of them loadable.

Teach the hub scanner the same pipeline-root test routes/models.py already
applies, in the three places that only accepted a root config plus loose
weights, and keep a pipeline row through the custom-folder format filter: the
layout has no loose weight to classify, so the row is "unknown" by construction
rather than by fault.

* Cover the local diffusers pipeline scan with tests

* Pin what the pipeline exemption must not let through

Three gaps in the cover added with the scan change, each found by mutating the fix and
watching the suite stay green.

The custom-folder format filter now waves a row through on its shape, and nothing said
what it still has to reject: replacing the whole predicate with True passed. A folder
holding a config.json and no weights, which an aborted download leaves behind, reports
the same "unknown" format and no loader can start it, so it pins the boundary.

The predicate is applied to every row the filter did not already accept, and a row's
path can be a GGUF file rather than a directory. A missing path, a file, and a
directory whose model_index.json is itself a directory must answer False rather than
raise, because an exception there fails the scan and empties the picker.

The publisher walk was only covered one level up. Adding the model folder itself as a
scan folder is the obvious thing to do and used to publish vae, transformer and
text_encoder as three models.

* Drop the unused H3_TASK_KEYFRAMES import from the video backend

video.py only branches on H3_TASK_REFERENCES; the keyframe constant is read
from video_minimax_h3 directly by the tests that need it. The hoisted-import
safety net in Source lint flags the unused name as a blocker.

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

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

* Stage the hosted pre-quantized H3 denoiser in the video download plan

The plan already drops the dense transformer shards whenever a hosted
pre-quantized checkpoint covers them, but nothing put that checkpoint back:
an int8 or fp8 H3 stage skipped 66.3 GB of base shards and added none of the
20.25 GB artifact the load actually opens. The byte total under-reported the
stage by the size of the checkpoint, the disk preflight cleared a volume that
could not hold it, and an offline stage completed without the one file the
load needs.

_denoiser_prequant_hub_files mirrors the pre-cast encoder helper: it resolves
the family's hosted checkpoint, confirms the file really exists on the Hub,
and prefers the repo-root name over the legacy scheme name in the same order
the load tries them. An unreachable repo is logged and yields no files, so a
gated or renamed artifact keeps the dense shards instead of sinking the plan.

The progress-bar estimate is deliberately left alone: it counts cached bytes
for the checkpoint and base repos only, so adding a third repo there would
leave the bar permanently short of 100 percent.

* List both H3 denoiser partitions in the picker, not only FL2VA

The bundle-repo filter accepted only minimax_h3_fl2va*, so every published
minimax_h3_ref2va* quant was hidden from the variant listing. The loader
disagrees: validate_h3_transformer_filename accepts either partition, on the
grounds that which one is picked IS the task, and h3_transformer_task routes
Ref2VA to the reference-video workflow this PR adds. The community bundle repo
publishes three Ref2VA quants today, so the reference path was unreachable from
the remote catalog.

Accept both prefixes from one shared tuple and keep excluding the Qwen3-VL
encoder and VAE companions, which are never picks for either partition. The
filter test asserted the old behaviour and is updated with it.

* Bound H3 reference-audio decoding to the trained window

The reference-video decoder already selects, resizes and refuses incrementally
because the encoded size says nothing about the decoded size. The audio decoder
did not: it appended every resampled block to a list and then allocated a second
full buffer in np.concatenate, with no duration or sample cap.

The route accepts 32 MiB of encoded audio, which is over half an hour of
compressed stereo. That lands as roughly 1.9 GB of float32 and doubles again in
the concatenate, and up to three references are accepted per request, so an
ordinary long music or podcast file picked by mistake could exhaust the host
before the background job even started.

H3's reference window is 15 seconds, so anything past it is unusable rather than
merely large. Refuse it while decoding, with the same shape of message the video
guard uses, instead of decoding it first.

* Refuse a quantized H3 reference load instead of seeding the keyframe denoiser

The hosted pre-quantized checkpoints are FL2VA (keyframe) denoisers. Ref2VA
shares their module shapes and the same base model, so resolve_prequant_source
handed one back for a reference load, it passed every metadata check, and
seeding it made load_components skip the real Ref2VA transformer. The request
then generated from the wrong partition rather than failing, which is the worst
of the three outcomes.

The route accepts h3_task, so this was reachable from the public API even though
the picker does not expose the choice yet. validate_load_request now refuses the
pairing with a message naming the workable alternatives, in the same place it
already refuses a scheme with no hosted checkpoint, and the modular loader drops
to the released components if a direct call reaches it. Nothing changes for
keyframe loads, which are what the checkpoints are.

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

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

* Skip modular-workflow families in the dense text-encoder plan assertion

The plan-unchanged sweep from main walks every family and asserts the dense
budget plan_diffusion_memory received. MiniMax-H3 is the first modular-workflow
family to reach that list, and load_pipeline dispatches to the workflow's own
loader before the planner runs: each component is built by its own
from_pretrained, so there is no single dense pipeline to budget and no plan call
to assert on. Skip it the way the sweep already skips wan2.2-t2v-a14b.

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

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

* Scope the paired-axes canvas rule to keyframe requests

A half-specified width/height is long-standing, documented API behaviour: the backend
resolves the missing axis from the family's default preset, in both
validate_video_request_shape and _resolve_keyframes. Applying the new paired-axes rule
as an unconditional request validator rejected those calls with a 422 before family
validation ever ran, breaking existing LTX, Wan, Hunyuan and prompt-only H3 clients.

The rule still holds where it means something: with a keyframe present the canvas is
matched to the source aspect whenever either axis is missing, so the axis the caller
sent would be silently discarded.

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

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

* Keep the CUDA sd-cli pin and translate a mirror-only tag upstream

The merge with main brings in the accelerator-aware installer, whose fallback
translates a mirror-only -u<id> pin back to the upstream release it was built
from instead of skipping the upstream attempt. That is strictly better: skipping
kept the round trip cheap but dropped the pin entirely on every host the mirror
does not build, leaving them on upstream latest.

test_a_mirror_only_pin_is_never_requested_upstream asserted the old shape, that
the fallback settled for upstream latest. It now pins the new one: never the
literal -u<id> string, the translated release instead, and no latest attempt at
all because the translated pin succeeds.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-09 10:05:58 -07:00
Daniel Han
82bf2e6b9f
Fetch the llama.cpp validation model only when the smoke test reads it (#8248)
* Fetch the llama.cpp validation model only when the smoke test reads it

An approved bundle proves integrity by sha256 and skips the staged smoke test,
so the tiny GGUF probe goes unread on the default install path. It was still
downloaded up front, which made huggingface.co a hard gate on every prebuilt
install: a 429 there raised PrebuiltFallback and forced a multi-minute source
build over a file nothing opens.

Fetch it lazily instead, at the one branch that reads it, memoised so the two
smoke steps share a single download.

test_install_prebuilt_does_not_skip_unhealthy_existing_install tripped on the
probe download as a proxy for reaching the normal install flow. That proxy is
gone on the default path, so it now trips on validate_prebuilt_attempts, which
is the behaviour it actually names.

* Cover the probe rate limit end to end over install_prebuilt

The unit tests pin validate_prebuilt_choice's behaviour, but the bug was that a
429 short-circuited the whole install before the release loop. Assert that over
install_prebuilt itself: with download_validation_model raising 429, the flow
must still reach validate_prebuilt_attempts rather than exit EXIT_FALLBACK.

Verified it fails against the pre-fix source.

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

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

* Resolve the probe before the candidate loop when a plan will validate

validate_prebuilt_attempts catches Exception per candidate, so a probe download
failing inside that try read as a failure of the bundle itself: a transient 429
demoted a healthy GPU pick to the CPU asset or an older release, and because the
thunk memoises success but not failure, each further attempt re-downloaded after
extracting another bundle.

Hashless attempts always validate, so resolve the probe once before the loop
whenever any attempt will need it. Plans that skip validation never call the
thunk and stay lazy.

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

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

* Resolve the probe before the release loop too

The per-release handler in install_prebuilt swallows PrebuiltFallback and moves
to an older plan, the same shape as the per-candidate handler one level down. A
probe failure raised inside it installed an older llama.cpp build over a
transient 429, and since the thunk memoises success but not failure, each plan
retried the download.

The probe does not depend on which release was picked, so resolve it once before
the loop whenever any plan will smoke-test.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 04:27:16 -07:00
Daniel Han
954468a3cb
fix(studio): wire llama's OpenMP runtime into slim whisper installs (#8230)
* fix(studio): wire llama's OpenMP runtime into slim whisper installs

The slim wiring mirrors libggml*/ggml*.dll plus libomp*.dll from the paired
llama bin dir. The DLL glob was added for llama's clang-built windows-arm64
ggml-base.dll; the ELF side was left out on the assumption that Linux ggml
takes libgomp.so.1 from the host. That holds for the gcc-built x64 slice but
not for arm64: llama's arm64 libggml-base.so is clang-built and NEEDS
libomp.so.5 with RUNPATH $ORIGIN, and that library ships inside the llama
bundle rather than on the host. Every slim install on linux arm64 therefore
wired a ggml stack whose loader immediately failed with "libomp.so.5: cannot
open shared object file", so dictation fell back to Transformers.

Add libomp*.so* and libomp*.dylib to the globs, and bump
SLIM_RUNTIME_WIRING_VERSION so installs made under the old wiring re-wire
instead of reporting themselves current: their marker does not list
libomp.so.5, so the missing-library check that would otherwise repair them
never fires.

whisper.cpp side is unslothai/whisper.cpp#18 (CI wiring) and #19, which adds
the library to requires_ggml_sonames. That gate reads the llama bin dir, where
libomp.so.5 is a real file, so the new manifests pair correctly with both the
old and the new installer.

Verified against the real published llama.cpp arm64 and x64 cpu bundles:
wiring the arm64 bundle now places libomp.so.5 beside whisper-server and pulls
in no llama libraries; every soname the new whisper manifests require exists as
a file in the paired llama bin dir on both arches; and an x64 whisper-server
wired through link_ggml_runtime starts and returns a real transcription. All
350 whisper/llama/prebuilt-core install tests pass.

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

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

* Tighten the libomp wiring comments for PR #8230

* Fix the sidecar's ROCm wiring version check for PR #8230

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 01:35:55 -07:00
Etherl
968d2e50ff
Studio: fix Windows desktop updates failing integrity checks (#8185)
* Fix Windows desktop updater integrity checks

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

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

* Exclude nested compiled cache packages

* Tighten updater comments

* Keep package discovery test dependency-free

* Shorten the einx Windows pin comment

* Drop the einx pin comment

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-09 01:13:37 -07:00
Daniel Han
b0ea23bbf2
Catch the llama-prebuilt validator stubs up with rocm_gfx (#8182)
* Catch the llama-prebuilt validator stubs up with rocm_gfx

Both validators take a rocm_gfx keyword and the four stubs standing in for them
do not, so the real call site passes it and the stub raises TypeError. It lands
inside whatever assertion was running, which reddened four unrelated fallback
tests on main rather than saying a parameter had moved.

Adds the keyword, and a test naming the keyword-only set of each validator so
the next one fails once, here, with the parameter that moved.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-08 06:35:55 -07:00
oobabooga
69d555b98e
Studio: check llama.cpp cache access before setup (#8032)
* Windows: preflight managed llama.cpp cache access before setup and update

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

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

* Fix denied llama.cpp cache update errors

* Trim canonical paths on both branches and probe listing on POSIX for PR #8032

Follow-ups from reviewing the denied llama.cpp cache preflight.

Get-CanonicalDir only trimmed a trailing separator on the lexical branch.
Resolve-Path preserves one, so "...\studio\" compared unequal to "...\studio"
and Test-StudioHomeIsCustom called the default home custom. Get-ManagedLlamaCppDir
then handed the preflight ...\studio\llama.cpp instead of the real cache, so a
denied cache went undetected. The trim now runs after both branches, still
guarded so a path root keeps its separator.

setup.sh gated the prebuilt install on _studio_dir_unsearchable, which probes
search (+x). Mode 111 passes that and still raises PermissionError inside
install_llama_prebuilt.py, which lists the tree. Added _studio_dir_unreadable
for call sites that list or replace rather than probe a known child.

setup_fail only emitted [TAURI:ERROR] under UNSLOTH_TAURI_MODE. update.rs sets
UNSLOTH_TAURI_UPDATE on every platform, so macOS and Linux desktop updates still
showed "Update exited with code N" while Windows showed the reason.

A blank USERPROFILE threw a raw binding error from the relocated resolver before
Exit-SetupFailure could report it, so no [TAURI:ERROR] reached the app.

Tests: the marker-readable, listing-denied case was asserted only as a literal
substring, so one extra space reintroduced the early return with all 49 Python
tests and all 125 PowerShell checks green. That assertion is now a regex, and
the PowerShell suite builds the shape for real (icacls /deny :(RD) on Windows,
mode 111 on POSIX) with a negative control. The shell suite gains the same
mode 111 case and no longer compares empty grep output as an integer.

* Fix the Tauri marker gate and narrow the USERPROFILE guard for PR #8032

Two defects in my previous commit, both found by simulating the changes rather
than reading them.

setup_fail joined both variables into one case subject, which is not an exact
match. "*,1" matches any subject ending in ",1", so an unrelated
UNSLOTH_TAURI_UPDATE=a,1 printed a stray [TAURI:ERROR] on a plain CLI run, and
"1,*" did the same for a comma in UNSLOTH_TAURI_MODE. Testing each variable
separately matches setup.ps1, which uses exact membership. Verified over the
144-case product of both variables: no false positives, no false negatives, exit
codes preserved, and byte-identical to main for every value of
UNSLOTH_TAURI_MODE when UNSLOTH_TAURI_UPDATE is unset or 0, so no existing CLI
invocation changes. Added a regression test that fails on the old gate.

The USERPROFILE guard used IsNullOrWhiteSpace, but Join-Path only rejects null
and empty; it accepts a whitespace-only value. The guard therefore also stopped
a run that previously completed, USERPROFILE="   " with a fully qualified
UNSLOTH_STUDIO_HOME. IsNullOrEmpty keeps the clean message for null and empty,
which is where the raw binding error was, and changes nothing else.

* Run the llama.cpp access guard before the prebuilt and source branches

The guard sat inside the prebuilt else-branch, so UNSLOTH_LLAMA_FORCE_COMPILE=1,
a llama.cpp PR or source override, or anything else setting _SKIP_PREBUILT_INSTALL
bypassed it. Those paths reach the phase 9 swap, which only probes access after
`rm -rf "$LLAMA_CPP_DIR"` has already failed, so a denied cache stranded a
completed source build instead of failing before it started.

_assert_studio_owned_or_absent does not cover it either: it returns early unless
the studio home is custom, so a denied default cache had no guard on that path at
all. Verified by driving both helpers against a mode-000 tree: the ownership guard
returns 0 on a default home while the access probe reports denied.

Hoisted both checks, in the same order so the custom-home wording still wins, to
just before the branch. The local-link paths are excluded because they already
replaced or reused the tree. The late checks stay as defense in depth.

* Use the read probe in both llama.cpp replace postconditions

Mode 111 defeats `rm -rf` but stays searchable, so both postconditions fell
through `_studio_dir_unsearchable` to the generic "could not be replaced" text
and the user got no recovery guidance. Reproduced against a mode-111 tree: the
rm fails, the search probe does not fire, and the run exits with the generic
message.

The local-link site sits above the hoisted guard, so it is the one that is
reachable. The source-build site is only reachable when a tree becomes
unreadable during the build, but that is the most expensive path to end with the
wrong message, and it is the same one-word probe. Both now use
`_studio_dir_unreadable` and print the permissions block.

* Tighten the comments added by this PR

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
2026-08-07 22:33:42 -07:00
Daniel Han
8b34acebcd
Studio: stop the macOS launcher killing a warming backend, and the Mac tab blackout (#8076)
* Studio: recover the MLX self-heal when uv cannot resolve the venv interpreter

On Apple Silicon the self-heal reinstalls mlx/mlx-lm/mlx-vlm to re-enable
Train/Export. It ran uv with --python sys.executable and nothing else, so when uv
declined that path the repair gave up and Train stayed disabled for good:

  MLX self-heal failed (staying chat-only):
  error: No virtual environment or system Python installation found for path
  `<studio_home>/unsloth_studio/bin/python`; run `uv venv` to create an environment

A venv's bin/python is a symlink into the base interpreter, and macOS breaks that
link routinely: a Homebrew or python.org point upgrade moves the target and the
venv keeps a dangling symlink. The running process never notices, because it
mapped the binary at exec time, so uv is the first thing to fail.

Name the environment as well as the interpreter. _uv_python_target prefers
sys.executable and falls back to the venv root when the interpreter no longer
re-resolves, _mlx_install_env sets VIRTUAL_ENV from sys.prefix, and a uv run that
still reports an unresolvable interpreter is retried once against the venv
directory. Only that specific failure retries; an ordinary resolution failure
stays a single run.

VIRTUAL_ENV is set from sys.prefix rather than forwarded from os.environ, matching
how UV_OVERRIDE is already handled: it names the install target, so inheriting it
would let a caller redirect the install.

When both attempts fail the venv itself is broken rather than merely missing MLX.
uv's own text says to run `uv venv`, which would build an environment Unsloth does
not manage, so the warning now names `unsloth studio update` instead.

Reported on macOS 0.1.524-beta.

* Studio: stop double-logging exceptions and sqlite-vec spam

A macOS diagnostics bundle came back at ~300KB, and roughly half of it was the
same tracebacks written twice.

LoggingMiddleware logs request_failed with exc_info, which structlog renders as
a full traceback inside the JSON "exception" field, then re-raises. Uvicorn logs
that same exception again on stderr as "Exception in ASGI application", and the
desktop shell mirrors every stderr line into tauri.log individually, so one
failure cost about 90 log lines. Mark the exception once request_failed has
reported it and filter uvicorn's duplicate on the uvicorn.error logger, the same
technique run.py already uses for the startup line. An exception raised above
the middleware carries no marker and keeps its traceback, and --verbose restores
both copies.

The bundle's own failure was a missing sqlite_vec/vec0.dylib, which the import
check does not catch: every /api/rag/knowledge-bases poll opened a connection,
failed to load the extension and 500ed. rag_db now warns once per process and
raises RagExtensionUnavailable (a RuntimeError subclass, so existing handlers
are unaffected), and list_knowledge_bases degrades to an empty list for that
case only. A locked or corrupt database still surfaces as before.

Also quiet the 2xx line for four boot-burst catalog reads (/api/providers/
registry, /api/providers/, /api/models/loras, /api/settings/personalization);
4xx/5xx and every mutation still log. And tauri.log only checked its 5MiB
rotation threshold at startup, so a long session grew unbounded: the file logger
now writes through a size-tracking handle that rotates in place.

On the reported bundle this removes 945 of 2063 lines (89KB of duplicate stderr
traceback) plus the 9 sqlite-vec request_failed events (48KB), leaving one
warning line.

* Studio: stop Mac launches blacking out the Train and Video tabs

The platform store seeds chatOnly from the browser user agent, so on every Mac
both rows rendered disabled from first paint, visually identical to a measured
"this machine cannot do that", until /api/health answered. Since the hardware
detection went lazy that reply can take seconds to minutes.

Add capabilitiesUnknown() next to isChatOnly(), derived from the fetched flag
that already means "a server-reported verdict is stored" (a deferred reply
counts as settled: under the torch-warm kill switch nothing else is coming).
NavRowDef gains a pending field, folded in by a small import-free resolver both
render sites go through, so an unmeasured row stays enabled and reuses the
existing spinner column instead of graying out. The root guard lets /studio and
/video wait the verdict out rather than one-way redirecting them to /chat, and
each page shows its own loading state while it does.

Video also gets a real capability answer. There is no Apple path in the video
backend, but a healthy Apple Silicon host is not chat-only, so the tab was
enabled and would just fail at load. video_capability() mirrors
export_capability() and is spliced into GET /api/system and
GET /api/system/hardware as additive fields; the page renders a coming-soon
panel on an authoritative false, and the sidebar tooltip is now derived from the
reason instead of hardcoding "needs an NVIDIA or AMD GPU".

Also retry a failed hardware probe in useHardwareInfo: it resolved to the
unloaded default with nothing scheduled to run again, which would have left the
new Video gate spinning for the session.

* Studio: add a macOS tab-capability UI smoke and nav row test hooks

Covers the two things reported on Apple Silicon 0.1.524-beta in one live run:
Train and Video rendering blacked out for minutes after launch, and the desktop
launcher killing the backend about a minute in with "Server stopped unexpectedly".

The nav button now carries data-testid="nav-row-<id>" and data-spinner, so the
smoke can tell a spinning row from a greyed-out one. Nothing reads them at
runtime; resolveNavRowState still owns the behaviour.

tests/studio/playwright_mac_tab_capabilities.py drives a live Studio: it samples
the Train and Video rows from first paint and fails if either renders disabled
while /api/health still reports hardware_detecting, walks Chat, Hub, Images,
Train, Video and Export clicking each row and screenshotting the result, and
polls /api/liveness and /api/health on a background thread for the whole run.

The poll window is 330s by default, deliberately past the launcher's 300s startup
grace: the reported crash landed at t+66s, so a backend that dies to the watchdog
fails here rather than looking like a slow boot. Redirects away from /studio and
/video are allowed only once the verdict is measured.

* Desktop: stop the health watchdog killing a backend that is still importing torch

The macOS "Server stopped unexpectedly" report was three probe timeouts inside the
first 64s of a normal cold start. #7958 fixed the grace period being bypassed; this is
the rest of it, on the probe itself.

Probe /api/liveness instead of /api/health. Health awaits hardware detection through
_await_hardware_detection on purpose, so probing it every 15s bills the watchdog for the
warm thread's torch import. Liveness reads module-level caches only, which is what it was
added for. Backends older than the route answer 404, so fall back to health, in the same
order process::generic_backend_health_ok and desktop_backend_owner::fetch_liveness use,
and accept "alive" or "healthy" so a downgrade still validates.

Raise the per-probe budget from 2s to 10s. The C-extension imports hold the GIL and the
process can go quiet for seconds at a time on a cold start (3735ms measured on /api/health,
with a ~27s silence around it). preflight::backend keeps its own 2s: a timeout there
dead-ends the launch instead of retrying, and the backend derives _HEALTH_DETECT_BUDGET_S
from that number.

Stop one early reply ending the startup grace for good. A backend that answers now can
still miss the next three probes while it loads a large model, so has_seen_healthy is set
only once a reply says the hardware verdict has settled. /api/liveness now carries the same
hardware_detecting marker health publishes, plus hardware_detection_deferred when the warm
is switched off and nothing will ever settle it; a backend too old to send either reads as
settled, which is what the launcher assumed before.

Adds the regression test #7958 landed without: the failure policy replayed against the
reported timeline, and the probe covered against a stub backend on both routes.

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

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

* Studio: accept either password env var in the macOS tab smoke

The repo's macOS workflow exports STUDIO_OLD_PW; the staging harness exports
STUDIO_PW. Reading only the first made the script KeyError before it reached the
backend under staging CI.

* Studio: make the new liveness and RAG tests hold on a macOS runner

Two assumptions that hold on this dev box but not on a bare macos-15 runner:

studio_root_id is environment-derived and is legitimately empty on a fresh
runner, so the liveness test asserts the key is present (which is what the
launcher reads) rather than that it is truthy.

python.org macOS builds ship a sqlite3 without enable_load_extension, so the
healthy-path RAG test cannot open a connection at all and errored in fixture
setup. It skips there. The unavailable-path tests, which are the ones this PR
changes behaviour for, still run everywhere.

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

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

* Studio: drop the MLX self-heal venv-root retry, which cannot work

Codex was right that passing the venv root to uv does not recover a dangling
bin/python. Confirmed against uv directly, with a venv whose interpreter symlink
was pointed at a missing target:

  --python <venv>/bin/python    error: No virtual environment or system Python
                                installation found for path ...
  --python <venv>               error: No virtual environment or system Python
                                installation found for directory ...
  VIRTUAL_ENV set, no --python  error: Failed to inspect Python interpreter from
                                active virtual environment

All three fail the same way, so the fallback and the retry were a placebo: when
the first attempt already resolved to the venv root the retry was skipped, and
when it ran it repeated a call that cannot succeed.

Keep the part that does work. uv refusing the environment means the venv is
broken rather than merely missing MLX, and nothing this process can pass to an
install command fixes that, so the warning names 'unsloth studio update' (which
rebuilds the environment) instead of uv's own 'uv venv' suggestion, which would
build one Unsloth does not manage.

_venv_root stays: it names the environment in that message.

* Studio: hold the startup grace for the whole warm, not just hardware detection

The health watchdog ends its five minute startup grace once a liveness reply says
the backend is no longer warming, and that signal was `hardware_detecting`. But
hardware detection is only the first of utils/torch_warmup.py's stages: the marker
disappears while inference_backend, transformers, datasets and unsloth_zoo are still
importing, and those are the C-extension imports that hold the GIL longest. So the
grace could end mid warm and a stall spanning three 10s probes would count as three
dead probes against a backend that was starting normally, which is the
unresponsive_health_check kill the grace exists to prevent.

Publish `torch_warm_in_progress` on /api/liveness and /api/health, derived from
warm_status(), and read that in the launcher.

A new field rather than a wider `hardware_detecting`: that marker also means "this
hardware verdict is provisional, re-read it", and config/hardware-verdict.ts keeps
the UI provisional and polling while it is set, so keeping it lit through datasets
would hide Train for the whole warm over a verdict that settled seconds in.

Additive and backwards compatible both ways. The launcher keeps its
`hardware_detecting` path as a fallback, so a backend that predates the new field
still gets the grace it gets today, and a backend that sends it talks to an older
launcher exactly as before.

The deferred case is preserved by construction: the field is published only while a
warm thread is actually running, so it is absent both when the warm finished and
when none is coming. UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 never starts one, and a
warm retired mid stage by a shutdown never sets finished; deriving the field from
"not finished" would have reported either as warming forever and held the grace
open until it expired on its own.

* Studio: poll the hardware verdict out of its unknown state on every platform

Train and Video now spin instead of graying out while the verdict is unmeasured,
so something has to end the spin. fetchDeviceType spends its bounded wait at most
once per page load, so a host that detects slower than that keeps the provisional
reply and `fetched` stays false. The sidebar's recovery poll returned early unless
the host was chat-only or deferred, which off macOS it is not: the store seeds
chatOnly from the user agent, so on Linux and Windows nothing re-read /api/health.
A cold GPU host importing torch sits squarely in that window, and there the rows
spun and /studio held its loading panel until the user navigated or reloaded.

Poll while the verdict is unknown too, on any platform, and keep the mlx_unavailable
and deferred cases as they were. The poll re-reads with force, which is what gets
past the cached provisional reply and the spent wait latch, and the effect re-runs
when the verdict lands, so the interval is cleared as soon as it is known. Re-reads
are skipped while one is outstanding, bounded so a request that never settles cannot
hold the poll off.

studio-page and video-page gate on the same store value, and the sidebar is mounted
on both routes, so they recover with it rather than growing a second poll.

Covered by tests/hardware-verdict-recovery.test.ts, which drives the real store
through a slow non-Mac detection and asserts the guard arms on the unknown state.

* Studio: teach the run-module test stub about the new loggers export

run.py imports install_uvicorn_duplicate_exception_filter at module scope, and
load_studio_run_module replaces 'loggers' with a stub module carrying only
get_logger, so importing run raised ImportError before any test body ran:

  ImportError: cannot import name 'install_uvicorn_duplicate_exception_filter'
  from 'loggers' (unknown location)

A no-op is the right stub here. The filter only de-duplicates uvicorn's copy of a
traceback and this module never starts a server.

* Studio: run the tab-capability smoke in the macOS UI job

Codex was right that the script was dead code: nothing under .github invoked
playwright_mac_tab_capabilities.py, so the regression coverage it claims never
executed and the tab blackout could come back unnoticed.

It gets its own boot on a cold port. The assertion is that Train and Video spin
rather than grey out while the verdict is unmeasured, and that window only exists
on a backend that has not warmed yet, so reusing the already-warm 18897 server
would have passed vacuously. For the same reason this phase deliberately does not
wait for a healthy backend first, unlike every other phase here: waiting is how
you miss the window. The script does its own wait, and health answers
provisionally inside a 1s budget.

The liveness poll is cut from its 330s default to 120s here. The full window
exists to outlive the launcher's 300s startup grace, and nothing in this job runs
that watchdog; it boots 'unsloth studio' directly. Spending five macOS-runner
minutes to prove something this job cannot observe is not worth it, and the
watchdog is covered by the Rust tests in commands.rs.

* Studio: let /video reach its own gate, and stop stale polls freeing the guard

Two review findings on this branch.

/video was still outside CHAT_ONLY_ALLOWED, so on a measured chat-only host, a
CPU-only box or a Mac without usable MLX, a direct link or a reload bounced to
/chat before VideoPage could render. That is exactly where the unsupported
explanation this branch added has something to say, so the message was
unreachable in the only cases it was written for. Video now follows /export: the
route is allowed through and the page self-gates on the backend's video verdict,
so nothing loads on a host that cannot run it.

The recovery poll's no-stacking guard could be freed by a read that no longer
held it. A read outliving the 30s stall window is abandoned and the next tick
starts a replacement, but the abandoned read's finally still zeroed the shared
marker, so every following tick saw a free guard and fired another forced
/api/health. On the backend this poll exists for, one still importing torch, that
is a read every three seconds piled onto the process being waited for. A
generation counter now means only the owning read can clear it.

Both regressions verified to fail without the fix: 3 of 754 tests go red.

* Studio: make the tab-capability smoke fail instead of passing vacuously

The first staging run went green having tested nothing. The log tells the story:

  [mac-tabs] spinner observed during warm: {'train': False, 'video': False}
  [mac-tabs] Train: redirected to http://127.0.0.1:8888/login ... (allowed)
  [mac-tabs] Train: nav row not pinned inline; reached by route instead
  [mac-tabs] PASS

The login form takes a username as well as a password and the script filled only
the password, so the submit was a no-op and the browser stayed signed out. Every
later check then read an empty shell: no sidebar, so no nav row, so no assertion
had anything to act on. The redirect check called the bounce to /login 'allowed'
because it only asked whether the verdict was measured, and the row checks
downgraded a total miss to an info line.

Three changes, all so this run would have gone red:

log_in fills the username, then proves the session took by navigating to /chat
and checking it stayed; a failure there aborts rather than continuing into checks
that cannot mean anything.

A bounce to /login, /onboarding or /change-password during the tab walk is a
failure. The session was proven live before the walk started, so losing it
mid-walk is never an allowed redirect.

A walk that never locates a single nav row now fails. That is the shape a
signed-out or unrendered run takes, and it has to be loud.

The survival poll was the one part that did mean something: 65 samples on each of
/api/liveness and /api/health, zero non-200, backend alive past 319s.

* Studio: make RAG unavailability one coherent state across the router

Degrading the KB list to an empty response when sqlite-vec's native library
cannot load stopped the 500-plus-traceback on every poll, but it left the rest
of the router behind: the frontend read the empty list as a working empty
state, offered Create, and the POST went straight to get_connection() and
raised. So the fix for the log spam reintroduced the log spam one click later,
and told the user nothing.

One contract now. The polled KB list still degrades, but carries ragAvailable
and ragUnavailableReason so a client can tell "no knowledge bases yet" from
"RAG cannot run on this machine". Every other endpoint answers 503 stating the
same reason, via a rag_available() gate up front and a _rag_connection()
wrapper that catches the case where the first request of a session is the one
that discovers the library is missing. That wrapper also reaches the
connections ingestion opens for itself, and removes an upload it had already
saved rather than orphaning it in the uploads root.

The two new fields are additive and the document listings are deliberately
left erroring rather than degrading, so a frontend that has not learned to read
the marker keeps every error surface it has today.

rag_available() remembers only that the extension loaded, never that it failed,
so a one-off cannot latch RAG off until restart. The warn-once stays: a 503
path that logged per request would be the same regression in a new place.
Genuine database errors are untouched and still surface as real errors.

reconcile_orphaned_ingestion_jobs() gates on rag_available() too, so it is the
no-op its docstring already claimed instead of raising out of startup to be
logged as a reconcile failure.

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

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

* Studio: let the UI read the RAG availability the backend already reports

routes/rag.py answers a host where sqlite-vec will not load as a contract: the
polled KB list degrades to 200 with ragAvailable/ragUnavailableReason beside an
empty list, and every other endpoint answers 503 with the same reason. The client
dropped both, so a broken-RAG Mac still showed an apparently working, empty
Knowledge bases page whose Create button could only 503.

features/rag/api/rag-availability holds the verdict, modelled on the platform
store in config/env: optimistic until the backend has actually answered, so a
slow first poll, an unreachable server and a backend that predates the marker
all render exactly as they do today. Only a measured unavailable gates anything.

Wired in at every response path, including the two that bypass ragRequest
(ragUpload, streamJobEvents), so a user who lands on a mutating route first gets
a coherent UI instead of waiting for the list poll. listKnowledgeBases reads the
marker, which is the only way to tell an empty store from a host where RAG cannot
run. The dialog then disables New knowledge base and Create/Save, guards
submitForm for the keyboard path, and shows the backend's reason in place of
"No knowledge bases yet.".

Also breaks a hang this condition triggers. targetHasIndexingDocuments answers
"still indexing" whenever its listThreadDocuments probe throws, and
dispatchQueuedPrompt reschedules on that with no cap, so on a broken-RAG host a
queued prompt on a thread using documents was never dispatched at all. A 503 is
now distinguishable, and there are no documents to wait for, so it sends. A
transient failure still holds the prompt back as before.

This is deliberately not folded into useRagToolDisabled: that is a model
capability gate and is false when no model is loaded.

* Studio: report video as macOS-unsupported on Intel Macs too

video_capability() keyed the macOS branch on is_apple_silicon(), so an Intel Mac
fell through to pytorch_not_installed or no_accelerator and was told to install
PyTorch or add a GPU. Neither enables video: the diffusers pipelines have no
supported macOS path at all, so both Macs get the same honest answer.

The accompanying AST test also asserted is_apple_silicon() was named in the
function, which kept passing on the word surviving in a comment. Strip comments
before asserting so it tests the gate rather than the prose.

* Studio: only the backend's own 503 detail is a RAG capability verdict

A 503 is what a reverse proxy, Cloudflare or a briefly overloaded server returns,
and those bodies say nothing about sqlite-vec. Recording one as unavailable gated
the Knowledge bases dialog for the rest of the session behind a transient outage,
explaining it with an extension failure that never happened -- and only a 2xx from
a gated endpoint could clear it.

Match the RAG router's own wording instead. A bodyless or unrelated 503 now leaves
availability unknown, which is what the store was built to represent.

* Studio: fix the macOS tab smoke signing in, which made every assertion vacuous

The helper read the password field with count() straight after domcontentloaded.
auth-form.tsx returns null while the auth-status request is in flight, so there is
no form in the DOM yet, count() does not wait, and the run fell through to
'assuming desktop auth' and checked an empty signed-out shell. It also filled a
username the login form does not have and clicked a button labelled 'Sign in'
when the label is 'Login'.

Wait for #password, click Login, and wait for the post-auth route. Verified against
a live Studio: the helper now reports 'signed in' where it previously did not.

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

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

* Studio: make the tab-capability smoke create the window it asserts on

Second hole of the same shape in this file. assert_row_never_greyed_while_unmeasured
computed seen_spinner and only logged it, so when the verdict settled before the
browser arrived the loop broke on its first iteration, nothing was sampled, and the
run went green having observed nothing.

Requiring the window would not have fixed it, because the window is not there to be
required. hardware_detecting covers stage 0 of the warm, and on the macOS runner's
--no-torch install with no MLX that stage is a failed `import torch` plus one failed
metadata lookup: it settles inside a second of the port binding, while Playwright is
still launching Chromium. Add the login and two navigations, one of which spends the
frontend's own 5s wait on the verdict, and the sampler arrives 15 to 20s late on every
host, fast or slow. The workflow comment claiming a cold boot keeps the window open
was wrong; it is corrected to say what boot ordering actually buys, which is a
provisional reply to the first /api/health probe and nothing past it.

So the script opens its own window instead of racing that one. It answers the
browser's /api/health with a real reply that has the measurement taken back out, holds
it there, and requires nav-row-train to render enabled with data-spinner="true", the
way pending beats disabled in resolveNavRowState. That window is open for as long as
the check needs, on any host, and a missing row fails rather than skips, so there is
no path through it that reports success without having read the row. The real warm is
still sampled and a real grey-out still fails, but nothing is required of it.

Two more things in here could observe nothing:

The Video half of this file was structurally empty. Video is not pinned inline
(SIDEBAR_NAV_DEFAULT_PINNED), so it renders inside the More dropdown, which mounts
nothing until it is opened and carries no data-testid even then. Every query for
nav-row-video returned null on every host, so seen_spinner["video"] could never be
true and "Video: nav row not pinned inline" was a permanent info line, not a finding.
Train carries the same pending flag through the same resolver, so it is the observable
end of that wire; the rows the sidebar does pin are now named, an inline row that does
not render is a failure, and the More rows are documented as expected misses.

_saw_any_row was satisfied by any row on any route, and the sampler swallowed a dead
page with a bare `except Exception: break`. Now the walk requires every default-pinned
row to have been seen, and a page that cannot be evaluated at all fails.

tests/studio/test_mac_tab_capability_warm_window.py drives all of this with the page
and the backend stubbed, so the red cases are checked in the tests/ walk rather than
only on a macOS runner. Against the previous file, its first case reproduces the
staging log exactly: "spinner observed during warm: {'train': False, 'video': False}",
then PASS.

* Studio: give an adopted backend the startup grace when it is still warming

The watchdog already knows that one healthy answer is proof of life and not proof
that startup is over, but only on the path where this app spawned the backend. An
adopted backend starts with the latch already set, on the reasoning that it was
serving before the app attached. That is the same fallacy: a force-quit during a
cold start leaves the backend running and still importing the ML stack, and the
relaunched app adopts it. Three GIL-stalled probes later it was cleared and the
user got a crash screen for a host that was starting normally.

The ownership probe carries no warm-up signal, so ask the backend directly once
ownership is verified, and clear the latch on a warming reply rather than merely
declining to set it. The grace stays bounded at 300s either way.

This path predates the fix on the owned side; it is the other half of the same
report rather than a regression.

* Studio: fix five guards in this PR's own tests that could not fail

Each was verified by mutation: the regression the test names was applied, the
suite stayed green, and it now goes red.

- provisional-hardware-verdict: the slice end was located by searching for the
  latch it then asserted was absent. String.search returns the first match, so
  moving the latch into the loop moved the boundary with it and the assertion
  passed against exactly the regression its header describes. Anchor on the
  loop's closing brace, and require the latch to exist after it so deleting it
  outright is not a pass either.
- liveness warm state: the subprocess harness builds its result with
  body.get(studio_root_id), so the key is present whether or not the reply
  carried it. Deleting the field from liveness_check() left the test green.
  Report presence explicitly, as the two neighbouring fields already do.
- warm window, DEVICE guard: asserted a count of at least two when the function
  has three comparisons, so deleting the poll loop's requirement still left two.
  Bind to the sites instead of counting them.
- warm window, boot silence: scanned only for _fetch_top_models, but the fetch
  is started through the public wrapper now, so putting _start_top_models_fetch
  back in the constructor restored the huggingface.co call on every boot
  undetected. Check both names.
- rag availability: asserted only after noteRagAvailability, which writes
  unavailable unconditionally, so the exemption under test could be deleted.
  Assert between the two calls.

Also replaces a dead 'disabled: chatOnly;' string check: that is an object entry,
so the regressed form ends in a comma and the literal could never match.

* Studio: deliver the hardware verdict to a component that subscribed a tick late

useHardwareInfo seeds its state from the module cache during render, but joins
the listener set in the effect. A probe resolving between those two points
notifies the listeners registered at the time, which does not include this one,
and leaves the cache set, so 'if (!cached) load()' skipped the fetch as
redundant. Nothing remained that would ever call setInfo, and the component sat
on the unloaded default for its whole life.

That was survivable while callers read individual fields. This PR gates whole
pages on 'loaded', so it now reads as 'Checking this machine for video support'
for the rest of the session, which is the stuck-loading state the PR exists to
remove. Hand the cache straight to the listener instead.

Also stops a successful 200 that a later invalidate superseded from resolving as
the unloaded default, which load() reads as a failed probe.

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

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

* Studio: complete the forced password change in the macOS tab smoke

The repo's own macOS smoke hands this script the raw bootstrap password, and a
backend that still holds one injects it into the page and signs itself in, landing
on /change-password with no login form ever rendered. This script treats that as a
signed-out route, since it has no sidebar to assert against, so the run failed with
'could not sign in'. The staging harness rotates over the API before driving, which
is why the same script passed there and failed here.

Rotate it in the browser instead of giving up, and check for that screen before
waiting on the login field so an authenticated session does not spend a minute
waiting for a form that is correctly absent.

Verified against a live Studio on both paths: a fresh instance holding its
bootstrap password now reports 'password rotated' then 'signed in' where it
previously reported 'could not sign in', and an already-rotated instance still
signs in through the login form.

* Studio: give the adopted ownership probe the watchdog's probe budget

The warm-up read added for adopted backends is gated on ownership verifying, and
that probe runs every request at the 2s default. During the multi-second GIL stall
the watchdog exists to ride out, both requests inside it time out, the backend
comes back unverified, and the warm-up read never runs at all, so the grace never
reopens and three stalls still clear the backend. The longer budget has to be
applied before verification, not after it.

probe_owned_backend_state keeps its signature and delegates to a variant taking an
explicit budget, so only the watchdog changes. A guard binds that call site to
HEALTH_PROBE_TIMEOUT and fails if it drifts back to the default.

Also uploads the tab-capability phase's log and Playwright evidence, which the
artifact list did not name, so a red macOS run discarded the only record of it.

* Studio: match the RAG 503 on the extension name only

Matching either fragment meant the loose half carried the same weight as the
specific one: anything RAG-aware in front of the backend can answer a transient
503 saying RAG is unavailable without meaning the extension, and that persisted a
capability verdict which only a later successful gated request could clear.

Keep sqlite-vec and drop the English phrase. Nothing upstream emits a package name
by accident, and the capability being gated is exactly that extension, so this is
narrower than requiring both fragments would be while still tolerating a reworded
backend detail. Requiring both would have made the matcher brittle to precisely the
rewording the fragment match exists for.

Two proxy phrasings added to the generic-503 case, which restoring the loose marker
now fails.

* Studio: stop the MLX install env claiming a recovery it does not perform

_mlx_install_env's docstring still described VIRTUAL_ENV as the second half of a
dangling-symlink recovery, and named the helper that performed the first half.
That helper was deleted earlier in this PR when the retry was found not to work,
but the claim outlived it and is false: an explicit --python outranks VIRTUAL_ENV,
so uv reports the same unresolved-interpreter error either way.

It is not a harmless stale comment. It is the tree asserting a repair that does not
happen, and a reviewer reading it asked for the mechanism to be restored. State
what uv actually does, and why --target and --prefix are not the escape hatch they
look like: both exit 0 against a broken venv but resolve against whatever ambient
interpreter uv finds, writing a wrong-ABI or off-sys.path install that leaves
mlx_stack_available() False while looking like success.

Two guards: the deleted helper must stay deleted and the claim must not come back,
and the unresolved-interpreter path must stay one attempt plus a diagnosis, never a
second install with a different target.

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

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

* Studio: make the watchdog-budget guard line-ending agnostic

include_str! embeds the file exactly as checked out, so on Windows the source is
CRLF and the \n}\n search for the end of check_watchdog_health never matched. The
guard panicked on the Tauri CI runner while passing on every Linux and macOS job.
Normalise before searching.

Verified both ways: 149 tests pass on the LF tree, and the guard still passes after
converting the file to CRLF, which reproduced the runner failure before this.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-07 02:45:06 -07:00
Daniel Han
2d231f8845
test: isolate the no-version gfx-override test from a real /opt/rocm (#7397)
---------

Co-authored-by: LeoBorcherding <borchborchmail@gmail.com>
2026-08-06 23:29:22 -03:00
Tai An
0c12d6473f
fix(studio/rocm): don't install for a shadowing iGPU on mixed AMD hosts (#7776) (#7778)
* fix(studio/rocm): don't install for a shadowing iGPU on mixed AMD hosts (#7776)

On a board with both an AMD APU and a discrete Radeon, HIP enumerates the
iGPU first, so _detect_windows_gfx_arch picked index 0 and the installer
pulled the iGPU's wheel family -- a gfx1036 Raphael iGPU shadowing a
gfx1200 RX 9060 XT, leaving the discrete card unused until the reporter
set HIP_VISIBLE_DEVICES=1 by hand.

When no visible-device mask is pinned and more than one distinct arch is
enumerated, skip a leading shadowing APU arch so the discrete card decides
the wheel family, and print which GPU was chosen plus the
HIP_VISIBLE_DEVICES override. An explicit HIP_VISIBLE_DEVICES /
ROCR_VISIBLE_DEVICES value still wins verbatim.

The Strix arches (gfx1150/1151/1152) are deliberately excluded from the
skip set: they are first-class unified-memory training targets, so their
selection is unchanged.

Signed-off-by: Tai An <antai12232931@outlook.com>

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

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

* fix(studio/rocm): mirror the dGPU repick in setup.ps1 and honour CUDA_VISIBLE_DEVICES

Addresses both review findings on #7778.

1. setup.ps1 resolved the gfx arch itself (hipinfo and amd-smi paths) and built
   $ROCmIndexUrl from it *before* invoking install_python_stack.py, and
   _ensure_rocm_torch() returns early once UNSLOTH_ROCM_TORCH_INSTALLED=1 -- so a
   fresh Windows install on a gfx1036 + gfx1200 host still received gfx103X-all
   wheels and never reached the Python-side repick. Resolve-ShadowingGfxPick
   mirrors _dedup_pick() and is applied at both PowerShell pick sites.

2. HIP honours CUDA_VISIBLE_DEVICES with the same semantics as its own masks --
   _pick_rocm_gfx_target in install_llama_prebuilt.py already resolves all three
   identically -- so a ROCm install launched with only CUDA_VISIBLE_DEVICES set
   was treated as unpinned and could be overridden by the iGPU skip. It now
   counts as a pin on both sides ("" / "-1" still mean "no mask").

Tests: CUDA_VISIBLE_DEVICES pin + empty-is-not-a-pin cases, a setup.ps1 <-> Python
parity check on the shadowing-arch list (the list now exists in two places), and
the pre-existing shadowing tests now clear CUDA_VISIBLE_DEVICES so CI runners that
export it cannot flip the assertions.

Signed-off-by: Tai An <antai12232931@outlook.com>

* fix(studio/rocm): keep a supported APU over a discrete card with no Windows wheels

The shadowing-iGPU preference returned the first non-integrated arch in the
enumeration regardless of whether AMD ships Windows wheels for it. On an
unpinned gfx1036 + gfx1010 host that deposed a supported Raphael APU for a
discrete card absent from _GFX_TO_AMD_INDEX_ARCH, so _windows_rocm_index_url
resolved to None and the install fell back to CPU -- strictly worse than the
shadowing the preference exists to undo.

Only prefer the discrete arch when it actually has an index, unless the
integrated pick has none either, in which case the swap costs nothing and the
discrete card still wins.

Both directions are covered: gfx1036+gfx1010 keeps the APU (fails without this
change, returning gfx1010), gfx1013+gfx1010 still yields to the discrete card
so the guard is not over-tightened.

Signed-off-by: Tai An <antai12232931@outlook.com>

* fix(studio/rocm): close two setup.ps1 gaps in the shadowing-iGPU preference

Both halves of the #7776 preference existed in install_python_stack.py but only
half of it in the PowerShell mirror, which resolves the arch and builds
$ROCmIndexUrl itself before the Python installer ever runs.

- Resolve-ShadowingGfxPick deposed a supported APU for any discrete arch, even
  one AMD ships no Windows wheels for (gfx1036 + an older gfx1010): the repick
  resolved to no index at all and dropped the host to CPU, strictly worse than
  the shadowing it undoes. It now consults $archFamilyMap, mirroring the
  _pick_has_wheels guard in _dedup_pick(). The map moves to script scope so
  detection can read it; contents are unchanged, so the four-way parity test
  still sees the same 18 entries.
- The WMI fallback took the first AMD adapter before name -> arch inference, so
  an Adrenalin-only host listing a 780M ahead of an RX 9060 XT still inferred
  gfx1103 and installed gfx110X-all. It now keeps every AMD adapter, infers an
  arch for each, and runs the same preference over the list.

Regression tests fail on the previous revision and pass on this one.

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

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

* fix(studio/rocm): index the enumeration with CUDA_VISIBLE_DEVICES too

_visible_devices_pinned() treats CUDA_VISIBLE_DEVICES as a pin, but
_pick_visible_index() only read HIP/ROCR. On the probes that enumerate
every GPU regardless of the masks (amd-smi, WMI), CUDA_VISIBLE_DEVICES=1
on a gfx1036 + gfx1200 host therefore suppressed the shadowing skip *and*
resolved to index 0, installing the iGPU's wheels for the device the user
masked away. Same mismatch in setup.ps1's $_hipVisIdx and $visGpu picks.

All three masks are now read at every site, matching _pick_rocm_gfx_target
in install_llama_prebuilt.py. Also corrects the _detect_windows_gfx_arch
docstring, which still claimed the first GPU always wins without a mask.

Signed-off-by: Tai An <antai12232931@outlook.com>

* Share one mask resolver across every ROCm pick site for PR #7778

The shadowing-iGPU preference was correct, but each pick site still resolved
HIP/ROCR/CUDA_VISIBLE_DEVICES with its own inline expression and they disagreed,
so a mask the pin check honoured could resolve to a different GPU than the one
the user asked for. On a mixed host that lands on index 0, which is the iGPU the
preference exists to skip.

setup.ps1
- Add Resolve-VisibleGpuIndex and use it at all four pick sites (hipinfo,
  amd-smi list, amd-smi static --asic, WMI name inference). Previously the
  hipinfo expression rejected " 1 " and the amd-smi one rejected "1,0".
- The static --asic branch now collects every gfx token and runs the repick
  instead of taking the first regex match.
- WMI inference indexes the adapter list rather than the inferred arch list, so
  an unrecognised name cannot shift a mask onto the wrong physical card, and it
  only repicks when every adapter mapped: an unknown name may itself be the
  discrete card.
- Filter WMI adapters on ConfigManagerErrorCode so a disabled or driver-errored
  Radeon cannot depose a working iGPU. Get-CimInstance to match the rest of the
  repo.

install_python_stack.py
- _pick_visible_index now skips "" and "-1" and reads the next mask, matching
  _visible_devices_pinned. Before, HIP_VISIBLE_DEVICES="" with
  CUDA_VISIBLE_DEVICES=1 counted as pinned while the index resolved to GPU 0.
- Out-of-range and unparseable masks warn instead of silently using GPU 0.
- Strip the ":sramecc+:xnack-" suffix from gcnArchName like setup.ps1 does; a
  suffixed token matched neither the wheel table nor the skip set.
- Prefer a wheel-backed candidate whenever one exists, not only when the picked
  arch has wheels, so gfx1036 + gfx1010 + gfx1200 no longer stops at gfx1010
  and drops the host to CPU torch.

Arch list
- Drop gfx1037: it is not an AMDGPU target in LLVM, so no Windows tool emits it.
- Add gfx1033 (Van Gogh) and gfx1153 (Krackan Point 2), both APUs. gfx1033 has a
  wheel family, so leaving it out let it act as the "discrete" card.
- gfx1013 is Cyan Skillfish, not Van Gogh.

Both advisories now tell the user to setx HIP_VISIBLE_DEVICES so the chosen GPU
is used at runtime, not just at install time: the wheels alone do not change
which device HIP enumerates first.

Tests
- TestSetupPs1ShadowingBehaviour actually executes Resolve-ShadowingGfxPick and
  Resolve-VisibleGpuIndex under pwsh, slicing them out by AST. The existing
  parity class only greps text, so a rename failed it while a semantic bug
  passed.
- Regression tests for each fix above.

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

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

* Warn on a pinned wheel-less GPU and silence a bogus range warning

Both found by simulating the change across the OS x GPU-vendor product rather
than by reading it.

- _dedup_pick now says so when an honoured pin selects a GPU AMD ships no
  Windows wheels for while another enumerated GPU has them. The pin is still
  honoured verbatim, but the install drops to CPU torch and the mask is the
  reason, which was previously invisible.

- _pick_visible_index takes warn=False for callers whose list is deduplicated.
  The Linux Strix reroute indexes _detect_amd_gfx_codes(), which collapses
  duplicates, so a dual same-arch box (two gfx1151) has a 1-element list and a
  perfectly valid HIP_VISIBLE_DEVICES=1 read as out of range. That printed a
  false "out of range" warning on a healthy Linux host. The Windows
  arch-selection path still warns, where the index space really is devices.

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

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

* Align the visible-device masks with the ROCm runtime, and two repick fixes

Three review items on the last round, all confirmed against the code at head.

Mask semantics (mine to fix: 6d0ac82 got this wrong).
"" and "-1" do not mean "no mask", they select no GPU at all, so falling
through to the next variable was wrong. The ROCm runtime stores an explicitly
empty var as " " (clr flags.cpp), then picks the HIP mask whenever its first
byte is not NUL (paldevice.cpp on Windows, rocdevice.cpp on Linux), so an empty
HIP_VISIBLE_DEVICES shadows CUDA_VISIBLE_DEVICES rather than deferring to it;
parseRequestedDeviceList surfaces zero devices for " " and "-1", which ROCR
states outright in amd_filter_device.h. _visible_devices_pinned and
_pick_visible_index are now first-set-wins and treat any set value as a
deliberate selection, matching _pick_rocm_gfx_target in install_llama_prebuilt.py
and PyTorch's own _parse_visible_devices. Resolve-VisibleGpuIndex and
Resolve-ShadowingGfxPick mirror it. Three tests asserted the old premise and now
assert the runtime's.

Resolve-ShadowingGfxPick did not prefer wheel-backed cards when the APU has no
wheels either. The predicate went vacuously true and took the first
non-integrated arch, so gfx90c,gfx1010,gfx1200 picked gfx1010, left
$ROCmIndexUrl null and installed CPU torch despite the supported gfx1200. Now
mirrors _dedup_pick's `_withWheels or (...)`.

The Python WMI probe listed disabled adapters. setup.ps1 filters on
ConfigManagerErrorCode but `(Get-CimInstance Win32_VideoController).Name` did
not, so on a driver-only laptop a disabled RX 9060 could depose a working 780M
and pull wheels for a GPU Windows never exposes. Same filter both sides.

* Stop double-applying the mask to hipinfo, and two selection fixes

hipinfo is itself a HIP application, so under a mask the runtime filtered and
renumbered its device list before we ever read it. Indexing that output again
applied the mask twice: with HIP_VISIBLE_DEVICES=1,0 on a gfx1036 + gfx1200
host, HIP exposes [gfx1200, gfx1036] and the second lookup landed on the iGPU,
installing its wheel family for the card the mask put first. _dedup_pick now
takes mask_resolved for the hipinfo probe and setup.ps1 reads $_hipAllArches[0];
amd-smi and WMI list every GPU regardless of the masks, so they keep the
explicit index. The repo already stated this in _hip_visible_device_mask_set:
"hipinfo, itself a HIP application, so under a mask it enumerates the VISIBLE
devices, not the physical ones".

The advisory hard-coded device 1. On gfx1036,gfx1010,gfx1200 the selected card
is device 2, so following the message exposed the gfx1010 the installed wheels
do not target. Both messages now name the selected arch's real index.

The WMI path substituted another adapter's arch when the selected one had an
unrecognised name. Unpinned that is the point (the #7776 iGPU has no entry in
the name table, so the named discrete card should decide), but under a mask it
installed wheels for a GPU the user masked away. The fallback is now gated on
Test-VisibleDevicesPinned, which also replaces the inline pin loop in
Resolve-ShadowingGfxPick so both sides share one definition.

Two existing tests described a host that cannot exist: unfiltered hipinfo output
under a mask. They now model the filtering HIP actually performs.

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

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

* Keep setup's dGPU repick for llama.cpp, and index WMI by adapter

* Reinstall Windows ROCm torch when the wheel family changes

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

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

* Keep the WMI arch probe silent on non-AMD adapters

* Read the active ROCm family from the rocm meta-package

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

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

* Resolve the visible-device mask against devices, not deduplicated arches

* Harden the WMI probe and the PowerShell index parse

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

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

* Pin the platform in the two new Linux reroute tests

* Pin the arch too in the new Linux reroute tests

* Parse rocminfo per agent and honour ROCR filtering on Linux

* Tighten comments

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-06 05:46:38 -07:00
oobabooga
f8730f4339
Installer: select CUDA wheels that cover the host's GPUs (#7814)
* Installer: select CUDA wheels that cover the host's GPUs

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

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

* Fix Windows venv wipe and warning dedupe for PR #7814

- Windows pins torch<2.11, whose cu128 still ships sm_70, so capping a Volta
  to cu126 there rewrote a working family. The stale-venv check then read that
  as drift and deleted the venv on a direct "unsloth studio update", which
  cannot recreate it. Make the pre-Turing floor per-family (70 for cu128).
- Repair an unpinned cu* -> cu* move in place instead of rebuilding the venv.
- Decide the cu126 advice before deduping the uncovered-host warning: the host
  facts are release invariant but the artifact list is not, so the release
  walk-back let an unhelpful release swallow the remedy.
- Gate the new coverage repair and the cu126 advice on x86_64, matching the cap.
- Add tests/studio/test_pre_turing_cap.ps1: the parity test only greps for the
  call spelling, so neither PowerShell copy had behavioural coverage.

* Tighten comments for PR #7814

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-04 06:55:29 -07:00
Daniel Han
9b452cb3b3
studio: pair slim whisper bundles on the ggml tree id, not the -mix- tag suffix (#7817)
* studio: pair slim whisper bundles on the ggml tree id, not the -mix- tag suffix

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

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

* Tighten the comments added by this PR

* Backfill ggml_tree into the marker when an existing llama install is reused

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

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

* Backfill on the third reuse path, gate the tree on its binary source, guard malformed trees

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

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

* Pair only on a declared ggml tree, and refresh a tree-less install so that stays recoverable

* Withdraw the forced marker migration; it broke the combined update path

* Tighten the comments added across the review rounds

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-04 00:52:26 -07:00
Daniel Han
cc0185fbb1
Windows: guard the source-build and whisper.cpp probes on an unreadable install tree (#7757)
* Windows: guard the source-build and whisper.cpp probes on an unreadable install tree

Follow-up to #7735, which routed the prebuilt llama.cpp probes through
three-state path probing but left two gaps.

Phase 4 read $LlamaServerBin with a bare Test-Path under "Stop". A forced
compile, a pinned PR or a custom llama source skips Phase 3.4 entirely, so on
those routes this was the first probe inside the tree and a denied build\
aborted with the raw "Test-Path : Access is denied" the merged PR set out to
remove. It now probes three-state, and the CMakeCache.txt read below it is
guarded too: a listed file can still deny the read, which the probe cannot see.
The probe is skipped for a linked UNSLOTH_LOCAL_LLAMA_CPP_DIR, where it would
read through the junction into the user's own checkout, and the denial reports
-OwnershipUnverified under a custom home, where nothing on this route has
proven the tree is ours.

The whisper.cpp phase promises failure is never fatal, but under a custom
UNSLOTH_STUDIO_HOME an unreadable tree exited the whole run, taking llama.cpp
inference down with it. Assert-StudioOwnedOrAbsent gains a -NonFatal mode that
hands the denial back instead; an unowned tree still stops. The check stays
behind the installer-exists gate it used to sit inside, so a tree without
install_whisper_prebuilt.py remains the no-op it was.

Backend: _is_runnable let Path.is_file() propagate EACCES. Now that setup
leaves a denied whisper.cpp in place, that turned into a 500 out of
/api/inference/audio/stt/status, the one endpoint reporting both dictation
engines, so the setup message promising Transformers dictation still works was
not true. It reads as engine-unavailable instead.

* Harden the denial contract tests against surviving mutations

Mutation testing found six ways to reintroduce the bugs this branch fixes
while the tests stayed green.

Assert-StudioOwnedOrAbsent: the -NonFatal returns were counted, not ordered.
Moving one below its Exit-PathAccessDenied makes it dead code and the whisper
phase fatal again; hoisting one above the custom-home gate reports a fresh
install as unreadable. Each return is now pinned immediately above the exit it
pre-empts, with no unpaired return allowed.

The whisper denial branch had no assertion scoped to its own body. Both
phrases it was checked for already occur elsewhere in the phase, so the branch
could be turned back into an Exit-SetupFailure and stay green. The branch is
now sliced out and checked for step/Yellow, both phrases, and the absence of
any exit.

The installer gate was checked for presence, not for being a conjunct, so
-or-joining or negating it reopened the installer-less tree the test is named
for. The denial subject was unpinned, so it could name llama-server.exe and
tell the user to move aside one file instead of the tree.

Slice terminators are now asserted through one helper: an unasserted
terminator does not fail, it silently widens the window to end-of-file and
makes everything inside it near-vacuous.

The whisper binary probe test gated its only behavioural case on geteuid() == 0,
which silently drops it in any root container. It probes for a real denial
instead. Two pre-existing exact counts in the ownership guard tests become
floors: this branch consumed the last of their headroom, so the next
legitimate route added there would break two tests that say nothing about it.

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

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

* Fix the -NonFatal negative control for Windows ACL semantics

The control probed a marker file that did not exist. Windows reports a
missing child of a denied directory as absent rather than throwing, so the
control read as "this host cannot deny" and failed the suite on
windows-latest while passing under chmod on Linux. It now probes a file that
exists inside the locked tree, matching the control the suite already uses.

That difference also splits the routes by platform for a tree with no
ownership marker, which is the fresh custom-home case: Linux catches it on the
marker probe, Windows has to catch it on the adoptable-state read. Added a
case that accepts either route and rejects anything but Denied, so the Windows
one is exercised for the first time.

* Detect a denied tree that has no ownership marker on Windows

Staging CI on windows-latest caught this. Get-StudioAdoptableState decided
"denied" only from probes of two marker files inside the tree, but Windows
reports a MISSING child of an unreadable directory as absent rather than
throwing. A denied tree holding neither marker therefore returned "No", and
Assert-StudioOwnedOrAbsent fell through to "path is not an Unsloth-owned
install" and exited: the wrong cause, and fatal, on the only platform any of
this runs on. It also defeated the whisper -NonFatal path, since an unowned
tree is still fatal by design.

Listing the directory itself distinguishes "no markers here" from "cannot
look", so that is the fallback when neither probe reported a denial. A
readable tree with no markers still returns "No" as before, and the catch
swallows anything that is not a denial because this helper must not throw.

This also corrects the message a denied custom-home llama.cpp tree produced on
Windows, which reported the same wrong cause.

chmod 000 blocks the child probes outright, so it never reached the new code.
chmod 111 allows stat of a named child while forbidding a listing, which is
exactly the Windows shape, so the test now covers both and the negative
control fires only on the 111 case.

* Tighten comments in the denied-tree and whisper install changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-02 23:47:24 -07:00
Michael Han
ebfefcf84e
Windows: do not abort setup on an unreadable llama.cpp install (#7735)
* Windows: do not abort setup on an unreadable llama.cpp install

Test-Path raises UnauthorizedAccessException instead of returning $false
when an ACL denies the probe. setup.ps1 runs under $ErrorActionPreference
= "Stop", so the bare probe of UNSLOTH_PREBUILT_INFO.json in the llama.cpp
prebuilt phase killed setup with a raw "Test-Path : Access is denied" and
exit code 1. The desktop app had nothing but [TAURI:ERROR_DEFAULT] to fall
back on, so it showed "unsloth studio setup failed (exit code 1)".

~/.unsloth/llama.cpp sits beside the app, not inside it, so reinstalling
reused the unreadable folder and hit the same line again, including a
reinstall to a different drive.

Add Get-PathState (Present / Absent / Denied) plus Test-PathQuiet, route
the probes that read inside install trees we do not own through them, and
report a denied llama.cpp install through Exit-SetupFailure so the reason
and the recovery steps reach the desktop UI.

Reported in unsloth-test/unsloth-test#9

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

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

* Stop on every denied path, and split the recovery commands

Review follow-ups:

- Assert-StudioOwnedOrAbsent treated a denied root as absent and returned,
  so the caller could go on to replace a tree it cannot read. Probe the
  root three-state and stop on Denied, still gated on $StudioHomeIsCustom
  so default-home behaviour is unchanged.
- The source-build .git probe treated a denied checkout as "no checkout"
  and cloned a replacement. The swap that follows recursively removes the
  original and moves the temp tree over it under "Continue" and unchecked,
  so a denied child could leave a half-deleted install. Stop instead.
  This path already treated denied as absent before the previous commit
  (that probe runs under "Continue", so it printed an error and took the
  false branch), so the hazard is older than this branch, but it is in
  scope for the same reason.
- Probe $LlamaCppDir itself three-state, so an unreadable parent is
  reported rather than dying on the bare probe under "Stop".
- takeown and icacls were printed joined by "then", which is not a
  PowerShell separator: takeown would swallow the rest as arguments and
  icacls would never run. Print them on separate lines.

Fold the repeated guidance into Exit-PathAccessDenied so all five denial
routes report the same thing.

* Harden the denial reporting path, found by simulation

Ran the real decision blocks against simulated filesystems (denied file,
denied parent, traverse-only and list-only dirs, symlinks, dangling links,
wildcard and unicode paths, 3000 random paths) plus PSScriptAnalyzer's
5.1/6.2/7.0 syntax check. Two things came out of it:

- Get-PathDenialDetail threw a parameter-binding exception on an empty
  path. It runs while a failure is being reported, so it would have
  replaced the actionable message with a raw binding error at exactly the
  wrong moment. Null and empty are now accepted and return no detail.
- The link-target lookup used an empty catch, which PSScriptAnalyzer flags
  and which hid the intent. It assigns $null explicitly now.

Both are covered by new checks. Also promoted the strongest invariant from
the simulation into the suite: Get-PathState must agree with a bare
Test-Path on every probe that did not throw, and Denied may only appear
where the old probe threw, so no path that worked before can take a
different branch now.

Verified: PSUseCompatibleSyntax reports nothing for 5.1, 6.2 and 7.0; the
Python contract tests pass on 3.10 through 3.13 in separate uv venvs; the
tauri install:: unit tests pass (17), which is the code that prefers the
[TAURI:ERROR] line over the generic exit-code message.

* Trigger the Windows PowerShell tests when they change

studio-windows-inference-smoke.yml runs six PowerShell unit tests out of
tests/studio, but its pull_request paths filter matched none of them, and
no other workflow runs them. A PR touching only one of those tests never
ran it. Five predate this branch; the sixth is the ACL test added here.

Scope the filter to tests/studio/*.ps1 rather than tests/studio/**, so a
python-only change under that directory does not pull in the GGUF smoke
jobs. This matches what the other two workflows already do: parity-ci
lists its .ps1 test outright and update-smoke uses a scoped glob.

Guard it in test_ci_shell_suite_coverage.py, which exists for this exact
failure (tests/sh had the same hole): every tests/*.ps1 a workflow invokes
must be matched by that workflow's paths filter, and must exist. The
GitHub glob matcher it needs has its own table-driven test, since a wrong
matcher would make the guard pass on everything.

Verified by reverting the one-line filter change: the guard then names all
six unrun tests.

* Make the Windows PowerShell test step fail when a test fails

Verifying the path-filter fix turned up a second hole in the same step. A
`shell: pwsh` step inherits only the LAST command's exit code, and this
step ran five tests as five bare commands, so only the last one could fail
the build. test_resolve_cuda_toolkit.ps1 has been printing

    FAIL  exits non-zero      (scenario 2, forced source build)
    FAIL  exits non-zero      (scenario 6, no toolkit, forced)
    2 check(s) FAILED

on every Windows run, exiting 1, and the job reported success. Confirmed
on main (run 30723608191, sha c67410a7), so it predates this branch, and
it reproduces locally.

The cause is in the test, not the installer. Resolve-CudaToolkit
-RequireOrExit leaves through Exit-SetupFailure, which the child harness
never stubbed, so under ErrorActionPreference=Continue the call was an
ignored command-not-found, the child fell through and exited 0. The
harness already injects the real Resolve-CudaToolkit and
Write-CudaDriverToolkitMismatch by AST, so inject the real
Exit-SetupFailure the same way. That test now passes 25/25.

With it green, add the exit-code checks after each invocation, matching
what studio-windows-update-smoke.yml already does, and guard the pattern
in test_ci_shell_suite_coverage.py: any step running more than one
PowerShell test must check $LASTEXITCODE after each.

Verified by reverting each piece: dropping one check makes the guard name
that test, and dropping the Exit-SetupFailure injection brings both
scenario failures straight back.

* Stop on a denied --with-llama-cpp-dir instead of reinstalling over it

When UNSLOTH_LOCAL_LLAMA_CPP_DIR points at the canonical $LlamaCppDir and
the llama-server.exe there is ACL-denied, Test-PathQuiet collapsed Denied
to $false, $LocalLlamaServerFound stayed false, and the canonical branch
reported "nothing built there yet; running the normal install". The
prebuilt installer then moves that tree aside and replaces it, which is
exactly what the branch's own comment says it exists to prevent. The old
bare probe stopped first, by throwing under "Stop".

Probe the candidates three-state and stop on Denied. Same for the
directory probe itself, which reported an unreadable dir as "does not
exist" and sent the user after the wrong problem.

The generic message could not be reused as-is here: it tells the user to
delete the folder because Unsloth reinstalls it, which is true of the
managed cache and wrong for a build they pointed us at. Exit-PathAccessDenied
takes -UserSupplied, which swaps that advice for restoring access or
repointing UNSLOTH_LOCAL_LLAMA_CPP_DIR, and keeps the takeown/icacls lines.

Verified by driving the real block through every state: a readable build
is still reused, a genuinely empty canonical dir still falls through to
the normal install, a missing dir still reports "does not exist", and a
denied build now stops with exit 1 instead of being replaced. Reverting
the probe puts the fall-through back, and the user-supplied path never
prints "delete or rename" or "managed cache".

* Carry the denial through three more probes

Three review points, all reproduced before fixing:

- The canonical --with-llama-cpp-dir override still got the managed
  advice ("delete it, Unsloth reinstalls it"). The override means the
  user asked to reuse whatever is in that tree, so deleting it is wrong
  wherever it sits. Both candidate denials now pass -UserSupplied, which
  collapses the branch to one call.
- Phase 1b's git prerequisite scan probes the same candidate binaries
  with a bare Test-Path under "Stop", thousands of lines before the
  Phase 4 guards, so a denied override terminated the run with the raw
  error this change exists to replace. Reproduced, then guarded.
- Test-StudioOwnedAdoptable collapsed a denied prebuilt marker to $false,
  so Assert-StudioOwnedOrAbsent called an Unsloth tree an unrelated
  directory and told the user to move it aside. Get-StudioAdoptableState
  returns Yes/No/Denied and the guard reports the denial first;
  Test-StudioOwnedAdoptable stays as the boolean view for the cosmetic
  cleanup gate.

A denied file under a readable directory is a Windows-ACL-only state:
POSIX keeps a mode-000 file stat-able, and a symlink into a denied
directory still answers Test-Path. The local run injects that one state
at the lowest seam and lets the real functions run; the Windows leg of
test_path_probe_access_denied.ps1 builds it for real with icacls and
skips elsewhere with the reason.

test_setup_ps1_adopts_existing_whisper_prebuilt_marker sliced between two
function names and the marker scan moved, so its anchor now points at
Get-StudioAdoptableState. Its assertion is unchanged.

Reverting each fix individually puts the original behaviour back: the
ownership misdiagnosis, the raw "Access to the path ... is denied" from
Phase 1b, and the delete-your-own-build advice.

* Close the remaining denial gaps and the stale probe anchors for PR #7735

- tests/sh/test_with_llama_cpp_dir_flag.sh anchored the literal
  'if ($ResolvedLocal -eq $LlamaCppDir) {', which 2a61343 hoisted into
  $LocalIsCanonical. Re-pin it to the comparison, not the branch.
- The junction path deleted and replaced $LlamaCppDir behind a bare
  Test-Path, so a denied destination still threw raw under a default home.
  Probe it three-state, and treat Denied as surviving removal.
- Get-Content on the prebuilt metadata still globbed while the probes
  gating it went literal, so a path holding [ or ] passed the probe and
  threw into the catch. Make both reads literal, with a test.
- Get-PathDenialDetail could throw on a non-filesystem provider item whose
  .Attributes has no -band overload, replacing the failure being reported.
- Win32Exception keeps E_FAIL in HResult and the code in NativeErrorCode,
  so the HRESULT check never matched it. Fix the comment and the check.
- test_windows_git_gate.py ran the layout scan in a child that never
  defined Get-PathState, so de486ff had it silently report nothing built.
  Inject the real helpers, as test_resolve_cuda_toolkit.ps1 does.
- Relax the exact Exit-PathAccessDenied count to a floor and key the
  -UserSupplied rule on the path reported rather than on position.

* Stop advising deletion of a tree whose ownership cannot be read

The ownership guard stops precisely because it could not read the marker,
so it cannot claim the folder is ours either. It was still emitting the
managed-cache text, telling the user to delete it. Eleven lines below, the
readable-but-unowned branch says 'move it aside' instead, so we were being
gentler when we could prove the tree was not ours than when we could not
read it at all. New -OwnershipUnverified wording for those three stops.

Also from re-reading the previous commit:

- The reparse-point unlink above the junction path ran before the new
  three-state probe, and a link reports Present, so the probe could not
  cover it. A denied unlink still terminated on the raw .Delete() throw.
- That junction destination probe had no test at all; reverting it left
  the suite green, since the count floor cannot see a swap. Pinned by
  name like every other route.
- The re-pinned shell anchor accepted an assignment that nothing consumed.
  Pin the branch that uses it too.
- Get-PathDenialDetail now checks the item type rather than the attribute
  type, which also covers a provider item with no Attributes at all.

* Check both destructive steps of the temp-dir swap

The guard above the clone path probes only .git, but its own comment names
any unreadable child as the risk. A forced source build over a non-git
install with a denied child elsewhere reads Absent, clones into a temp dir,
and reaches the swap.

Both steps there are non-terminating under Continue and neither was
checked. Reproduced: Remove-Item partially fails, the original survives,
and Move-Item then moves the temp dir INSIDE it, so the new binary lands at
llama.cpp/llama.cpp.build.<pid>/llama-server.exe while $LlamaServerBin
points at llama.cpp/build/bin/Release. Setup carries on reporting success
with no usable server and a half-deleted install.

Check the removal before moving, so the stop happens while the temp build
is still whole, and check the move afterwards. Denied routes through
Exit-PathAccessDenied; anything else surviving exits 3 like the other
blocked-replacement paths.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-02 07:25:39 -07:00
Etherl
1faa0377e5
Fix Windows llama.cpp prebuilt setup with inaccessible PATH entries (#7696)
* Fix Windows prebuilt fallback on inaccessible PATH

* Fix inaccessible inherited Windows PATH entries

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

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

* Fix Windows PATH test on POSIX

* Keep transient prebuilt failures on the source-build path

Narrowing the source build to helper exit 2 made every other nonzero exit
fatal, but the helper reports a rate-limited or unreachable api.github.com
as EXIT_ERROR: fetch_json raises a bare RuntimeError for HTTP 403, and the
fork branch of the release resolver has no wrapper of its own where the
ggml-org branch does. Unauthenticated GitHub API calls are limited to 60 an
hour per IP, so a shared runner or a NAT'd network hits this routinely, and
a source build clones over git rather than the API, which is why falling
back used to recover. This PR's own macos-14 kill@torch run failed exactly
that way.

Classify release-listing failures on the install path as PrebuiltFallback so
they exit 2 again. Scoped to install_prebuilt rather than the resolver:
--resolve-prebuilt turns PrebuiltFallback into a successful
{"prebuilt_available": false} payload and update_flow caches any exit-0
answer for RESOLVE_TTL_SECONDS, so wrapping there would pin a transient 403
as "no prebuilt" for 24 hours.

Also close the paths that stayed fatal:

- collect_system_report ran inside the PrebuiltFallback handler, so a probe
  that raised replaced the in-flight fallback with EXIT_ERROR.
- python_runtime_dirs and windows_runtime_dirs stat sys.path entries and
  %ProgramFiles% outside dedupe_existing_dirs, so skip_unusable could not
  protect them and a denied entry still aborted Windows discovery.
- binary_env still required inherited LD_LIBRARY_PATH and DYLD_LIBRARY_PATH
  to be readable, the same thing the Windows branch stopped requiring.
- sync_marker_force_cpu and sync_marker_llama_backend guarded the marker
  read but not the write, so a read-only marker on an otherwise up to date
  install failed setup.
- runtime_libs.python_runtime_dirs, the serve-time copy, had the same
  unguarded sys.path stat; the sidecar turns the raise into an empty dir
  list and loses every CUDA wheel dir.

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

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

* Only reclassify transient resolver failures, and persist the reuse marker

Three follow-ups from cross-platform validation.

The resolver wrapper caught Exception, so a TypeError or AttributeError from
a bug in host-conditional resolver code (per-gfx ROCm, Windows arm64, the
macOS walk-back) also exited 2 and bought a source build, logged under a
message that blames the network. Narrow it to OSError, RuntimeError and
ValueError, which covers every transient shape by MRO: URLError, HTTPError,
SSLError and TimeoutError are OSError, JSONDecodeError is a ValueError, and
fetch_json raises a bare RuntimeError for HTTP 403. Code defects go back to
EXIT_ERROR, with a test pinning that direction.

Guarding the marker write stopped a read-only marker from failing setup, but
it also let a deliberate --force-cpu go unrecorded while setup reported
success, which is how the updater re-routes a CPU user onto a GPU bundle
(#7213). Write through atomic_write_bytes first, which swaps in a sibling
temp file and so lands on a read-only marker in a writable dir, and warn
loudly when it genuinely cannot be recorded.

Skip the setup.sh routing test on Windows: setup.sh is the POSIX installer,
Windows runs setup.ps1, and driving a POSIX script through Git Bash with
Windows paths proves nothing about either. The PowerShell branch of the same
routing stays covered by the platform-independent textual test.

* Make Python runtime discovery optional, and never truncate the reuse marker

Guarding only the search root left the strict dedupe on python_runtime_dirs'
own return to raise, so a readable site-packages with a denied torch/lib or
nvidia/*/lib child still aborted Windows discovery. That is the same shape as
the bug this branch is about, one level down: the parent lists fine and the
entry underneath is denied. These candidates are optional CUDA wheel dirs
found by globbing, and one that cannot be stat'd could not have served DLLs
to the loader either, so skip them like the serve-time copy already does.

Drop the in-place retry in the marker rewrite. It opened a valid marker with
truncation, so an ENOSPC or I/O error mid-write would strand a partial
UNSLOTH_PREBUILT_INFO.json and later updates would stop recognising the
install. The atomic path already covers the case the retry was there for, a
read-only marker in a writable dir; when it fails, leave the old marker alone
and warn.

* Preserve the marker mode across the atomic refresh

os.replace keeps the source file's mode and NamedTemporaryFile is 0600, so
refreshing a shared install's marker left UNSLOTH_PREBUILT_INFO.json readable
only by whoever ran setup, and other users could no longer recognise or
update that installation. Reproduced: a 0444 marker came back 0600.

Build the temp file here instead of calling atomic_write_bytes, so the
original mode is restored before the swap rather than after, leaving no
window where the marker is private. Clean up the temp file if the replace
fails, so a failed refresh strands nothing next to the marker.

* Claim only transport failures, and never strand a temp marker

A plain OSError is not evidence of a network problem. EMFILE after file
descriptor exhaustion, ENOMEM, or a local EACCES reading TLS configuration
were all being converted to EXIT_FALLBACK, so setup started the resource
heavy source build that this branch otherwise refuses for unexpected helper
failures, and a build needs more descriptors and more memory, not fewer.
Name the transport shapes instead: URLError covers HTTPError and the
socket/DNS errors urllib wraps, plus SSLError, ConnectionError, TimeoutError,
and the RuntimeError and ValueError that fetch_json raises for a 403 and for
an undecodable payload. ENOSPC still reaches EXIT_NO_SPACE, now through
__main__'s classifier rather than install_prebuilt's, and the test asserts it
the same way __main__ decides it.

The marker temp file was only unlinked when the chmod or the replace failed.
A write, flush or fsync that raised, which is the ENOSPC case this is built
to tolerate, jumped straight past the cleanup and left a partial
UNSLOTH_PREBUILT_INFO.json.tmp-* beside the valid marker, one per attempt on
a full volume. Track it across every failure path instead.

Also restore the original owner and group on the replacement where the caller
is permitted to, since os.replace installs the temp file's ownership and a
shared marker would otherwise pick up the invoking user's primary group.

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

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

* Tighten the comments added by this change

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-01 14:16:47 -07:00
Daniel Han
9bfa18cdb0
Windows: unblock the consumer install on clean and no-winget machines (#7549)
* Windows: unblock the consumer install on clean and no-winget machines

Four independent things stop a clean Windows box today.

git was a hard Exit-SetupFailure in setup.ps1, justified as required by pip for
git+https:// deps and by npm. Neither holds on the consumer path: the unsloth-zoo
git+https URL is only used under STUDIO_LOCAL_INSTALL, node is a pinned
nodejs.org prebuilt that never touches system npm, and the frontend lockfile has
no VCS dependencies. It stays fatal for --local, where it really is needed.

Ensure-VCRedist was winget-only, so on hosts without winget (LTSC, Server,
managed corporate images) it silently did nothing while the install reported
success, and torch then failed to import on a missing VCRUNTIME140.dll. Adds a
direct aka.ms/vs/17/release/vc_redist.<arch>.exe download with /quiet /norestart,
accepting exit codes 0 and 3010. The redistributable stays required: it is the
runtime the prebuilt llama-server and torch link against, not the MSVC compiler,
which is already detection-only.

Windows on ARM has no PyTorch at all. Measured with uv against
download.pytorch.org/whl/cpu and PyPI for aarch64-pc-windows-msvc / cp313: torch,
torchvision and torchaudio all resolve to nothing, wheels exist only for
win_amd64 and the manylinux targets. The installer burned three uv retries on an
unsatisfiable resolution and reported a bare 'Failed to install PyTorch (exit
code 1)'. Now it says what is actually wrong and points at --no-torch, which
works because llama.cpp does publish windows-arm64-cpu.

install_node_prebuilt.py hit '[WinError 5] Access is denied' on os.replace of the
freshly extracted directory during a FRESH install, which is a scanner or indexer
holding handles for a moment. Retries only winerror 5, 32 and 145 with capped
exponential backoff; any other OSError still raises immediately.

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

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

* Give the ARM64 dead end a recovery that works for web installs

The only remedy printed was .\install.ps1 --no-torch, but the documented path is
irm | iex, where no file exists and flags cannot be forwarded. Name the env var
the script already honours at line 145.

* Windows on ARM: drop torchaudio, do not abort the install

The fail-fast was based on a wrong premise. Counted against
download.pytorch.org/whl/cpu: torch has 42 win_arm64 wheels and torchvision 60;
only torchaudio has none. PyTorch has shipped Arm-native Windows builds since
April 2025, so aborting blocked a platform that mostly works. Drop the one
unsatisfiable pin instead.

Decide from the interpreter uv will resolve for, not the PowerShell host: an x64
CPython under emulation gets working win_amd64 wheels on an ARM64 box, and
powershell.exe inherits PROCESSOR_ARCHITECTURE from its parent.

* Carry the ARM64 torchaudio omission into studio setup

Dropping it from the first PyTorch command was not enough: install.ps1 then runs
studio setup with SKIP_STUDIO_BASE=1 and setup.ps1 reinstalls the bare trio from
the CPU index, so the ARM64 path still aborted. Apply the same interpreter-based
test there. An unreadable platform keeps the full trio.

* Build the torch spec list outside the verbose branch

The ARM64 guard landed inside `if ($script:UnslothVerbose)`, so on the default
path $_torchTrio was never assigned and the splat expanded to nothing: uv ran as
`uv pip install --index-url ...` with no package, exit 2, straight to
Exit-SetupFailure. That broke the ordinary Windows install. Hoist it above the
branch and use substep, which prints on both paths.

Realign the two parity guards to the splat form; they asserted the pre-refactor
literal command and were the actual cause of the red parity legs. Both halves
are still checked: the bounded list is built, and it reaches the install.

* Tighten the comments on the Windows install path

* Windows install: honour the ARM64 torchaudio skip everywhere and keep git for source builds

Hoist the venv-interpreter platform probe above every torch branch in
studio/setup.ps1 so the win_arm64 torchaudio omission applies to the ROCm,
CPU and CUDA/custom paths. A pinned index whose leaf is not cpu routed an
ARM64 host into the CUDA/custom branch, which still asked for torchaudio.

Require git again when a llama.cpp source build is opted into up front
(UNSLOTH_LLAMA_FORCE_COMPILE, UNSLOTH_LLAMA_PR / PR_FORCE, a non-upstream
source). Those paths git clone in phase 4, so setup used to report git as
not required, install the build toolchain, then fail at the clone. A local
llama.cpp dir overrides them, and the automatic source fallback after a
failed prebuilt download stays non-fatal.

Also tighten the comments across the changed install paths.

* Install the x64 VC++ runtime unconditionally in the direct-download fallback

The winget branch always installs Microsoft.VCRedist.2015+.x64, but the
direct-download fallback picked the package from PROCESSOR_ARCHITECTURE, which
reports the architecture of the running PowerShell process rather than the
interpreter that will load the DLLs. Find-CompatiblePython in install.ps1
selects an interpreter on version and non-Conda status alone, with no
architecture predicate, so a native ARM64 shell can settle on an emulated x64
Python whose win_amd64 torch and prebuilt llama-server need the x64 runtime,
while the fallback had just installed the ARM64-only package. Ensure-VCRedist
also runs well before the venv exists, so the interpreter cannot be probed at
that point. Microsoft ships the x64 redistributable as an Arm64X superset that
carries both ARM64 and x64 binaries, so it is correct on both machines and the
manual instruction printed on failure already pointed at it.

* Windows on ARM: prefer an x64 Python interpreter

An ARM64 host cannot complete the install with a native ARM64 interpreter.
pyarrow, pulled in by unsloth -> datasets, has never published a win_arm64
wheel on any version, and neither has hf-transfer, a direct dependency.
Both therefore fall back to a source build: pyarrow dies in scikit-build-core
CMake configuration and hf-transfer dies in openssl-sys for want of perl,
several minutes into a run that looked healthy. torch and torchvision are
not the problem, they have win_arm64 wheels and install fine.

Windows 11 on ARM runs x64 binaries under emulation and both packages ship
win_amd64 wheels, so an x64 interpreter installs cleanly.

Find-CompatiblePython accepted an interpreter on version and non-Conda
status alone. It now ranks candidates by architecture on ARM64 hosts and
returns an x64 one when present, asking each interpreter for its own
sysconfig.get_platform() rather than guessing from its path. Host
architecture comes from PROCESSOR_ARCHITEW6432 and OSArchitecture as well
as PROCESSOR_ARCHITECTURE, which describes only the current process and
reads AMD64 in an emulated shell.

This is a preference, not a requirement. If only ARM64 is found, x64 is
bootstrapped through winget --architecture x64 or the python.org fallback,
and if neither works the installer names pyarrow and hf-transfer up front
instead of failing later on a CMake or Rust error. The ARM64 torchaudio
skip stays live for that path.

Non-ARM hosts return on the first match exactly as before, with no extra
interpreter probing.

* Windows install: three correctness fixes on the ARM64 and git-less paths

Ensure-VCRedist never reached its x64 download on an ARM64 machine that already
had the arm64 redistributable: Test-VCRedistInstalled accepted System32\vcruntime140_1.dll
regardless of architecture, and there that file can be the pure-ARM64 package. An
ARM64 PE cannot load into an emulated x64 process, so the x64 Python this branch now
prefers would have been left without a usable runtime. The x64 registry entry is the
only x64-specific proof, and Microsoft registers Runtimes\{x86|x64|arm64} per
architecture, so vc_redist.x64.exe still writes Runtimes\x64 on an ARM64 host and the
check cannot loop. The DLL probe stays for x64 hosts.

Phase 1 demanded git for any non-blank UNSLOTH_LLAMA_PR_FORCE, but the promotion that
actually turns it into a source build requires a positive integer, so PR_FORCE=0 or a
non-numeric value aborted a git-less consumer install for a build that never runs. Both
sites now use the same predicate.

The automatic fallback after a failed prebuilt llama.cpp download reached git clone with
no git check anywhere in between, and Invoke-SetupCommand returns 0 for a command-not-found,
so a git-less host did not stop there: it continued into an empty directory and reported a
cmake configure failure instead. Git is now resolved where the source build is decided,
with a last winget attempt, and a missing git degrades exactly like a missing cmake rather
than aborting, since the opt-in source triggers already required git in Phase 1.

Also tightened the comments across the changed Windows install code, keeping the reasons
on the guards that prevent a specific failure.

* Rank ARM64 Python candidates by minor version before architecture

The x64 preference filtered the whole candidate list on architecture, which
outranks the version preference the candidates were collected in. With
UNSLOTH_PYTHON=3.12 on a Windows ARM64 box holding an ARM64 3.12 and an x64
3.13, it returned the x64 3.13: the explicit pin was silently broken, and
because a x64 interpreter was found the caller never ran Install-X64Python
to fetch an x64 3.12. With no pin it was worse still, since an x64 3.11
outranked a newer ARM64 3.13 and defeated the newest-first fallback.

Walk $minors in order and take the x64 build of the best minor available,
falling back to that minor's ARM64 build so the caller bootstraps x64 for
the version actually requested. x64 still wins within a minor, and non-ARM
hosts are untouched.

* Windows install: see every registered Python, order git before the toolchain

Find-CompatiblePython only ever probed `py -3.X`, which runs the launcher's
preferred build for that minor. On an ARM64 box that is the native ARM64
interpreter, so a same-minor x64 install that is registered with the launcher
but neither preferred nor on PATH never became a candidate. The x64 preference
then lost to ARM64, and Install-X64Python re-downloaded an x64 CPython that was
already on the machine; when that download is unavailable the install continues
on ARM64 and source-builds pyarrow and hf-transfer, which publish no win_arm64
wheels. Enumerate `py -0p` on ARM64 hosts and probe each listed path. The
`-3.12-64` suffix cannot be used for this: it has meant "not 32-bit" since 3.11
and does not distinguish arm64 from amd64.

studio/setup.ps1 ran Ensure-BuildToolsForLlamaSourceBuild before checking git in
Phase 4. That helper calls Exit-SetupFailure when Visual Studio Build Tools
cannot be installed, so on a clean no-winget box the git degraded path added by
this PR was unreachable and a standalone update aborted instead of finishing in
limited mode; where winget does exist it spent a multi-GB Build Tools download on
a clone that could never run. Check and install git first, skip the toolchain
helper when git is still missing, and report the git branch before the cmake
branch so the message names the real cause.

_swap_into_place retried the forward rename for about 16 seconds but rolled back
with a bare os.replace. A scanner holding the backup for the same WinError 5/32
then left no install_dir at all and stranded the working runtime in .old-*, and
its exception replaced the original failure. The rollback now uses the same
backoff and logs instead of masking the error it is recovering from.

* Installer: use an already installed x64 Python on ARM64 when none can be downloaded

Find-CompatiblePython ranks x64 within one minor and returns the native build
when that minor is ARM64-only, leaving Install-X64Python to bootstrap x64. On an
offline or winget-less box that bootstrap fails, and the retry went through the
same resolver, so an x64 build of a lower-priority supported minor already on the
machine was never picked up and setup continued on ARM64 Python, where pyarrow
and hf-transfer have no wheels.

Add an -X64Only mode that returns the best installed x64 interpreter or nothing,
and call it as the last resort in Install-X64Python. The version-first preference
is unchanged: x64 of the requested minor is still bootstrapped first.

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

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

* Tighten comments in the Windows ARM64 installer changes

* Setup: require Git for a source build behind an unbuilt local llama.cpp dir

UNSLOTH_LOCAL_LLAMA_CPP_DIR only overrides the source-build opt-ins once the
directory holds a reusable llama-server.exe. Pointing it at the canonical
install location with nothing built there falls through to the normal install,
so the Phase 1 gate now probes the same layout candidates as the Phase 4 reuse
check before dropping the requirement.

* Setup: require Git when UNSLOTH_LLAMA_TAG=master forces a source build

* Tighten comments in the Windows installer changes

* Setup: negotiate TLS 1.2 for the direct VC++ runtime download

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-28 22:24:40 -07:00
Leo Borcherding
411cb86d62
amd: require bitsandbytes>=0.50.0 in the amd extra (fixes ROCm 4-bit NaNs) (#7535)
* amd: require bitsandbytes>=0.50.0 in the amd extra

bnb <= 0.49.2 NaNs at decode shape on every AMD GPU. The ROCm 4-bit GEMV
fix (bnb PR #1887) first ships in 0.50.0, on PyPI since 2026-07-24, so the
old >=0.49.1 floor could still resolve the broken range.

Mirrors the same change made on the pip release branch in #7278.

* amd: cite the 0.50.0 ROCm work accurately in the bnb floor comment

The comment credited bnb PR #1887 as "the ROCm 4-bit GEMV fix" for every
AMD GPU. #1887 decouples blocksize from warp size and fixes a hardcoded
warp size of 32 in kgemm_4bit_inference_naive, which is a CDNA problem by
construction. The RDNA-side work is #1979 (fused 4-bit SIMT GEMM) and
#2012 (RDNA3/4 workgroup resonance). All three first ship in 0.50.0, so
the >=0.50.0 floor is unchanged; only the justification was wrong.

* amd: raise the installer bitsandbytes fallback floors to 0.50.0

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

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

* amd: stop reporting the bitsandbytes PyPI fallback as broken

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

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

* Tighten AMD bnb floor comments

* Keep the amd extra citation and the AMD install guide reference

* amd: do not promise aarch64 a ROCm 4-bit backend it never gets

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

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

* amd: fall back to the PyPI bitsandbytes floor on Windows ROCm too

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

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

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-28 18:12:26 -07:00
Willow Lopez
77971d0deb
fix(rocm): prefer system LLVM runtime on native Linux (#7448)
* fix(rocm): prefer system LLVM runtime on native Linux

* Fix/adjust the nested LLVM probe for PR #7448: lib64 hosts and non-directories

Two gaps found while simulating the fix against real ROCm layouts.

1. lib64 hosts got no LLVM dir. The candidate was built from the HSA dir, so a
   host with libhsa-runtime64 under lib64 probed <root>/lib64/llvm/lib. ROCm
   installs LLVM under <root>/lib/llvm regardless, so that host kept binding
   system libamd_comgr to the bundle's libLLVM: exactly the bug #7446 reports.
   Probe both spellings, the HSA dir's own first so a genuine lib64 layout still
   wins. When lib_sub is already "lib" the seen set collapses them.

2. os.path.exists accepted a non-directory. The serve-time caller joins these
   straight into LD_LIBRARY_PATH with no is-dir filter, so a file named
   llvm/lib reached the loader. os.path.isdir instead.

Verified on a 27-case matrix built from real directory trees (not mocks), run on
both Windows and Linux against three revisions: main, this PR as-is, and this
commit. Zero regressions and zero reorderings of the pre-existing entries in
every case, and the installer and launcher copies never disagree. The lib64 case
goes [lib64] -> [lib64, lib/llvm/lib]; the file case drops the bogus entry; a
symlinked llvm/lib resolves correctly on Linux.

End-to-end loader check: built real ELF objects mirroring the shipped bundle
(RUNPATH=$ORIGIN, an incomplete libLLVM.so.23.0git next to llama-server, system
comgr from /opt/rocm/lib) and reproduced the reported failure verbatim, then
confirmed the prepend clears it:
  before  undefined symbol: LLVMInitializeSPIRVTarget  ->  after  exit 0

Test helper now patches os.path.isdir alongside os.path.exists, else every fake
host reports its nested llvm dir as missing. New cases: lib64 finding llvm under
lib, lib64 preferring its own when both exist, and a real-filesystem check that a
non-directory is not prepended. Removing the lib fallback from one copy reddens
three tests including the two-copy parity guard.

tests/studio/install: 1361 passed on Linux, 4 pre-existing environmental
failures unchanged (3 managed-node-runtime under root, 1 the real /opt/rocm case
already covered by #7397). 30/30 on the helper suite on Windows and Linux.

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

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

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-28 06:19:29 -07:00
Lee Jackson
d7594ec10f
Fix Windows no-torch setup (#7511)
* Fix Windows no-torch setup

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

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

* Fix no-torch env normalization on Windows

* Accept on for Windows no-torch mode

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

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

* Keep no-torch mode across studio update on Windows

Guarding the direct torch/Triton install made `install.ps1 --no-torch`
actually produce a torch-free venv, which then broke the next
`unsloth studio update`. That path exports no UNSLOTH_NO_TORCH, so
$NoTorchMode was false, the stale-venv check read the missing torch as a
broken venv, and setup tried to delete the venv it was running out of:

  [ERROR] Could not remove stale venv: Access to the path 'python.exe' is denied.

That teardown can never succeed there, because setup.ps1 runs via
unsloth.exe out of that same venv. The same gap also let the shared
dependency pass reinstall torch from PyPI, unpinned, into a GGUF-only
environment.

install_python_stack.py now records the mode in the install manifest and
setup.ps1 reads it back when no env var is exported, then re-exports a
canonical value for the dependency pass (setup.ps1 drops the manifest
before invoking it, so the child cannot repeat the lookup). The key is
additive and MANIFEST_SCHEMA is unchanged, so existing manifests stay
valid and a missing key keeps today's behaviour.

Also:
- read_manifest() caught only OSError, but UnicodeDecodeError is a
  ValueError. That is now on the installer's import path, so a manifest
  re-saved as ANSI or truncated mid-write would abort every install.
- The env predicate now trims surrounding whitespace, matching the
  Python side.
- The Windows update smoke workflow asserts the update leaves the venv
  GGUF-only, which is what would have caught this.

Known follow-up, pre-existing: an install killed between the manifest
drop and the dependency pass leaves no recorded mode, so a later update
still walks the stale-venv path. Closing that needs a marker the
installer never drops.

* Persist no-torch mode in a marker the dependency pass cannot drop

The install manifest alone was not enough. Both setup.ps1 and
install_python_stack.py remove it before every dependency pass, and it is
only rewritten on success, so a no-torch install interrupted in between
left nothing recording the mode. The next update then resolved no-torch
as false, read the expected missing torch as a stale venv, and tried to
delete the environment whose python.exe was running it, which leaves the
install unrepairable from the CLI.

Add .unsloth-no-torch next to the existing .unsloth-studio-owned marker,
written before the pass and cleared when torch is wanted. setup.ps1
writes it as soon as the mode resolves, so the window between the
manifest drop and its own torch install is covered too.

Read order stays manifest key first, then marker, so migrating out of
no-torch is never blocked by a marker an earlier run left behind. Neither
present still reads as "install torch", so nothing changes for installs
made before either existed.

Also adds the AGPL-3.0 header the new test file was missing.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-28 05:54:25 -07:00
Daniel Han
1781770bee
Studio: detect an interrupted dependency install instead of launching a backend that cannot import (#7492)
Some checks are pending
Unsloth GGUF CI / JSON, images (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Windows Unsloth GGUF CI / JSON, images (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio API CI / Unsloth API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio Update CI / Unsloth Updating Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
* Studio: detect an interrupted dependency install instead of launching a backend that cannot import

An installer killed part-way leaves a venv with a working CLI but without
studio.txt's dependencies. Nothing recorded that, so three separate places all
reported it healthy:

- the desktop preflight probed only `unsloth -h` (typer + rich) and a hardcoded
  desktop-capabilities dict, neither of which touches studio.backend, so it
  returned ManagedReady and spawned a backend that died on `import structlog`;
- setup.sh's fast path compared the installed unsloth version against PyPI,
  which matches on a half-built venv because unsloth is installed early, so
  `unsloth studio update` printed "up to date" and repaired nothing;
- start_managed_repair calls that update and then re-checks with the same blind
  probes, so Repair reported success without fixing anything.

install_python_stack.py now clears a completion manifest before the dependency
pass and writes it only after the final step. `unsloth studio verify-install`
and desktop-capabilities' new studio_install_ok field read it, the preflight
turns a false answer into ManagedStale so auto-repair runs, and setup.sh /
setup.ps1 gain an escape hatch next to the existing anyio one.

Separately, the wheel ships studio/ and studio.backend* but declared none of
their dependencies, so `unsloth train`, `export`, `chat`, `inference` and
`studio` all ended in a rich traceback after a plain pip install. structlog is
the only hard module-level import that chain reaches once starlette's
annotation-only import moves under TYPE_CHECKING, so it becomes a core
dependency and the rest of the server stack becomes a [studio] extra mirroring
studio.txt. The CLI import sites now report missing dependencies as a sentence
with two remedies.

Fixes #4701, #5260, #7147

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

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

* Match the trimmed comments merged on the pip branch

* Put the install manifest in the preflight fingerprint for PR #7492

The capability cache keyed the venv on pyvenv.cfg, uv.lock, requirements.txt,
the interpreter and site-packages/unsloth_cli/commands/studio.py, none of which
a repair touches when it only reinstalls studio.txt. So an entry cached while
the install was healthy stayed valid after the manifest was dropped, and the
probe returned Ready on exactly the half-built venv this is meant to catch.

* Address the review findings on PR #7492

Fail the install when the completion manifest cannot be written, instead of
exiting 0 without the record every later check requires, which is a repair
loop by construction.

Compare the version of the package the manifest names, so `studio update
--package X` does not read as a permanent version change.

Read the manifest from the venv that owns it when the CLI runs outside the
managed venv, and drop the dependency verdict in that case: the walk ran
against the wrong interpreter and says nothing about that venv.

Name the import that actually failed. `unsloth train` reaches torch through
the same guard, and the studio extra does not carry it, so recommending that
extra alone left the command failing in the same place.

* Declare click, which typer stopped providing, for PR #7492

unsloth_cli/commands/start.py imports click at module scope and
unsloth_cli/__init__.py imports that module, so every unsloth command needs
it. typer carried click through 0.19 and dropped it in 0.27, and the declared
floor is typer>=0.12.0, so a fresh resolve gets no click. On the published
wheel it still arrives because huggingface_hub requires click<9,>=8.4.2, which
is luck rather than a declaration. A wheel built from this branch's
dependency list has neither, and every command dies at import.

Verified: before, `unsloth --help` on a fresh venv raised ModuleNotFoundError
for click; after, it exits 0. The drift test now covers it.

* Keep a running backend from the previous app version manageable

The manageability bump gated two unrelated things through one constant. For
the managed CLI probe 2 is right: a CLI reporting 1 cannot answer
studio_install_ok. For a RUNNING backend it is wrong, because a process
already started cannot change what it reports, so bumping studio/backend/main.py
in lockstep does not help one the previous app version spawned.

That backend is proven ours by root id and ownership token, but
lifecycle_control_block_reason returned Unmanageable, and that branch never
calls adopt_verified_backend. has_owned_backend() stays false, so Repair falls
into block_external_conflict, which finds the same process and refuses: the app
could no longer stop a backend it owns the token for. The same regression in
backend.rs turned a terminal-launched same-root server from AttachedReady into
ExternalConflict.

Split the constant: DESKTOP_BACKEND_MANAGEABILITY_VERSION = 1 for the two
live-backend probes, DESKTOP_MANAGEABILITY_VERSION = 2 for the CLI probe. Every
real gate (protocol, auth, ownership, desktop-login, MIN_DESKTOP_BACKEND_VERSION)
is untouched, so an old backend still reaches OwnedStale, adopt, stop, repair.

Also stop the installer when the stale manifest cannot be removed. Windows
raises on a read-only or locked file, and the pass would then run behind a
marker that still names this version and these digests, so a run killed
part-way would verify as complete.

* Answer for the managed venv, not the one the CLI happens to run in

The guard matched ModuleNotFoundError.name, an import name, against
missing_requirements(), which returns distribution names. So a missing PyJWT
printed 'pip install jwt', and jwt, docx and fitz are each a real but unrelated
PyPI project (fitz is a neuroimaging workflow tool), so following the advice
installed the wrong package and left the backend just as broken. Map the import
to its distribution before deciding, and never offer the import itself.

install_state() verified the caller's own prefix. The wheel ships studio/, so a
CLI installed outside the managed venv always finds its own copy of the helper
first, and a healthy managed install reported studio_install_incomplete with a
missing list copied from the wrong venv. Selecting the root is not enough:
_installed_version() reads the running interpreter and req_root defaults to the
caller's studio.txt, so both checks still answered for the wrong venv. Hand
verify_install() that venv's own metadata, enumerated through
Distribution.discover(context = ...path), which does not fall back to sys.path.
The candidate order is untouched, so shadowed-tree detection is unchanged.

setup.ps1 replaces pip, torch and triton before install_python_stack.py runs,
so the manifest it drops is not dropped before the first mutation. A run killed
in between kept a marker that still verifies while torch was half-replaced;
drop it at the top of the dependency pass instead. setup.sh is unaffected, the
stack is the first thing its pass runs, and a test now pins both.

pip uninstall rewrites nothing that was fingerprinted, and cache_matches
re-reads the cached studio_install_ok rather than re-checking, so a venv that
lost a studio.txt package kept being served the healthy verdict. Fold a sorted
hash of the installed dist-info names into the marker hash.

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

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

* A missing manifest helper is a torn install, not an old one

studio/install_manifest.py ships in the same wheel as _studio_deps.py, so
nothing legitimately has one without the other: a CLI predating both never
reaches this code, and the desktop already calls such a CLI stale on
desktop_manageability_version.

Returning ok=true there reported a healthy install for a tree the package
update had half replaced, and the preflight then launched a backend whose
own run.py could be just as absent. Report it incomplete so repair runs.

* Tighten comments across the install-detection changes

* Validate Studio dependency readiness

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-07-28 10:57:20 +02:00
Souravrajvi0
7917c7828c
Installer: opt-in Vulkan llama.cpp backend (and fallback when no AMD card is HIP-supported) (#7373)
Some checks are pending
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio API CI / Unsloth API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Unsloth Updating Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Windows Unsloth GGUF CI / JSON, images (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* feat(install): opt-in Vulkan llama.cpp backend and HIP gfx fallback (#7357)

Add UNSLOTH_LLAMA_BACKEND=vulkan and --llama-backend vulkan to force the
upstream Vulkan prebuilt on any host, persist llama_backend in the install
marker, and re-assert it during Studio updates.

On Windows AMD, auto-fallback to Vulkan when no detected gfx arch is in the
upstream win-hip-radeon GPU_TARGETS set (e.g. gfx803 / RX 480). Mixed setups
where at least one card is HIP-supported still default to HIP unless opted in.

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

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

* fix(install): address Codex P2s on Vulkan gfx routing (#7357)

Honor ROCm family tokens (gfx110X), include fork-supported gfx1103, require
a known active gfx before auto-Vulkan, and base the HIP floor check on the
visible-device target instead of every physical GPU in hipinfo.

* Address Codex review: env namespace, physical-NVIDIA guard, test kwarg

- llama_backend_from_env: stop reading UNSLOTH_LLAMA_CPP_BACKEND. That is a
  separate pre-existing setup variable meaning auto/cpu; setup.sh/setup.ps1
  warn and ignore other values, so reading it here forced Vulkan behind that
  warning. Vulkan opt-in stays on UNSLOTH_LLAMA_BACKEND / UNSLOTH_FORCE_VULKAN.
- _should_auto_vulkan_for_amd_windows: gate on not has_physical_nvidia (not
  merely has_usable_nvidia). A CUDA-masked NVIDIA card keeps has_physical_nvidia
  while has_usable_nvidia goes False; Vulkan ignores CUDA_VISIBLE_DEVICES and
  could enumerate the reserved card. Mirrors the Intel auto path. Explicit
  opt-in still overrides.
- test fakes: validate_prebuilt_attempts/validate_prebuilt_choice gained a
  llama_backend kwarg; the four fake signatures in the fallback tests now
  accept it, clearing the TypeError that reddened Backend CI / Repo tests (CPU).

Tests: UNSLOTH_LLAMA_CPP_BACKEND=vulkan no longer triggers Vulkan; hidden
physical NVIDIA suppresses AMD auto-Vulkan while explicit opt-in overrides.

* Keep gfx1034 on the ROCm path (fork gfx103X bundle covers it)

The WINDOWS_HIP_PREBUILT_GFX_TARGETS allow-list omitted gfx1034, so
_route_to_vulkan_prebuilt downgraded RX 6500/6400-class hosts to the upstream
Vulkan prebuilt before published_rocm_choice_for_host could match the fork
windows-rocm gfx103X bundle (whose members include gfx1034). Add gfx1034 to the
allow-list and a regression test asserting it stays on the fork ROCm asset.

* Fix auto-Vulkan stealing fork windows-rocm gfx908/gfx90a hosts for PR #7373

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

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

* Fix Vulkan marker claiming a backend that was never installed for PR #7373

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

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

* Tighten the Vulkan backend routing comments for PR #7373

* Keep the visible-device-aware gfx when setup forwards --rocm-gfx

setup.ps1 resolves the gfx arch from its own probe, and that pick is not
fully visible-device aware: neither the hipinfo nor the amd-smi branch
reads CUDA_VISIBLE_DEVICES, and the amd-smi branch matches a bare integer
only, so a comma-separated HIP/ROCR mask such as 1,0 also falls back to
GPU 0. The resulting arch was then forwarded through --rocm-gfx and
replaced the arch detect_host() had already resolved for the
runtime-visible GPU.

On a mixed-AMD Windows host that flipped the auto-Vulkan decision: with
GPU 0 gfx1100 and a masked-in gfx1010, the forward reinstated gfx1100,
_should_auto_vulkan_for_amd_windows() saw a HIP-supported arch and the
HIP bundle was installed for a GPU that cannot run it.

Fold the forward in as a fill rather than a replacement: it still supplies
the arch on amd-smi-only, driver-only and name-inferred hosts where the
probe reports none, which is what --rocm-gfx exists for, but no longer
overwrites a successfully detected active arch. An explicit
UNSLOTH_ROCM_GFX_ARCH stays authoritative, since it is the documented
manual override for hosts whose arch the probes get wrong.

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

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

* Scope the Windows AMD Vulkan fallback per device and per repo

Three follow-ups on the auto-Vulkan routing for #7357.

Keep an explicit --rocm-gfx authoritative. The previous round stopped a
forwarded gfx from replacing an arch detect_host() had already resolved,
but --rocm-gfx is also the documented operator override for hosts whose
probe is wrong or stale, and both arrive as the same argv. Narrow the
advisory case to the two shapes setup can actually be describing: an arch
the probe saw on this host (setup picked a different physical GPU of the
same box), or a family label such as gfx110X, which is a bundle name the
update path derives from the marker asset rather than a real GPU arch.
Any other value is an override for an arch no probe reported and stays
authoritative. Keeping family labels advisory also preserves the rule that
an in-generation-but-unbuilt arch (gfx1033) is never upgraded into the
gfx103X bundle.

Do not auto-route to Vulkan from a HIP-only device mask. HIP_VISIBLE_DEVICES,
ROCR_VISIBLE_DEVICES and CUDA_VISIBLE_DEVICES select the active arch, but the
Vulkan runtime honours none of them: it enumerates through
GGML_VK_VISIBLE_DEVICES and Vulkan ordinals in
LlamaCppBackend._get_gpu_free_memory_vulkan. Masking down to a below-floor
card therefore used to install a backend that could still enumerate the
HIP-capable card the user deliberately hid, possibly one reserved for another
workload. Require every physical AMD gfx to be below the floor, matching the
has_physical_nvidia gate right above it. So the per-GPU list survives to that
check, a forward that agrees with the probe no longer collapses
rocm_gfx_targets to a single entry.

Make the HIP support predicate repository-specific. The floor constant is a
union of ggml-org's windows-hip gpu_targets and the fork's windows-rocm
bundles, so it only answers "is this arch served" for the fork. With
--published-repo ggml-org/llama.cpp, direct_upstream_release_plan() offers
win-hip-radeon then CPU and never Vulkan, so the four fork-only archs
(gfx908, gfx90a, gfx1034, gfx1103) were declared supported and fell through
to CPU instead of the Vulkan bundle that would actually run. Add
UPSTREAM_WINDOWS_HIP_GFX_TARGETS and select the set from the planned repo.

* Keep probe-confirmed AMD GPUs in the physical list when a gfx is forwarded

rocm_gfx_targets is the physical inventory _should_auto_vulkan_for_amd_windows()
reads, so a forwarded --rocm-gfx that the probe never reported was deleting cards
the probe had confirmed. On a mixed Windows AMD box whose active device is masked
down to a below-floor card, a stale UNSLOTH_ROCM_GFX_ARCH or a name-inferred arch
for the other GPU collapsed the list to that one arch, the floor check concluded no
AMD GPU on the host reaches the Windows HIP prebuilt, and the install auto-fell back
to Vulkan, which honours no HIP mask and would enumerate the reserved HIP-capable
card. Add the forwarded arch to the list instead of replacing it: it selects the HIP
target, it does not redefine what hardware is present.

An empty probe still yields a single-entry list, so the driver-only Windows AMD host
the forward exists for keeps its automatic Vulkan fallback, and an explicit
--llama-backend vulkan is unaffected.

* Do not auto-fall back to Vulkan when a HIP device mask filtered the probe

hipinfo is itself a HIP application, and AMD documents HIP_VISIBLE_DEVICES as
"only devices whose index is present in the sequence are visible to HIP", with
that spelling recommended on Windows. Under a mask the Windows probe therefore
enumerates the visible devices, so rocm_gfx_targets is what survived the mask
rather than the physical inventory the auto-Vulkan floor check assumes. A
masked-out gfx1100 next to a visible gfx803 made the check conclude that no AMD
GPU on the box reaches the Windows HIP prebuilt and route the install to Vulkan,
which honours none of these masks and would enumerate the reserved card.

Decline to guess when a mask is set: the physical inventory is unknowable from a
masked probe, so keep the HIP / fork / source path. This only ever turns the
automatic fallback off, never on. The driver-only single-GPU host the fallback
exists for sets no mask, an all-hiding "" / -1 mask is still handled as no active
target rather than a partial view, and an explicit --llama-backend vulkan or
UNSLOTH_LLAMA_BACKEND=vulkan is unaffected.

Reading the physical inventory through an unmasked re-probe would also correct
_pick_rocm_gfx_target, which indexes the token list by the mask value and so
already assumes an unmasked probe. That is pre-existing behaviour on main and is
left alone here.

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

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

* Treat an all-hiding HIP device mask as suppressing the Vulkan fallback too

The mask guard exempted an empty or -1 value on the grounds that the probe reports
no active target under it, but that only holds for the probe: a forwarded
--rocm-gfx still reconstructs an active arch, and setup infers that arch from the
display-adapter name, which no HIP mask touches. A user who hid every AMD GPU from
HIP could therefore still be auto-routed to Vulkan, which honours none of these
masks and would then use all of them. That is the strongest form of the hazard the
guard exists for, not an exemption from it.

Presence of any of the three variables is now the whole test, which also removes
the value parsing. An explicit --llama-backend vulkan or UNSLOTH_LLAMA_BACKEND is
still unaffected.

* Grant the fork-only Windows HIP coverage to the fork, not to every mirror

The floor set is a union of the fork's windows-rocm bundles and only the fork is
planned from its manifest: resolve_simple_install_release_plans() compares
== DEFAULT_PUBLISHED_REPO and sends every other --published-repo through
direct_upstream_release_plan(), whose AMD branch offers win-hip-radeon then CPU
and never Vulkan. Exempting only the exact ggml-org spelling therefore told a
mirror carrying upstream-standard assets that fork-only archs such as gfx1034,
gfx1103 and gfx908 were HIP-served, landing them on HIP or CPU instead of the
Vulkan bundle that would actually run. Gate on the fork instead.

Matching the dispatch exactly, spelling included, also fixes a differently cased
repo: that really does take the upstream path, so it must be answered with
upstream coverage rather than the fork superset. An empty repo still defaults to
the fork, as the resolver does.

* Derive the Windows HIP gfx floor guard from the published manifest

The guard compared WINDOWS_HIP_PREBUILT_GFX_TARGETS against a second hardcoded
tuple in the same test file, so a windows-rocm arch newly published by the fork
passed both. Affected hosts would then be routed off the hash-approved fork ROCm
bundle onto an unhashed upstream Vulkan build with nothing failing.

Read the fork's llama-prebuilt-manifest.json through the installer's own
resolver instead, and assert the floor, the family labels, and the routing
tuple all still cover what it publishes. The manifest ships only as a release
asset, so an unreachable release skips with an explicit reason rather than
flaking. Both literals match the manifest as published today.

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

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

* Compress the Vulkan backend routing comments and docstrings for PR #7373

* Correct the family-label rationale in the Windows HIP coverage check

The comment justified serving gfx103X / gfx110X against any repository by
claiming upstream's windows-hip targets build every member of those families.
The fork manifest maps gfx103X to gfx1030..1032 plus gfx1034 and gfx110X to
gfx1100..1102 plus gfx1103, and UPSTREAM_WINDOWS_HIP_GFX_TARGETS carries
neither gfx1034 nor gfx1103, so the stated reason is wrong even though the
answer is right.

State the real reason instead. A family label is a bundle name, not an arch,
so the concrete GPU is unknown at this point; answering unsupported to cover
the two uncovered members would move gfx1030..1032 and gfx1100..1102 off a
working HIP build onto Vulkan for a card the label cannot identify. Those two
archs still reach Vulkan through the concrete-arch branch below, which does
answer per repository.

Comment only. No behaviour change: the 5850-combination override sweep still
reports 0 rocm_gfx_target changes, 0 auto_vulkan False to True flips and 680
True to False flips all backed by a probe-confirmed HIP GPU, and both the
feature and override profile matrices are byte-identical.

* Pin that a deliberate CPU install outranks Vulkan for PR #7373

UNSLOTH_LLAMA_CPP_BACKEND (setup.sh / setup.ps1, "auto" or "cpu") and
UNSLOTH_LLAMA_BACKEND (this module, a backend name) are separate variables at
separate layers, and both accept "cpu". setup translates its own =cpu into
--force-cpu, which is what pins the CPU-only bundle on a GPU host and keeps
Intel iGPU Vulkan crashes away (#7213), so no trigger this PR adds may
outrank it.

_route_to_vulkan_prebuilt already gets this right, since force_cpu
short-circuits ahead of the forced, auto-Intel and auto-no-HIP triggers.
Cover it so it stays that way: the matrix runs [Linux, Windows, macOS] x
[NVIDIA, AMD, Intel, CPU only] x [unset, vulkan, hip, rocm, cpu] with the
legacy UNSLOTH_FORCE_VULKAN set as well, and asserts the published bundle
survives every one. WSL presents as Linux to this resolver, so it rides the
Linux row.

Also assert the guard is not vacuous: the same host still takes Vulkan once
the CPU pin is gone, so the matrix cannot pass on a resolver that had simply
stopped routing to Vulkan.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: LeoBorcherding <borchborchmail@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-27 06:57:19 -07:00
Leo Borcherding
f03e669442
AMD: enable ROCm torch on gfx906 (MI50 / Radeon VII) on Linux (#7354)
* Add community-maintained legacy support path for gfx906 (MI50 / Radeon VII)

rocm6.4+/7.x torch wheels bundle ROCm libraries whose Tensile kernels
dropped gfx906 (rocBLAS 'TensileLibrary.dat ... not read for gfx906',
ROCm/TheRock#1844), so on MI50/Vega 20 hosts with newer ROCm the
installer picked wheels that fail at the first BLAS call. The rocm6.3
index is the last one whose wheels run on gfx906 (torch 2.7.0 verified
on MI50 32GB, up to 2.9 in community use). Dynamo/Inductor codegen is
also broken on this arch, crashing compiled graphs that train fine in
eager mode.

- install.sh: when the runtime GPU is gfx906 and the picked index is
  newer than rocm6.3, reroute torch to the rocm6.3 index and reset the
  constraint trio to the default <2.11 window (a rocm7.2 pick raises
  the floor to 2.11, which rocm6.3 cannot satisfy), with a legacy-path
  warning.
- install_python_stack.py: mirror the reroute in _ensure_rocm_torch
  using the _default pkg specs, including repairing an existing
  +rocm7.x torch and leaving a working rocm6.3 install alone.
- device_type.py: default TORCHDYNAMO_DISABLE / TORCH_COMPILE_DISABLE /
  UNSLOTH_COMPILE_DISABLE on gfx906 (setdefault, user override wins).

Windows allowlists are untouched: repo.amd.com publishes no gfx906
wheel family (verified in the RDNA2 enablement PR). 16-bit LoRA and
full finetuning work out of the box; 4-bit QLoRA needs a source-built
bitsandbytes for gfx906. Based on the verified MI50 32GB setup in
namnguyen0503/mi50-gfx906-unsloth-bnb4bit-lab.

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

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

* gfx906: second Codex pass (bnb skip under pin, override beats Strix)

- Compute the gfx906 runtime-target flag independently of any torch-index
  pin or Strix override, so the bitsandbytes skip still applies when a user
  pins the ROCm index and sets UNSLOTH_ROCM_GFX_ARCH=gfx906 (the pin
  suppresses the torch reroute, not the bnb skip). Probe only when no pin
  is set (an explicit pin means don't second-guess it, matching the Strix
  path's asserted no-probe invariant); an explicit gfx906 override needs
  no probe.
- Let UNSLOTH_ROCM_GFX_ARCH=gfx906 suppress the Strix reroute (both
  install.sh and install_python_stack.py) so a mixed Strix + MI50 host
  routes to rocm6.3 instead of the gfx1151 wheels probe order would pick.
- Fix test_hardcoded_torch_constraint: the default <2.11 window literal now
  legitimately appears on two TORCH_CONSTRAINT= assignments (default + the
  gfx906 reroute reset after the rocm7.2 floor bump); assert it only ever
  appears on assignment lines, never on a pip install line (its real intent).

New tests: bnb skipped under an explicit pin, gfx906 override wins over
Strix, install.sh suppresses Strix on the override. rocm_support +
selection + cross-platform parity: 667 passed; structural constraint 9/9.

* gfx906: collapse single-line asserts to match pre-commit formatting

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

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

* gfx906: keep bnb skip + rocm6.3 routing correct under pins and suffixed overrides

Address the four Codex P2 findings on #7354:

- bnb skip under a pinned index (install.sh + install_python_stack.py):
  a real gfx906 host that pins UNSLOTH_TORCH_INDEX_URL to rocm6.3 without also
  setting UNSLOTH_ROCM_GFX_ARCH no longer reinstalls the generic bitsandbytes
  wheel over a source-built gfx906 bnb. A pin now suppresses only the torch
  reroute, not the gfx906 detection used for the bnb skip (Python drops the pin
  gate on _runtime_is_gfx906; bash _is_gfx906_bnb_skip probes via
  _probe_amd_gfx_arch when the index is pinned).

- clear the Radeon marketing-name flag for every gfx906 target, not only when
  the >=6.4 reroute fires, so a Radeon VII already on rocm6.3 does not divert to
  the repo.radeon.com branch (whose wheels lack gfx906 kernels).

- normalize a copied HIP gcnArchName (gfx906:sramecc-:xnack- -> gfx906) before
  the exact comparisons in install.sh and install_python_stack.py, mirroring
  device_type.py.

Tests: relax the three Strix-pin tests (the gfx probe may now run for the bnb
flag but must not reroute the pinned index) and add coverage for the pinned
bnb skip, the suffixed override, and the bash Radeon-clear / pinned-probe paths.

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

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

* gfx906: log skipped vLLM aimv2 fix + robust source-scan test bounds

Follow-up review polish:
- import_fixes: log at info level when the vLLM aimv2 fix is skipped because
  the dist metadata is unreadable, so the skip is diagnosable instead of silent.
- test_rocm_support: bound the gfx906 install.sh source-scan on the ';;' that
  closes its case arm via a shared _gfx906_reroute_block helper, replacing the
  brittle fixed-length (3200/3800) slices that shift when the block grows.

* gfx906: trim whitespace on UNSLOTH_ROCM_GFX_ARCH in install.sh (py parity)

The bash gfx906 comparisons lowercased and stripped the gfx906:… feature
suffix but not surrounding whitespace, while the Python paths do .strip().
A stray newline (e.g. export UNSLOTH_ROCM_GFX_ARCH=$(cmd)) would make bash
miss gfx906 while Python catches it. Trim with `tr -d '[:space:]'` at both
comparison sites so the reroute target and bnb-skip agree across bash/Python.

* gfx906: remove generic bitsandbytes pulled in transitively after the skip

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-27 05:22:19 -07:00
Daniel Han
ef97f3c961
tests: record the gfx1152 llama.cpp bundle gap so the next one is not silent (#7491)
* tests: record the gfx1152 llama.cpp bundle gap so the next one is not silent

#7431 made gfx1152 (Krackan Point, Radeon 860M/840M) a first-class arch, which
fixed torch wheel selection: those laptops were pulling gfx1150 wheels built for
a different LLVM target. It also changed llama.cpp prebuilt selection, because
no gfx1152 bundle is published. published_rocm_choice_for_host deliberately
refuses to serve a sibling-family bundle, so those hosts now fall back to a HIP
source build.

That is the right outcome, a wrong-ISA binary fails at the first BLAS call
rather than merely installing slowly, but nothing recorded it and nothing would
have caught it. TestPublishedRocmGfxSelection builds its release from a
hardcoded family list, so it can only assert about arches someone already
thought to add.

Adds TestPublishedRocmBundleCoverage:

- PUBLISHED mirrors the mapped_targets in llama-prebuilt-manifest.json.
- KNOWN_GAPS lists arches _GFX_TO_AMD_INDEX_ARCH routes torch for that no bundle
  covers: gfx1033/1035/1036 (RDNA 2, never built) and gfx1152.
- test_known_gaps_fall_back_to_source_build pins each to None.
- test_every_torch_routed_arch_is_covered_or_a_known_gap compares the routed set
  against bundle coverage, so adding an arch for torch without a bundle has to
  be a deliberate KNOWN_GAPS entry.

The invariant fires both ways. Simulating a new routed arch fails with
"coverage drifted: ['gfx1153'] newly uncovered"; simulating a published gfx1152
bundle fails with "gfx1152 is in KNOWN_GAPS but a bundle now matches it; drop it
from the set", so closing the gap cannot leave the list stale.

Reads _GFX_TO_AMD_INDEX_ARCH from source instead of importing
install_python_stack, which this suite does not otherwise depend on.

No production code changes. Install suite 1355 passed, no new failures.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-27 03:46:38 -07:00
Leo Borcherding
1dd2fc4583
tests: read checked-in files as UTF-8 instead of the platform default (#7438)
* tests: read checked-in files as UTF-8 instead of the platform default

Path.read_text() with no encoding uses locale.getpreferredencoding(), which
is UTF-8 on the Linux runners and cp1252 on a stock Windows install. Nine
module-level reads of checked-in source files were relying on that default.

studio/backend/routes/inference.py carries the DeepSeek tool-call token
regexes, so it holds U+FF5C and U+2581. Under cp1252 that read raised
UnicodeDecodeError on byte 0x81 at position 97806, and because the reads run
at import time it took test_cancel_atomicity.py and test_cancel_id_wiring.py
out at collection, not as failures. Green on CI, permanently broken for a
Windows contributor running the suite locally.

Adds a guard: at module scope there is no tmp_path fixture, so a bare
read_text()/write_text()/open() there is always touching a checked-in file.
That makes the rule mechanical enough to enforce with no allowlist, while
staying quiet about temp-dir I/O inside test bodies where the platform
default is harmless.

The repo already spells this correctly in 464 other places; this only stops
the stragglers coming back.

* tests: cover import-time helper reads and keep the guard py3.9-safe

Follows up on the Codex review:

- add `from __future__ import annotations`, since `str | None` in
  `_offender` is evaluated at import on Python 3.9 and pyproject declares
  requires-python ">=3.9,<3.15".
- widen the guard from module scope to import time. Class bodies and the
  bodies of module-level helpers called from an executing statement run
  during collection too, so `CODE = _extract_mixed_precision_code()` was
  the same hazard as an inline read. `if __name__ == "__main__":` blocks
  are skipped: pytest never executes them.
- scan studio/backend/tests/ as well as tests/. Both trees are collected
  on Windows by separate CI jobs, and the offender that started this,
  test_tool_xml_strip.py reading routes/inference.py, lives there.

Widening it surfaced seven more import-time reads of checked-in sources;
all now name utf-8.

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

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

* Harden the import-time encoding guard for PR #7438

Close the detector gaps raised in review, all of which I reproduced against
the actual AST before changing anything.

False negatives (the guard let a real hazard through):
- _is_main_guard ignored the comparison operator, so if __name__ != "__main__"
  counted as script-only even though its body runs at import.
- The else arm of a main guard was discarded with the rest of the If node.
- Decorators and argument defaults on a module-level def were skipped with the
  body, though both are evaluated when the def executes.
- Path.open() in text mode was invisible; only builtin open() was matched.
- encoding = None and encoding = "locale" both re-select the platform default,
  but the keyword merely being present counted as pinned.

False positives (the guard would have blocked a compliant contributor):
- A non-literal mode fell through to the "r" default, so open(p, mode) was
  flagged even when mode is "rb", where adding encoding= is a ValueError and
  there is no edit that satisfies the rule.
- Same for open(*args) and a **kwargs splat, which hide the mode and can hide
  an encoding.
- Lambda bodies and comprehension elements were walked even though neither runs
  at definition.

Verified: still reports the same 22 offenders on unpatched main, green on this
branch and on the tree merged with latest main (557 files), and an adversarial
corpus of 33 cases now scores zero false positives and zero false negatives.
Also corrected two docstring claims: neither collecting job runs on Windows,
and the read is governed by locale.getencoding().

* Walk eager comprehensions and treat io.open as the builtin

Two regressions from the previous commit, both reproduced against the AST
before changing anything.

Lumping list, set and dict comprehensions in with generator expressions was
wrong. Only a genexp is lazy; the other three run their element expression,
their filters and their nested iterators immediately, so
CONTENTS = [p.read_text() for p in PATHS] at module scope is an import-time
read the guard was silently missing. Comprehensions are now walked in full and
only the genexp keeps the outermost-iterable-only treatment.

io was also in the not-a-path-opener list, but io.open is the builtin, with the
same mode position and the same platform default. io.open(CHECKED_IN_FILE) is
exactly the hazard this guard exists for, so it is matched now, with binary
modes and a pinned encoding still exempt. tarfile.open and fitz.open stay
exempt since neither has an encoding to name.

Verified: 13 targeted cases covering all five eager comprehension forms and
io.open in text, binary and pinned shapes all classify correctly; still 22
offenders on unpatched main; green on this branch and on the tree merged with
latest main.

* Close three more walker gaps in the import-time guard

All three reproduced against the AST first.

A generator expression handed straight to a call is consumed there, so
DATA = "".join(p.read_text() for p in paths) runs its element at import. Only
an unconsumed genexp bound to a name stays lazy, so the walker now follows the
consumed ones in full and keeps the outermost-iterable-only treatment for the
rest.

if "__main__" == __name__ is an equivalent and accepted spelling of the main
guard, but requiring __name__ on the left meant its body was treated as
import-time code. That is a false positive on a block pytest never runs, so
both operand orders are recognised now.

The helper table was built from module-level defs only, so a def in a class
body invoked while the class is constructed was never followed, contradicting
the walker's stated coverage of class bodies. Helpers are now collected from
the module body and from class bodies at any nesting.

Verified: 15 targeted cases including all three fixes and the earlier ones
still classify correctly; still 22 offenders on unpatched main; green on this
branch and on the tree merged with latest main.

* Handle positional read_text encodings, lazy generators and nested helpers

* Guard reads reached from test bodies, unbound Path calls and __file__ paths

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

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

* Follow derived paths, skip lazy generator helpers, cover compressed openers

* Guard the CLI tests, helper parameters and unbound Path arguments

* Discover test roots and follow literal, in-place and tuple-derived paths

* Identify module openers by import, unwrap starred paths, pin subprocess snippets

* Resolve import origins, seed helper locals, follow named generators and parametrize

* Scope imports lexically, list tracked test files, bind unpacked names

* Resolve aliased openers, keyword-only params, destructured targets, next()

* Pin the encoding on subprocess snippets, workflow lint and CLI output for PR #7438

* Harden the CLI encoding guard against detached streams for PR #7438

* Tighten the encoding guard's path and scope analysis for PR #7438

* Resolve path provenance more precisely and keep POSIX stream encodings for PR #7438

* Resolve qualified path classes and scope conditional imports for PR #7438

* Scope CLI stream setup to the entry point and align two encoding pairs for PR #7438

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-26 23:31:56 -07:00
Daniel Han
170b412c1d
Fix the CPU-only ROCm routing errors and two font-scale UI flakes (#7469)
* Fix the CPU-only ROCm routing errors and two font-scale UI flakes

Two unrelated causes of red CI on every PR, both reproduced before fixing.

ROCm routing: 12 errors on Repo tests (CPU). The spoof reports an AMD GPU, and
unsloth_zoo pulls in bitsandbytes, which picks a compute backend at import. Once
torch looks like a GPU is present, bnb loads its ROCm/CUDA ops, which a CPU-only
torch cannot satisfy (no libhipblas.so.2, no torch._C._cuda_getCurrentRawStream),
so the child died before printing RESULT. Nothing here tests bitsandbytes, so
import it first, under the honest hardware. Reproduced in a CPU-only torch venv:
11 passed with 12 errors before, 23 passed after. Still 23 passed on a CUDA build.

Font-scale UI: the select-viewport step pressed ArrowDown six times behind fixed
sleeps, but Radix moves focus into the listbox after the content opens, so on a
loaded runner the keys landed on the trigger and nothing scrolled. Wait on the
overflow and press until it moves, bounded at 40. The same fixed-sleep pattern
made open_appearance miss the dialog when the shortcut fired before the app wired
its handler; alternate both chords on a bounded retry and wait for the control the
caller is about to drive.

Both were reproduced locally by running the suite against a real Studio under full
CPU load. Original: 2 of 10 passed, with the exact CI signature 'keyboard did not
scroll the select viewport: 0' five times. Fixed: 10 of 10.

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

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

* Keep the ROCm routing assertion live on Apple Silicon for PR #7469

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

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

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-26 04:48:49 -07:00
Nilay
ae6b96ba93
Studio: fail fast on out-of-disk instead of a doomed llama.cpp source build (#7420)
* guard llama.cpp prebuilt against out-of-disk instead of doomed source build

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

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

* address review comments on out-of-disk guard

* keep reusable installs and Windows parity in the out-of-disk guard

* preserve the ENOSPC cause when re-raising fallback errors

* catch out-of-disk before the attempt loop and accept all llama-server layouts

* Fix out-of-disk detection gaps and false positives for PR #7420

Follow-ups found while testing the guard against a real ENOSPC (LD_PRELOAD
shim returning errno 28 under a path prefix, real network, real release):

- hydrate_source_tree retried the next mirror after an ENOSPC and only raised
  on the last URL. Both source fallbacks 404 for the published mix commit, so
  the reported cause was HTTP 404 and the run fell through to the source build
  exactly like before the guard. Stop at the first environment-fatal error.
- The 5 GB preflight rejected hosts that install fine. A full CUDA install
  peaks at 0.87 GB, the largest published bundle is 0.77 GB and macOS is
  0.01 GB, so at 3 GB free the install succeeded before and exited 4 after,
  with the source-build fallback suppressed too. It is now advisory, and a
  real ENOSPC still exits 4. This also drops the case where an install
  matching an older release plan was rejected before its reuse check.
- ENOSPC raised inside shutil.copytree arrives as shutil.Error with errno
  None and no __cause__ or __context__, so it was never classified. That path
  covers the hydrated source tree, the runtime overlay and the activation
  fallback copy.
- _causal_chain followed __context__ even when __suppress_context__ was set,
  so `raise ... from None` over an unrelated ENOSPC reported disk full and
  wrongly suppressed the source build.
- TemporaryDirectory now ignores cleanup errors: an rmtree failure on the way
  out replaced the in-flight SystemExit and lost EXIT_NO_SPACE.
- setup.sh skips the arm64 CPU last resort after exit 4; it re-ran the same
  disk-rejected installer and buried the hint under a second error dump.
- The in-app updater turns exit 4 into a readable message instead of
  "installer exited 4" plus a log tail.

Adds tests/studio/install/test_llama_prebuilt_no_space.py covering the
classifier, the advisory warning and the exit codes.

* Fix Python 3.9 breakage and Windows disk-full detection in the out-of-disk guard

Found by running the guard across the whole supported interpreter range
(requires-python is >=3.9,<3.15) and a spoofed [Linux, WSL, macOS, Windows] x
[NVIDIA, AMD, CPU] host matrix.

- TemporaryDirectory(ignore_cleanup_errors = True) is 3.10+, so the previous
  commit raised TypeError at install time on 3.9 and turned a working install
  into a hard failure. Replaced with a scratch_dir() contextmanager built on
  mkdtemp plus rmtree(ignore_errors = True), which behaves the same on every
  supported version.
- getattr(exc, "winerror", None) crashed on 3.9. urllib's HTTPError is an
  OSError that proxies unknown attributes to a wrapped file object and raises
  KeyError, which getattr does not swallow, so any mirror 404 during an install
  would have blown up inside the classifier. Read it defensively instead.
- Classify Windows disk-full by winerror as well as errno. CPython's
  PC/errmap.h maps ERROR_DISK_FULL (112) to ENOSPC but has no case for
  ERROR_HANDLE_DISK_FULL (39), which arrives as EINVAL, so a Windows
  os.replace() onto a full disk read as an ordinary failure and fell through to
  the source build.

Tests cover both winerror codes, a non-disk winerror, and HTTPError alone and
wrapped in a PrebuiltFallback. 116 simulation cases pass on 3.9 through 3.14.

* Classify quota, flattened Windows and validate-install out-of-disk for PR #7420

- EDQUOT counts as out of space: a quota'd home has free blocks this user
  cannot have, so the source build is just as doomed. Reported separately so
  df does not mislead. Confirmed end to end with a real kernel EDQUOT: the
  installer went from 6 retries then a source build (exit 2) to exit 4.
- Match the flattened Windows disk-full text. copytree stringifies each
  per-file OSError, and OSError.__str__ returns early on winerror, so the
  text reads [WinError 112] and never [Errno 28]. Captured on a real NTFS
  volume. Markers are bracketed so WinError 112 does not match WinError 1120.
- --validate-install now exits 4 on a full disk. It caught PrebuiltFallback
  and exited 2 before the classifier ran, and setup.sh answered 2 by deleting
  the GPU build that had just succeeded and starting a CPU rebuild that needs
  more of the space that ran out. Both halves are needed: the call site only
  tested nonzero.

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

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

* Tighten comments in the llama.cpp out-of-disk guard

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-26 00:11:38 -07:00
Leo Borcherding
3ea6d14c39
AMD: CI coverage for recent fixes, plus three wrong gfx ids (#7431)
* ROCm/AMD CI coverage: arch-table parity, native-Linux lib prepend, RDNA4 grouped_mm, discovery-based shell suite

Three merged ROCm fixes shipped without tests, and the CI wiring that
would have run them was gated on files the fixes do not touch.

Tests added (113):
  tests/studio/install/test_rocm_arch_table_parity.py (27)
    diffs the four duplicated gfx -> AMD pip-index tables across
    install.sh, install.ps1, studio/setup.ps1 and install_python_stack.py,
    plus the GPU-name -> arch tables and the torch 2.11 pin allowlist.
  tests/studio/install/test_rocm_native_linux_lib_dirs.py (26)
    covers #7233: system-ROCm lib dirs prepended ahead of bundled
    libggml-hip, the /dev/kfd + not-WSL + libhsa gate, the opt-out env
    var, root resolution order, and source parity between the two copies.
  studio/backend/tests/test_grouped_mm_rdna4_fallback.py (46)
    covers #7292: registration on the CUDA dispatch key, grouped and
    ungrouped numerics, bias/dtype promotion, and the Linux HIP<7.13 +
    RDNA4 name gate, executed from the shipped source rather than a copy.
  tests/studio/test_ci_shell_suite_coverage.py (14)
    fails if either shell runner goes back to a hardcoded list or skips
    a file without a recorded reason.

CI wiring:
  studio-backend-ci.yml: add install.sh / install.ps1 to the path filter
    (the suites it runs assert against those two files, so install-only
    changes -- the shape most AMD/ROCm routing fixes take -- skipped it),
    and replace the 13-file hardcoded shell list with directory
    discovery. That list had fallen seven files behind, including
    test_strixhalo_wsl_reroute.sh, the only shell coverage of the ROCm
    WSL reroute, which had never run on a PR.
  tests/run_all.sh: same discovery loop so local and CI agree.

* Test review fixes: assert on outcomes, not on the code under test

Self-review of the previous commit found four tests that passed for the
wrong reason.

1. The arch-table parity test pinned expected gfx ids copied out of the
   shipped tables, which enshrined three upstream inaccuracies as
   correct: RX 9070 (non-XT) is gfx1201 not gfx1200, RX 7800 XT is
   gfx1101 not gfx1100, and PRO V710 is gfx1101 not gfx1102 per AMD's
   ROCm compatibility matrix. The expectation is now the AMD pip index
   leaf -- the thing the tables exist to produce, and what a wrong
   answer costs the user. The three known drifts are listed explicitly
   with a test asserting they stay cosmetic, i.e. that the wrong and
   right ids still map to the same wheel index. That test turns red the
   day one of them starts routing users to the wrong wheel.

2. The RDNA4 device-name test extracted the regex from worker.py and
   then matched with it, so it could not fail. Widening the pattern --
   the dangerous edit, since it forces the slow Python mm fallback onto
   RDNA3 users -- would have been silently accepted. It now reads the
   live pattern and checks it against fixed cases, plus asserts the
   name match stays guarded by `not _lin_arch` and that the name is
   lowercased before matching.

3. The CI-coverage test matched a verbatim line of studio-backend-ci.yml,
   so reindenting the step would fail the build while a real regression
   to a hardcoded list could slip past a reformat. It now parses the
   YAML, finds the step by name, and asserts on the glob plus the
   absence of individual filenames. The path-filter test likewise reads
   the parsed trigger instead of scanning raw text.

4. A set comprehension in the parity helper had a ternary whose branches
   were identical.

Mutation-tested: widening the RDNA4 regex, desyncing one copy of the
name table, dropping install.sh from the path filter, and re-skipping
the ROCm WSL shell suite each fail at least two tests. Verified on
Linux (WSL Ubuntu 24.04) with CI's torch pin: 86 + 48 pass.

* Fix three wrong gfx ids in the GPU-name arch tables

The name -> gfx tables disagreed with AMD's ROCm compatibility matrix on
three entries. Corrected against the "Radeon GPU" list at
rocm.docs.amd.com/en/latest/compatibility/compatibility-matrix.html:

  RX 9070, RX 9070 GRE   gfx1200 -> gfx1201   (Navi 48, same die as the XT)
  RX 7800 XT, RX 7700 XT gfx1100 -> gfx1101   (Navi 32, not Navi 31)
  PRO W7700              gfx1100 -> gfx1101
  PRO V710               gfx1102 -> gfx1101   (Navi 32, not Navi 33)

No wheel changes for anyone: gfx1200/gfx1201 both resolve to gfx120X-all
and gfx1100/gfx1101/gfx1102 all resolve to gfx110X-all, in all four copies
of the index-family map. That collapse is why the errors survived being
copied into six places -- the leaf-level tests could not see them.

It was not purely cosmetic, though. install.sh's second copy feeds
"Tip: set UNSLOTH_ROCM_GFX_ARCH=<arch>", so a 7800 XT user following the
printed advice exported gfx1100 and made a wrong id authoritative for
every later run. It would also have become a real misroute the moment AMD
split a family across index leaves, as they already do for gfx1151/gfx1150.

Fixed in all six places, which is two more than the table's own "kept in
sync with" comments claim exist:

  install.sh   _infer_amd_gfx_arch_from_gpu_name
  install.sh   case "$_gpu_disp_mkt"          (banner + env tip; undocumented)
  studio/setup.sh
  install.ps1
  studio/setup.ps1
  studio/install_python_stack.py

Ordering is preserved: the gfx1102 arm still precedes gfx1101 in the shell
copies so "RX 7700S" cannot fall onto the "RX 7700" glob, and the
PowerShell copies keep the (?!S) lookahead.

Test changes:
  - test_rocm_arch_table_parity.py gains _AMD_DOCUMENTED_ARCH, exact gfx
    ids transcribed from AMD rather than from the tables. Agreement between
    six copies proves nothing when all six were transcribed from the same
    mistake, so the ground truth has to come from outside. Verified it
    catches the bug: against the pre-fix tables it fails 6 tests.
  - The parity check now covers all six copies. It had four; the two
    install.sh copies were being treated as one, and
    _WIN_GPU_NAME_ARCH_TABLE was not checked at all.
  - test_rocm_support.py's TestGfxArchNameFallback pinned two of the wrong
    ids as expected values; updated, and extended with a 9060 XT and a
    7900 XTX case so each RDNA3/4 die is represented.

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

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

* Guard against unregistered copies of the GPU-name arch table

Counting the copies by hand is what let them drift: the in-code "kept in
sync with" comments claimed four, the arch-id fix found six, and scanning
the tree turns up a seventh.

TestNoUnregisteredArchTable rediscovers the copies from the source tree
instead of trusting a hand-maintained list. A table line is one that names
a card and gives its arch; real tables score 9-17 such lines and the only
other hits in the repo are two single-line prose comments, so the
three-line threshold is not load-bearing. A companion test asserts the
scan still finds the known copies, so the heuristic cannot go blind and
pass by finding nothing.

The seventh copy is tests/_zoo_rocm_spoof.py, the fixture other ROCm tests
build their fake AMD host from. It states the mapping backwards (gfx ->
the name torch should report), which makes it an independent witness: it
had gfx1101 -> RX 7800 XT and gfx1201 -> RX 9070 XT right while all six
installer copies were wrong, and nothing compared the two. Now they are
round-tripped against each other.

RX 6700 XT is pinned as a known divergence rather than normalised. AMD's
compatibility matrix documents no consumer RX 6000 card and no gfx1031 at
all, the installer arm is commented "gfx103X family", and gfx1031 appears
only as an index-family key, never as a value a name table emits. With no
external source to correct against, changing shipped behaviour would be
guesswork. A test fails if the divergence ever disappears, so the
exemption cannot go stale.

Also adds the reverse of the AMD-matrix check: a documented card that
matches no arm anywhere is a silent CPU fallback rather than a wrong id.
This cannot detect hardware nobody transcribed, which would need a live
fetch of AMD's matrix and a non-hermetic suite; the docstring says so
rather than implying coverage that is not there.

Verified on Linux: 478 passed, plus all five new guards mutation-tested
to confirm each fails when its invariant is broken.

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

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

* Docstring said six copies; the list under it now has seven

* tests: run discovered shell tests with bash, not sh

tests/run_all.sh discovered tests/sh/ instead of listing files, but still
invoked each one with sh. Every file there declares a bash shebang, and on
Debian/Ubuntu /bin/sh is dash: test_apt_distro_prompt.sh,
test_studio_home_node_dir.sh and test_with_llama_cpp_dir_link_behavior.sh
fail on bashisms under dash and pass under bash. The old hand-written list
happened to name only dash-clean files, so switching to discovery is what
surfaced it. Backend CI already used bash, so this was a local-only break.

Guarded by a new test asserting both runners invoke tests/sh/ with bash.

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

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

* Fix Krackan Point (Radeon 860M/840M) routed to the gfx1150 wheel index

The GPU-name tables map 860M/840M and the Ryzen AI 7 350 / AI 5 340 CPU
strings to gfx1150, but Krackan Point is gfx1152. AMD's own lemonade table
(src/cpp/server/system_info.cpp) maps both Krackan iGPUs to gfx1152.

Unlike the three ids already fixed here, this one is not wheel-neutral:
repo.amd.com publishes gfx1150 and gfx1152 as separate index leaves with
separately built torch wheels, so these laptops were installing wheels
built for a different LLVM target. gfx1152 was absent from the codebase
entirely, so it needed the index-family maps, the torch 2.11 floor lists
(same _grouped_mm bug as gfx1150/1151), the Strix reroute set and the
Windows arch allowlist as well as the seven name tables.

The parity test added in this PR did not catch it because its AMD-matrix
expectations stopped at 890M/880M. Added the APU rows, so the case that
actually changes a wheel is now covered: reverting the tables fails 9
tests naming 860M, 840M and Krackan.

gfx1153 (Ryzen AI 5 430 era) is left alone; AMD publishes no gfx1153
wheel family, so there is nothing to route it to.

Verified: bash -n on both shell installers, PowerShell AST parse on both
.ps1 files, python ast.parse on all touched modules, install suite 1334
passed with no new failures against main, shell suite 20 files.

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

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

* Add gfx1152 to unified-memory classifiers, make parity allowlist set-based

Krackan Point (gfx1152, Radeon 860M/840M) is the third RDNA 3.5 APU and
shares one GPU/system-RAM pool exactly like Strix Point (gfx1150) and
Strix Halo (gfx1151), but only the installers knew about it. The two
runtime classifiers still had two-element arch sets, so a Krackan laptop
got the 0.90 discrete headroom factor on a shared pool and ran llama.cpp
without GGML_CUDA_ENABLE_UNIFIED_MEMORY.

- worker.py _rocm_classify_unified_memory: add gfx1152 to the arch set,
  and 860m/840m to the device-name fallback. The NVIDIA GeForce 840M
  cannot collide there: the function is only reached under _hw.IS_ROCM.
- llama_cpp.py _amd_apu_wants_unified_memory: add gfx1152 to the arch set.
- Tests for both, including the :sramecc-:xnack- suffix form.

TestGfx211AllowlistParity compared four hardcoded allowlist strings, so
adding gfx1152 to all four installers correctly turned three assertions
red without any installer actually disagreeing with another. Each test
now extracts the set its installer holds and compares it to one EXPECTED
constant. Order and spacing are free, membership is not, and the next
leaf is a one-line edit instead of four.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-25 18:58:02 -05:00
Daniel Han
6f60bf4f82
Studio whisper: pair slim bundles on the ggml commit, not the full llama tag (#7381)
The slim whisper bundle is ggml-less and links the ggml runtime out of the
installed llama.cpp prebuilt, so each whisper release pins a paired llama tag.
The gate required an exact tag match, but llama fork tags are
b<upstream_build>-mix-<ggml_commit> and the build number tracks upstream llama
and fork PRs that live outside ggml. When llama republishes a newer build with
the same ggml commit (a frequent event), the installed llama advances past the
whisper pin and curated dictation goes unavailable until whisper is republished,
even though the ggml runtime is ABI-identical.

Key the pairing gate on the ggml commit after -mix- instead of the full tag, in
all three comparison sites (slim_pairing_for_artifact,
_slim_release_incompatibility, resolve_selection). requires_ggml_sonames stays
the real per-file ABI gate, and a genuine ggml skew still fails closed. Tags
without a -mix- marker fall back to exact matching.
2026-07-23 20:18:36 -07:00